Compare commits
2
Commits
d7fcda345d
...
8ccb7157b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ccb7157b2 | ||
|
|
063a493b18 |
@@ -10,7 +10,7 @@ end
|
|||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
apply_settings = lambda do |config|
|
apply_settings = lambda do |config|
|
||||||
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
config.build_settings['ENABLE_USER_SCRIPT_SANDBOXING'] = 'NO'
|
||||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.6'
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
|
||||||
end
|
end
|
||||||
|
|
||||||
# Pods project (Pods.xcodeproj)
|
# Pods project (Pods.xcodeproj)
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41
|
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb
|
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
"swift_versions": [
|
"swift_versions": [
|
||||||
"5.10"
|
"5.10"
|
||||||
],
|
],
|
||||||
"homepage": "https://github.com/namesubai/RDEpubReaderView.git",
|
"homepage": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
|
||||||
"authors": {
|
"authors": {
|
||||||
"subai": "804663401@qq.com"
|
"shenlei": "shenlei@touchread.com"
|
||||||
},
|
},
|
||||||
"source": {
|
"source": {
|
||||||
"git": "https://github.com/namesubai/RDEpubReaderView.git",
|
"git": "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git",
|
||||||
"tag": "0.0.1"
|
"tag": "0.0.1"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Generated
+2
-2
@@ -37,9 +37,9 @@ EXTERNAL SOURCES:
|
|||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||||
RDEpubReaderView: 603761e6d74a34230472bf53138942168e350d41
|
RDEpubReaderView: 5ff99b803f5a46e5ff4ebef3024dd217284c69ed
|
||||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||||
|
|
||||||
PODFILE CHECKSUM: 966c18a0a8d86d2f90e367ab41338d4e476d39bb
|
PODFILE CHECKSUM: adda84af3e8577b2a99d5b2cecf381511352f0bd
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
+1599
-1595
File diff suppressed because it is too large
Load Diff
@@ -329,14 +329,10 @@
|
|||||||
inputFileListPaths = (
|
inputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
);
|
);
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputFileListPaths = (
|
outputFileListPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
);
|
);
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo-frameworks.sh\"\n";
|
||||||
@@ -402,7 +398,7 @@
|
|||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||||
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -427,7 +423,7 @@
|
|||||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MG4Z7FU83W;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = cn.shen.ReadViewDemoUITests;
|
||||||
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
PRODUCT_MODULE_NAME = ReadViewDemoUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -460,7 +456,7 @@
|
|||||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -499,7 +495,7 @@
|
|||||||
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
INFOPLIST_KEY_UIMainStoryboardFile = Main;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -571,7 +567,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -628,7 +624,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ enum IDs {
|
|||||||
static let settingsFontValue = "epub.reader.settings.font.value"
|
static let settingsFontValue = "epub.reader.settings.font.value"
|
||||||
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
static let settingsFontChoice = "epub.reader.settings.font.choice"
|
||||||
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
static let settingsLineHeight = "epub.reader.settings.lineHeight"
|
||||||
static let settingsColumns = "epub.reader.settings.columns"
|
|
||||||
static let settingsDisplayType = "epub.reader.settings.displayType"
|
static let settingsDisplayType = "epub.reader.settings.displayType"
|
||||||
static let settingsDone = "epub.reader.settings.done"
|
static let settingsDone = "epub.reader.settings.done"
|
||||||
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
static func settingsTheme(_ index: Int) -> String { "epub.reader.settings.theme.\(index)" }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct DemoReaderState {
|
|||||||
|
|
||||||
var isOpened: Bool { fields["reader"] == "opened" }
|
var isOpened: Bool { fields["reader"] == "opened" }
|
||||||
var page: Int? { fields["page"].flatMap(Int.init) }
|
var page: Int? { fields["page"].flatMap(Int.init) }
|
||||||
|
var pagesPerScreen: Int? { fields["pagesPerScreen"].flatMap(Int.init) }
|
||||||
var display: String? { fields["display"] }
|
var display: String? { fields["display"] }
|
||||||
var toolbar: String? { fields["toolbar"] }
|
var toolbar: String? { fields["toolbar"] }
|
||||||
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
var highlights: Int? { fields["highlights"].flatMap(Int.init) }
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ final class FixedLayoutRotationTests: XCTestCase {
|
|||||||
for _ in 0..<3 {
|
for _ in 0..<3 {
|
||||||
XCUIDevice.shared.orientation = .landscapeLeft
|
XCUIDevice.shared.orientation = .landscapeLeft
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||||
|
let landscapeState = app.waitForDemoReaderState(
|
||||||
|
timeout: 5,
|
||||||
|
description: "固定版式横屏保持单页"
|
||||||
|
) { $0.pagesPerScreen == 1 }
|
||||||
|
XCTAssertEqual(landscapeState.pagesPerScreen, 1, "交互型固定版式书横屏不应启用双页")
|
||||||
XCUIDevice.shared.orientation = .portrait
|
XCUIDevice.shared.orientation = .portrait
|
||||||
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||||
}
|
}
|
||||||
@@ -30,4 +35,29 @@ final class FixedLayoutRotationTests: XCTestCase {
|
|||||||
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
XCTAssertEqual(app.state, .runningForeground, "反复旋转后 app 已退出(疑似崩溃)")
|
||||||
app.waitForReader()
|
app.waitForReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFixedLayoutCachedPageCanReturnAfterPageTurn() {
|
||||||
|
app.launchAndOpenSampleBook(bookTitleQuery: "熊爷爷")
|
||||||
|
app.waitForReader()
|
||||||
|
|
||||||
|
let content = app.otherElements[IDs.readerContent]
|
||||||
|
XCTAssertTrue(content.waitForExistence(timeout: 5), "固定版式阅读区域未出现")
|
||||||
|
let initialState = app.waitForDemoReaderState(timeout: 5, description: "固定版式初始页") {
|
||||||
|
$0.page != nil
|
||||||
|
}
|
||||||
|
let initialPage = initialState.page ?? 0
|
||||||
|
|
||||||
|
content.swipeLeft()
|
||||||
|
let nextState = app.waitForDemoReaderState(timeout: 8, description: "固定版式向后翻页") { state in
|
||||||
|
guard let page = state.page else { return false }
|
||||||
|
return page != initialPage
|
||||||
|
}
|
||||||
|
|
||||||
|
content.swipeRight()
|
||||||
|
let returnedState = app.waitForDemoReaderState(timeout: 8, description: "固定版式返回缓存页") {
|
||||||
|
$0.page == initialPage
|
||||||
|
}
|
||||||
|
XCTAssertNotEqual(nextState.page, returnedState.page, "固定版式返回缓存页后页码未恢复")
|
||||||
|
XCTAssertTrue(content.exists, "返回缓存页后阅读区域不应消失")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
else if app.buttons["高亮"].waitForExistence(timeout: 2) { app.buttons["高亮"].tap() }
|
else if app.buttons["划线"].waitForExistence(timeout: 2) { app.buttons["划线"].tap() }
|
||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
app.waitForDemoReaderState(timeout: 5, description: "highlights exists") { $0.highlights != nil }
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@@ -63,7 +63,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@@ -102,7 +102,7 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
if highlightMenuItem.waitForExistence(timeout: 3) { highlightMenuItem.tap() }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@@ -112,15 +112,14 @@ final class HighlightsManagementTests: XCTestCase {
|
|||||||
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
XCTAssertTrue(highlightsTable.waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
XCTAssertTrue(waitForCellCount(in: highlightsTable, minimum: 1, timeout: 5))
|
||||||
|
|
||||||
highlightsTable.cells.element(boundBy: 0).tap()
|
let editButton = app.navigationBars.buttons["编辑"].firstMatch
|
||||||
|
XCTAssertTrue(editButton.waitForExistence(timeout: 3), "标注列表应提供编辑入口")
|
||||||
|
editButton.tap()
|
||||||
|
|
||||||
let deleteButton = app.buttons["删除标注"].firstMatch
|
let deleteControl = highlightsTable.buttons["删除"].firstMatch
|
||||||
let deleteHighlightButton = app.buttons["删除高亮"].firstMatch
|
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
|
||||||
if deleteButton.waitForExistence(timeout: 3) {
|
deleteControl.tap()
|
||||||
deleteButton.tap()
|
app.buttons["删除"].lastMatch.tap()
|
||||||
} else if deleteHighlightButton.waitForExistence(timeout: 1) {
|
|
||||||
deleteHighlightButton.tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,8 +136,8 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5))
|
||||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.55, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
XCTAssertTrue(app.menuItems["高亮"].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.menuItems["划线"].waitForExistence(timeout: 3))
|
||||||
app.menuItems["高亮"].tap()
|
app.menuItems["划线"].tap()
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
app.showReaderChromeIfNeeded()
|
||||||
@@ -167,7 +167,7 @@ final class LocationPersistenceTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let highlightItem = app.menuItems["高亮"]
|
let highlightItem = app.menuItems["划线"]
|
||||||
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
XCTAssertTrue(highlightItem.waitForExistence(timeout: 3), "应出现高亮菜单")
|
||||||
highlightItem.tap()
|
highlightItem.tap()
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
app.waitForDemoReaderState(timeout: 8, description: "highlights=1") { $0.highlights == 1 }
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
let highlightMenuItem = app.menuItems["高亮"]
|
let highlightMenuItem = app.menuItems["划线"]
|
||||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||||
if highlightMenuItem.waitForExistence(timeout: 3) {
|
if highlightMenuItem.waitForExistence(timeout: 3) {
|
||||||
highlightMenuItem.tap()
|
highlightMenuItem.tap()
|
||||||
@@ -65,7 +65,7 @@ final class ReaderAnnotationTests: XCTestCase {
|
|||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
app.waitForDemoReaderState(timeout: 5, description: "selection=1") { $0.selection == 1 }
|
||||||
|
|
||||||
let annotateMenuItem = app.menuItems["批注"]
|
let annotateMenuItem = app.menuItems["注释"]
|
||||||
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
XCTAssertTrue(annotateMenuItem.waitForExistence(timeout: 3), "选中文本后未出现批注菜单")
|
||||||
annotateMenuItem.tap()
|
annotateMenuItem.tap()
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
// 点击批注菜单项
|
// 点击批注菜单项
|
||||||
let annotateItem = app.menuItems["批注"]
|
let annotateItem = app.menuItems["注释"]
|
||||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("批注菜单项不存在")
|
throw XCTSkip("批注菜单项不存在")
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
|
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
let annotateItem = app.menuItems["批注"]
|
let annotateItem = app.menuItems["注释"]
|
||||||
guard annotateItem.waitForExistence(timeout: 3) else {
|
guard annotateItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("批注菜单项不存在")
|
throw XCTSkip("批注菜单项不存在")
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ final class SelectionAnnotateTests: XCTestCase {
|
|||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
Thread.sleep(forTimeInterval: 0.5)
|
Thread.sleep(forTimeInterval: 0.5)
|
||||||
|
|
||||||
let highlightItem = app.menuItems["高亮"]
|
let highlightItem = app.menuItems["划线"]
|
||||||
guard highlightItem.waitForExistence(timeout: 3) else {
|
guard highlightItem.waitForExistence(timeout: 3) else {
|
||||||
throw XCTSkip("高亮菜单项不存在")
|
throw XCTSkip("高亮菜单项不存在")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应包含拷贝选项")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSelectionMenuShowsAnnotateOption() throws {
|
func testSelectionMenuShowsAnnotateOption() throws {
|
||||||
@@ -32,7 +32,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["批注"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
XCTAssertTrue(app.menuItems["注释"].waitForExistence(timeout: 3), "选区菜单应包含批注选项")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testTapBlankAreaClearsSelection() throws {
|
func testTapBlankAreaClearsSelection() throws {
|
||||||
@@ -46,14 +46,14 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
XCTAssertTrue(app.menuItems["拷贝"].waitForExistence(timeout: 3), "选区菜单应出现")
|
XCTAssertTrue(app.menuItems["复制"].waitForExistence(timeout: 3), "选区菜单应出现")
|
||||||
|
|
||||||
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
let contentArea = app.otherElements[IDs.readerContentView].firstMatch
|
||||||
if contentArea.waitForExistence(timeout: 3) {
|
if contentArea.waitForExistence(timeout: 3) {
|
||||||
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
contentArea.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap()
|
||||||
}
|
}
|
||||||
|
|
||||||
let menuGone = !app.menuItems["拷贝"].waitForExistence(timeout: 2)
|
let menuGone = !app.menuItems["复制"].waitForExistence(timeout: 2)
|
||||||
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
XCTAssertTrue(menuGone, "点击空白区域后选区菜单应消失")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ final class SelectionMenuTests: XCTestCase {
|
|||||||
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
let end = selectionText.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.5))
|
||||||
start.press(forDuration: 0.8, thenDragTo: end)
|
start.press(forDuration: 0.8, thenDragTo: end)
|
||||||
|
|
||||||
let copyMenuItem = app.menuItems["拷贝"]
|
let copyMenuItem = app.menuItems["复制"]
|
||||||
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
if copyMenuItem.waitForExistence(timeout: 3) { copyMenuItem.tap() }
|
||||||
|
|
||||||
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
app.waitForDemoReaderState(timeout: 5, description: "reader=opened") { $0.isOpened }
|
||||||
|
|||||||
@@ -44,24 +44,6 @@ final class SettingsEffectTests: XCTestCase {
|
|||||||
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
XCTAssertTrue(contentView.waitForExistence(timeout: 5), "主题切换后内容区应存在")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testColumnCountChangeUpdatesLayout() throws {
|
|
||||||
app.launchAndOpenSampleBook()
|
|
||||||
app.waitForReader(timeout: 12)
|
|
||||||
|
|
||||||
app.showReaderChromeIfNeeded()
|
|
||||||
app.buttons[IDs.readerSettings].tap()
|
|
||||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5))
|
|
||||||
app.scrollViews[IDs.settingsScroll].swipeUp()
|
|
||||||
|
|
||||||
let columnsControl = app.segmentedControls[IDs.settingsColumns]
|
|
||||||
if columnsControl.waitForExistence(timeout: 3) && columnsControl.buttons.count > 1 {
|
|
||||||
columnsControl.buttons.element(boundBy: 1).tap()
|
|
||||||
}
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
|
||||||
app.waitForDemoReaderState(timeout: 8, description: "reader=opened") { $0.isOpened }
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLineHeightChangeUpdatesContent() throws {
|
func testLineHeightChangeUpdatesContent() throws {
|
||||||
app.launchAndOpenSampleBook()
|
app.launchAndOpenSampleBook()
|
||||||
app.waitForReader(timeout: 12)
|
app.waitForReader(timeout: 12)
|
||||||
|
|||||||
@@ -36,18 +36,6 @@ final class SettingsExtendedTests: XCTestCase {
|
|||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testColumnCountControl() {
|
|
||||||
openSettings()
|
|
||||||
let columns = app.segmentedControls[IDs.settingsColumns]
|
|
||||||
XCTAssertTrue(columns.waitForExistence(timeout: 3), "分栏控件未出现")
|
|
||||||
|
|
||||||
columns.buttons["单栏"].tap()
|
|
||||||
columns.buttons["双栏"].tap()
|
|
||||||
|
|
||||||
app.buttons[IDs.settingsDone].tap()
|
|
||||||
XCTAssertTrue(app.buttons[IDs.readerBack].waitForExistence(timeout: 5))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDisplayTypeFromSettings() {
|
func testDisplayTypeFromSettings() {
|
||||||
openSettings()
|
openSettings()
|
||||||
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
let displayType = app.segmentedControls[IDs.settingsDisplayType]
|
||||||
@@ -73,7 +61,6 @@ final class SettingsExtendedTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.sliders[IDs.settingsBrightness].waitForExistence(timeout: 3))
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.segmentedControls[IDs.settingsLineHeight].waitForExistence(timeout: 3))
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsColumns].waitForExistence(timeout: 3))
|
|
||||||
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
XCTAssertTrue(app.segmentedControls[IDs.settingsDisplayType].waitForExistence(timeout: 3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
|
|
||||||
public var note: String?
|
public var note: String?
|
||||||
|
|
||||||
|
/// True when this record was created directly as an annotation instead of
|
||||||
|
/// adding a note to an existing underline.
|
||||||
|
public var isAnnotationOnly: Bool
|
||||||
|
|
||||||
public var createdAt: Date
|
public var createdAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
@@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
location: RDEPUBLocation,
|
location: RDEPUBLocation,
|
||||||
text: String,
|
text: String,
|
||||||
rangeInfo: String? = nil,
|
rangeInfo: String? = nil,
|
||||||
style: RDEPUBHighlightStyle = .highlight,
|
style: RDEPUBHighlightStyle = .underline,
|
||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil,
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false,
|
||||||
createdAt: Date = Date()
|
createdAt: Date = Date()
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
self.style = style
|
self.style = style
|
||||||
self.color = color
|
self.color = color
|
||||||
self.note = note?.nilIfEmpty
|
self.note = note?.nilIfEmpty
|
||||||
|
self.isAnnotationOnly = isAnnotationOnly
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
case style
|
case style
|
||||||
case color
|
case color
|
||||||
case note
|
case note
|
||||||
|
case isAnnotationOnly
|
||||||
case createdAt
|
case createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||||
style = decodedStyle
|
style = decodedStyle == .highlight ? .underline : decodedStyle
|
||||||
} else {
|
} else {
|
||||||
style = .highlight
|
style = .underline
|
||||||
}
|
}
|
||||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||||
|
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
|
||||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
|||||||
try container.encode(style, forKey: .style)
|
try container.encode(style, forKey: .style)
|
||||||
try container.encode(color, forKey: .color)
|
try container.encode(color, forKey: .color)
|
||||||
try container.encodeIfPresent(note, forKey: .note)
|
try container.encodeIfPresent(note, forKey: .note)
|
||||||
|
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
|
||||||
try container.encode(createdAt, forKey: .createdAt)
|
try container.encode(createdAt, forKey: .createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public final class RDEPUBPublication {
|
|||||||
parser.readingProfile()
|
parser.readingProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scripted fixed-layout publications own a complete interactive scene per
|
||||||
|
/// spine item. Combining scenes in landscape can start multiple timelines
|
||||||
|
/// and media tracks, so these publications remain single-page.
|
||||||
|
public lazy var requiresSinglePagePresentation: Bool = {
|
||||||
|
layout == .fixed && parser.hasInteractiveContent()
|
||||||
|
}()
|
||||||
|
|
||||||
public var readingProgression: RDEPUBReadingProgression {
|
public var readingProgression: RDEPUBReadingProgression {
|
||||||
metadata.readingProgression
|
metadata.readingProgression
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ public enum RDEPUBSafeArea {
|
|||||||
/// paddings, not approximations of the safe area itself.
|
/// paddings, not approximations of the safe area itself.
|
||||||
public static let minimumVerticalTextMargin: CGFloat = 20
|
public static let minimumVerticalTextMargin: CGFloat = 20
|
||||||
|
|
||||||
|
/// Compact top/bottom padding used by a landscape two-page spread. This
|
||||||
|
/// applies only beyond the actual safe area, which is always preserved.
|
||||||
|
public static let landscapeVerticalTextMargin: CGFloat = 12
|
||||||
|
|
||||||
public static let minimumHorizontalTextMargin: CGFloat = 16
|
public static let minimumHorizontalTextMargin: CGFloat = 16
|
||||||
|
|
||||||
public static func keyWindow() -> UIWindow? {
|
public static func keyWindow() -> UIWindow? {
|
||||||
|
|||||||
@@ -25,10 +25,22 @@ extension RDEPUBWebView {
|
|||||||
forMainFrameOnly: true
|
forMainFrameOnly: true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
userContentController.addUserScript(
|
||||||
|
WKUserScript(
|
||||||
|
source: Self.readerPageMediaLifecycleUserScript,
|
||||||
|
injectionTime: .atDocumentStart,
|
||||||
|
forMainFrameOnly: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
let configuration = WKWebViewConfiguration()
|
let configuration = WKWebViewConfiguration()
|
||||||
configuration.websiteDataStore = .nonPersistent()
|
configuration.websiteDataStore = .nonPersistent()
|
||||||
configuration.userContentController = userContentController
|
configuration.userContentController = userContentController
|
||||||
|
// Interactive fixed-layout EPUBs commonly start narration from their
|
||||||
|
// document-load timeline. Allow those page-owned audio elements to play
|
||||||
|
// without requiring an extra tap, and keep playback inside the reader.
|
||||||
|
configuration.allowsInlineMediaPlayback = true
|
||||||
|
configuration.mediaTypesRequiringUserActionForPlayback = []
|
||||||
let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser)
|
let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser)
|
||||||
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme)
|
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme)
|
||||||
|
|
||||||
@@ -44,9 +56,9 @@ extension RDEPUBWebView {
|
|||||||
webView._editMenuInteraction = interaction
|
webView._editMenuInteraction = interaction
|
||||||
} else {
|
} else {
|
||||||
UIMenuController.shared.menuItems = [
|
UIMenuController.shared.menuItems = [
|
||||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
UIMenuItem(title: "注释", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
webView.navigationDelegate = self
|
webView.navigationDelegate = self
|
||||||
@@ -77,6 +89,11 @@ extension RDEPUBWebView {
|
|||||||
self.schemeHandler = schemeHandler
|
self.schemeHandler = schemeHandler
|
||||||
self.webView = webView
|
self.webView = webView
|
||||||
self.configuredPublicationKey = publicationKey
|
self.configuredPublicationKey = publicationKey
|
||||||
|
appliedNativeMediaPlaybackSuspended = false
|
||||||
|
isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
nativeMediaPlaybackRequests.removeAll()
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +108,7 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
func teardownWebView() {
|
func teardownWebView() {
|
||||||
cancelFixedLayoutReadyFallback()
|
cancelFixedLayoutReadyFallback()
|
||||||
|
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
|
||||||
if let webView {
|
if let webView {
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||||
}
|
}
|
||||||
@@ -107,6 +125,10 @@ extension RDEPUBWebView {
|
|||||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
||||||
webView?.removeFromSuperview()
|
webView?.removeFromSuperview()
|
||||||
webView = nil
|
webView = nil
|
||||||
|
finishAllNativeMediaPlaybackRequests()
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
schemeHandler = nil
|
schemeHandler = nil
|
||||||
configuredPublicationKey = nil
|
configuredPublicationKey = nil
|
||||||
currentLoadSignature = nil
|
currentLoadSignature = nil
|
||||||
@@ -130,3 +152,416 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
|
|||||||
target?.userContentController(userContentController, didReceive: message)
|
target?.userContentController(userContentController, didReceive: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializes native media activation across page WebViews. WebKit suspension
|
||||||
|
/// is asynchronous and separate WKWebView instances have no ordering contract;
|
||||||
|
/// the incoming page therefore waits for the previous active page's suspension
|
||||||
|
/// completion before it can be resumed.
|
||||||
|
private final class RDEPUBReaderMediaPlaybackCoordinator {
|
||||||
|
|
||||||
|
static let shared = RDEPUBReaderMediaPlaybackCoordinator()
|
||||||
|
|
||||||
|
private weak var activePage: RDEPUBWebView?
|
||||||
|
private weak var pendingPage: RDEPUBWebView?
|
||||||
|
private var activationGeneration: UInt = 0
|
||||||
|
|
||||||
|
func setPlaybackSuspended(
|
||||||
|
_ suspended: Bool,
|
||||||
|
for page: RDEPUBWebView,
|
||||||
|
completion: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
if suspended {
|
||||||
|
suspend(page, completion: completion)
|
||||||
|
} else {
|
||||||
|
activate(page, completion: completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ page: RDEPUBWebView) {
|
||||||
|
if pendingPage === page {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
}
|
||||||
|
if activePage === page {
|
||||||
|
activePage = nil
|
||||||
|
}
|
||||||
|
if pendingPage === page {
|
||||||
|
pendingPage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func suspend(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||||
|
if pendingPage === page || (activePage === page && pendingPage == nil) {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
}
|
||||||
|
if pendingPage === page {
|
||||||
|
pendingPage = nil
|
||||||
|
}
|
||||||
|
let suspensionGeneration = activationGeneration
|
||||||
|
|
||||||
|
page.applyNativeMediaPlaybackSuspended(true) { [weak self, weak page] in
|
||||||
|
guard let self, let page else { return }
|
||||||
|
if self.activationGeneration == suspensionGeneration,
|
||||||
|
self.activePage === page {
|
||||||
|
self.activePage = nil
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activate(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||||
|
activationGeneration &+= 1
|
||||||
|
let generation = activationGeneration
|
||||||
|
pendingPage = page
|
||||||
|
let outgoingPage = activePage
|
||||||
|
|
||||||
|
let resumeIncomingPage = { [weak self, weak page] in
|
||||||
|
guard let self,
|
||||||
|
let page,
|
||||||
|
self.activationGeneration == generation,
|
||||||
|
self.pendingPage === page else { return }
|
||||||
|
self.pendingPage = nil
|
||||||
|
self.activePage = page
|
||||||
|
page.applyNativeMediaPlaybackSuspended(false) { [weak self, weak page] in
|
||||||
|
guard let self, let page else { return }
|
||||||
|
if self.activePage !== page {
|
||||||
|
page.applyNativeMediaPlaybackSuspended(true, completion: {})
|
||||||
|
} else {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let outgoingPage, outgoingPage !== page else {
|
||||||
|
resumeIncomingPage()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outgoingPage.applyNativeMediaPlaybackSuspended(true) {
|
||||||
|
resumeIncomingPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RDEPUBWebView {
|
||||||
|
|
||||||
|
/// Installed in every frame before EPUB scripts run. Hidden pages start in
|
||||||
|
/// a suspended state for both HTML media and Web Audio. Tumult Hype uses
|
||||||
|
/// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
|
||||||
|
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
|
||||||
|
static let readerPageMediaLifecycleUserScript = #"""
|
||||||
|
(() => {
|
||||||
|
if (window.__rdReaderMediaLifecycleInstalled) return;
|
||||||
|
window.__rdReaderMediaLifecycleInstalled = true;
|
||||||
|
|
||||||
|
let isActive = false;
|
||||||
|
const trackedMedia = new Set();
|
||||||
|
const trackedAudioContexts = new Set();
|
||||||
|
const lifecycleSuspendedAudioContexts = new Set();
|
||||||
|
const command = "rd-reader-page-visibility";
|
||||||
|
const frameReadyCommand = "rd-reader-frame-media-ready";
|
||||||
|
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
|
||||||
|
const nativePlay = mediaPrototype && mediaPrototype.play;
|
||||||
|
|
||||||
|
const ignoreRejection = result => {
|
||||||
|
if (result && result.catch) result.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = media => {
|
||||||
|
if (media) trackedMedia.add(media);
|
||||||
|
return media;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectDocumentMedia = () => {
|
||||||
|
if (!document.querySelectorAll) return;
|
||||||
|
document.querySelectorAll("audio,video").forEach(register);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAudioContext = context => {
|
||||||
|
if (!context || context.state === "closed") return;
|
||||||
|
if (isActive) {
|
||||||
|
if (!lifecycleSuspendedAudioContexts.has(context)) return;
|
||||||
|
try {
|
||||||
|
const result = context.resume();
|
||||||
|
if (result && result.then) {
|
||||||
|
result.then(() => lifecycleSuspendedAudioContexts.delete(context)).catch(() => {});
|
||||||
|
} else {
|
||||||
|
lifecycleSuspendedAudioContexts.delete(context);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.state !== "running" || lifecycleSuspendedAudioContexts.has(context)) return;
|
||||||
|
lifecycleSuspendedAudioContexts.add(context);
|
||||||
|
try { ignoreRejection(context.suspend()); } catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerAudioContext = context => {
|
||||||
|
if (context) {
|
||||||
|
trackedAudioContexts.add(context);
|
||||||
|
updateAudioContext(context);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActive = active => {
|
||||||
|
isActive = !!active;
|
||||||
|
collectDocumentMedia();
|
||||||
|
trackedMedia.forEach(media => {
|
||||||
|
if (isActive) {
|
||||||
|
if (media.__rdReaderReplayWhenVisible) {
|
||||||
|
try {
|
||||||
|
media.currentTime = 0;
|
||||||
|
const result = nativePlay && nativePlay.call(media);
|
||||||
|
if (result && result.then) {
|
||||||
|
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
|
||||||
|
} else {
|
||||||
|
media.__rdReaderReplayWhenVisible = false;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) {
|
||||||
|
media.__rdReaderReplayWhenVisible = true;
|
||||||
|
}
|
||||||
|
if (!media.paused) {
|
||||||
|
try { media.pause(); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
trackedAudioContexts.forEach(updateAudioContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mediaPrototype && nativePlay) {
|
||||||
|
mediaPrototype.play = function() {
|
||||||
|
register(this);
|
||||||
|
this.__rdReaderWasPlayed = true;
|
||||||
|
if (!isActive) {
|
||||||
|
this.__rdReaderReplayWhenVisible = true;
|
||||||
|
try { this.pause(); } catch (_) {}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
this.__rdReaderReplayWhenVisible = false;
|
||||||
|
return nativePlay.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const NativeAudio = window.Audio;
|
||||||
|
if (typeof NativeAudio === "function") {
|
||||||
|
const ReaderAudio = function(source) {
|
||||||
|
const media = arguments.length > 0 ? new NativeAudio(source) : new NativeAudio();
|
||||||
|
return register(media);
|
||||||
|
};
|
||||||
|
ReaderAudio.prototype = NativeAudio.prototype;
|
||||||
|
try { Object.setPrototypeOf(ReaderAudio, NativeAudio); } catch (_) {}
|
||||||
|
window.Audio = ReaderAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const installAudioContext = name => {
|
||||||
|
const NativeAudioContext = window[name];
|
||||||
|
if (typeof NativeAudioContext !== "function") return;
|
||||||
|
const ReaderAudioContext = function() {
|
||||||
|
const context = Reflect.construct(
|
||||||
|
NativeAudioContext,
|
||||||
|
Array.prototype.slice.call(arguments),
|
||||||
|
NativeAudioContext
|
||||||
|
);
|
||||||
|
return registerAudioContext(context);
|
||||||
|
};
|
||||||
|
ReaderAudioContext.prototype = NativeAudioContext.prototype;
|
||||||
|
try { Object.setPrototypeOf(ReaderAudioContext, NativeAudioContext); } catch (_) {}
|
||||||
|
try { window[name] = ReaderAudioContext; } catch (_) {}
|
||||||
|
};
|
||||||
|
installAudioContext("AudioContext");
|
||||||
|
installAudioContext("webkitAudioContext");
|
||||||
|
|
||||||
|
const sendVisibility = target => {
|
||||||
|
try {
|
||||||
|
target.postMessage({ type: command, active: isActive }, "*");
|
||||||
|
} catch (_) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastVisibility = () => {
|
||||||
|
for (let index = 0; index < window.frames.length; index += 1) {
|
||||||
|
sendVisibility(window.frames[index]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("message", event => {
|
||||||
|
const message = event.data;
|
||||||
|
if (!message) return;
|
||||||
|
if (message.type === frameReadyCommand) {
|
||||||
|
if (event.source) sendVisibility(event.source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type !== command) return;
|
||||||
|
setActive(message.active);
|
||||||
|
broadcastVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
setActive(isActive);
|
||||||
|
broadcastVisibility();
|
||||||
|
});
|
||||||
|
if (window.MutationObserver) {
|
||||||
|
new MutationObserver(() => setActive(isActive)).observe(document, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (window.parent !== window) {
|
||||||
|
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""#
|
||||||
|
|
||||||
|
func setReaderPageVisible(_ isVisible: Bool) {
|
||||||
|
let wasVisible = isReaderPageVisible
|
||||||
|
isReaderPageVisible = isVisible
|
||||||
|
|
||||||
|
if !isVisible {
|
||||||
|
if wasVisible,
|
||||||
|
hasReaderPageBeenVisible,
|
||||||
|
currentRenderRequest?.isFixedLayout == true {
|
||||||
|
needsReaderPagePlaybackRestart = true
|
||||||
|
}
|
||||||
|
applyReaderPageMediaVisibility()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !wasVisible,
|
||||||
|
needsReaderPagePlaybackRestart,
|
||||||
|
restartReaderPagePlayback() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isWaitingForReaderPageMediaNavigation else { return }
|
||||||
|
applyReaderPageMediaVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerPageMediaNavigationDidFinish(_ navigation: WKNavigation?) -> Bool {
|
||||||
|
if isWaitingForReaderPageMediaNavigation,
|
||||||
|
let pendingReaderPageMediaNavigation,
|
||||||
|
let navigation,
|
||||||
|
pendingReaderPageMediaNavigation !== navigation {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
applyReaderPageMediaVisibility()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func readerPageMediaNavigationDidFail(_ navigation: WKNavigation?) {
|
||||||
|
if let pendingReaderPageMediaNavigation,
|
||||||
|
let navigation,
|
||||||
|
pendingReaderPageMediaNavigation !== navigation {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = true
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
// A failed fixed-layout reload may leave the outgoing Hype document
|
||||||
|
// alive. Keep it suspended instead of reviving narration from the page
|
||||||
|
// the user already left; the next successful load will activate again.
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
webView?.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyReaderPageMediaVisibility() {
|
||||||
|
guard let webView else { return }
|
||||||
|
let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
|
||||||
|
if !isVisible {
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReaderPageBeenVisible = true
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(false) { [weak self, weak webView] in
|
||||||
|
guard let self,
|
||||||
|
let webView,
|
||||||
|
self.webView === webView,
|
||||||
|
self.isReaderPageVisible,
|
||||||
|
!self.isReaderPageMediaActivationBlocked,
|
||||||
|
!self.isWaitingForReaderPageMediaNavigation else { return }
|
||||||
|
webView.evaluateJavaScript(
|
||||||
|
"window.postMessage({type: 'rd-reader-page-visibility', active: true}, '*');",
|
||||||
|
completionHandler: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restartReaderPagePlayback() -> Bool {
|
||||||
|
guard let publication,
|
||||||
|
let currentRenderRequest,
|
||||||
|
currentRenderRequest.isFixedLayout else { return false }
|
||||||
|
load(publication: publication, request: currentRenderRequest)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNativeReaderPageMediaPlaybackSuspended(
|
||||||
|
_ suspended: Bool,
|
||||||
|
completion: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
RDEPUBReaderMediaPlaybackCoordinator.shared.setPlaybackSuspended(
|
||||||
|
suspended,
|
||||||
|
for: self,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNativeMediaPlaybackSuspended(_ suspended: Bool, completion: @escaping () -> Void) {
|
||||||
|
nativeMediaPlaybackRequests.append((suspended, completion))
|
||||||
|
processNextNativeMediaPlaybackRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processNextNativeMediaPlaybackRequest() {
|
||||||
|
guard !isNativeMediaPlaybackOperationInFlight else { return }
|
||||||
|
guard let webView else {
|
||||||
|
finishAllNativeMediaPlaybackRequests()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let request = nativeMediaPlaybackRequests.first else { return }
|
||||||
|
|
||||||
|
guard appliedNativeMediaPlaybackSuspended != request.suspended else {
|
||||||
|
nativeMediaPlaybackRequests.removeFirst()
|
||||||
|
request.completion()
|
||||||
|
processNextNativeMediaPlaybackRequest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetSuspended = request.suspended
|
||||||
|
isNativeMediaPlaybackOperationInFlight = true
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
let operationGeneration = nativeMediaPlaybackOperationGeneration
|
||||||
|
webView.setAllMediaPlaybackSuspended(targetSuspended) { [weak self, weak webView] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard operationGeneration == self.nativeMediaPlaybackOperationGeneration,
|
||||||
|
let webView,
|
||||||
|
self.webView === webView else { return }
|
||||||
|
|
||||||
|
self.appliedNativeMediaPlaybackSuspended = targetSuspended
|
||||||
|
self.isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
guard let completedRequest = self.nativeMediaPlaybackRequests.first else { return }
|
||||||
|
self.nativeMediaPlaybackRequests.removeFirst()
|
||||||
|
completedRequest.completion()
|
||||||
|
self.processNextNativeMediaPlaybackRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishAllNativeMediaPlaybackRequests() {
|
||||||
|
let completions = nativeMediaPlaybackRequests.map(\.completion)
|
||||||
|
nativeMediaPlaybackRequests.removeAll()
|
||||||
|
nativeMediaPlaybackOperationGeneration &+= 1
|
||||||
|
appliedNativeMediaPlaybackSuspended = false
|
||||||
|
isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
completions.forEach { $0() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ extension RDEPUBWebView {
|
|||||||
|
|
||||||
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||||
webView.loadHTMLString(
|
pendingReaderPageMediaNavigation = webView.loadHTMLString(
|
||||||
html,
|
html,
|
||||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,19 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
|||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||||
|
guard readerPageMediaNavigationDidFinish(navigation) else { return }
|
||||||
applyPresentation()
|
applyPresentation()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||||
|
// WebKit may not complete an in-flight media suspension after its
|
||||||
|
// content process exits. Tearing down flushes the FIFO callbacks and
|
||||||
|
// lets the coordinator activate another page without deadlocking.
|
||||||
|
// Rebuild the page afterward so a cached/current page does not remain
|
||||||
|
// permanently blank after WebKit recovers its content process.
|
||||||
|
let publication = publication
|
||||||
|
let renderRequest = currentRenderRequest
|
||||||
|
teardownWebView()
|
||||||
|
if let publication, let renderRequest {
|
||||||
|
load(publication: publication, request: renderRequest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||||
|
readerPageMediaNavigationDidFail(navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||||
|
readerPageMediaNavigationDidFail(navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
|||||||
menuFor configuration: UIEditMenuConfiguration
|
menuFor configuration: UIEditMenuConfiguration
|
||||||
) -> UIMenu {
|
) -> UIMenu {
|
||||||
UIMenu(children: [
|
UIMenu(children: [
|
||||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +136,26 @@ public final class RDEPUBWebView: UIView {
|
|||||||
|
|
||||||
var isFixedLayout = false
|
var isFixedLayout = false
|
||||||
|
|
||||||
|
var isReaderPageVisible = true
|
||||||
|
|
||||||
|
var hasReaderPageBeenVisible = false
|
||||||
|
|
||||||
|
var needsReaderPagePlaybackRestart = false
|
||||||
|
|
||||||
|
var appliedNativeMediaPlaybackSuspended = false
|
||||||
|
|
||||||
|
var isNativeMediaPlaybackOperationInFlight = false
|
||||||
|
|
||||||
|
var nativeMediaPlaybackOperationGeneration: UInt = 0
|
||||||
|
|
||||||
|
var nativeMediaPlaybackRequests: [(suspended: Bool, completion: () -> Void)] = []
|
||||||
|
|
||||||
|
var isWaitingForReaderPageMediaNavigation = false
|
||||||
|
|
||||||
|
var pendingReaderPageMediaNavigation: WKNavigation?
|
||||||
|
|
||||||
|
var isReaderPageMediaActivationBlocked = false
|
||||||
|
|
||||||
var pendingProgressionRequest = false
|
var pendingProgressionRequest = false
|
||||||
|
|
||||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||||
@@ -186,6 +206,12 @@ public final class RDEPUBWebView: UIView {
|
|||||||
pendingHighlights = []
|
pendingHighlights = []
|
||||||
fixedSpread = nil
|
fixedSpread = nil
|
||||||
isFixedLayout = false
|
isFixedLayout = false
|
||||||
|
isReaderPageVisible = true
|
||||||
|
hasReaderPageBeenVisible = false
|
||||||
|
needsReaderPagePlaybackRestart = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = false
|
||||||
|
pendingReaderPageMediaNavigation = nil
|
||||||
|
isReaderPageMediaActivationBlocked = false
|
||||||
pendingProgressionRequest = false
|
pendingProgressionRequest = false
|
||||||
didRenderCurrentRequest = false
|
didRenderCurrentRequest = false
|
||||||
teardownWebView()
|
teardownWebView()
|
||||||
@@ -204,6 +230,15 @@ public final class RDEPUBWebView: UIView {
|
|||||||
|
|
||||||
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
||||||
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
||||||
|
if request.isFixedLayout {
|
||||||
|
// A fixed-layout load replaces the whole Hype document. Keep WebKit
|
||||||
|
// suspended until navigation finishes so the outgoing page cannot
|
||||||
|
// overlap the new page's zero-second narration.
|
||||||
|
hasReaderPageBeenVisible = false
|
||||||
|
needsReaderPagePlaybackRestart = false
|
||||||
|
isWaitingForReaderPageMediaNavigation = true
|
||||||
|
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||||
|
}
|
||||||
RDEPUBWebViewDebug.log(
|
RDEPUBWebViewDebug.log(
|
||||||
debugScope,
|
debugScope,
|
||||||
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
||||||
|
|||||||
@@ -55,7 +55,6 @@
|
|||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
|
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
|
||||||
var safeAreaInsets = { top: {{INSET_TOP}}, right: {{INSET_RIGHT}}, bottom: {{INSET_BOTTOM}}, left: {{INSET_LEFT}} };
|
|
||||||
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
|
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
|
||||||
var fit = '{{FIT_MODE}}';
|
var fit = '{{FIT_MODE}}';
|
||||||
var pendingFrames = 0;
|
var pendingFrames = 0;
|
||||||
@@ -206,10 +205,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (widthFit && scaledHeight > localViewport.height) {
|
if (widthFit && scaledHeight > localViewport.height) {
|
||||||
offsetY = safeAreaInsets.top;
|
offsetY = 0;
|
||||||
} else {
|
} else {
|
||||||
offsetY = (localViewport.height - scaledHeight) / 2;
|
offsetY = (localViewport.height - scaledHeight) / 2;
|
||||||
offsetY += (safeAreaInsets.top - safeAreaInsets.bottom) / 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
iframe.style.left = offsetX + 'px';
|
iframe.style.left = offsetX + 'px';
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import UIKit
|
||||||
|
|
||||||
|
final class RDEPUBAnnotationEditorViewController: UIViewController, UITextViewDelegate {
|
||||||
|
|
||||||
|
private let quote: String
|
||||||
|
private let initialNote: String?
|
||||||
|
private let onSave: (String?) -> Void
|
||||||
|
private let textView = UITextView()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
|
||||||
|
init(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
|
||||||
|
self.quote = quote
|
||||||
|
self.initialNote = initialNote
|
||||||
|
self.onSave = onSave
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
title = "写注释"
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
view.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
|
configureNavigation()
|
||||||
|
configureContent()
|
||||||
|
updateCount()
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureNavigation() {
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "取消", style: .plain, target: self, action: #selector(cancelAction)
|
||||||
|
)
|
||||||
|
navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||||
|
title: "保存", style: .done, target: self, action: #selector(saveAction)
|
||||||
|
)
|
||||||
|
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
|
navigationController?.navigationBar.titleTextAttributes = [
|
||||||
|
.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1),
|
||||||
|
.font: UIFont.systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureContent() {
|
||||||
|
let quoteCard = UIView()
|
||||||
|
let rail = UIView()
|
||||||
|
let quoteLabel = UILabel()
|
||||||
|
let inputCard = UIView()
|
||||||
|
|
||||||
|
[quoteCard, rail, quoteLabel, inputCard, textView, countLabel].forEach {
|
||||||
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
}
|
||||||
|
view.addSubview(quoteCard)
|
||||||
|
quoteCard.addSubview(rail)
|
||||||
|
quoteCard.addSubview(quoteLabel)
|
||||||
|
view.addSubview(inputCard)
|
||||||
|
inputCard.addSubview(textView)
|
||||||
|
inputCard.addSubview(countLabel)
|
||||||
|
|
||||||
|
quoteCard.backgroundColor = UIColor.white.withAlphaComponent(0.78)
|
||||||
|
quoteCard.layer.cornerRadius = 16
|
||||||
|
rail.backgroundColor = UIColor(red: 0.94, green: 0.63, blue: 0.18, alpha: 1)
|
||||||
|
rail.layer.cornerRadius = 2
|
||||||
|
quoteLabel.text = quote
|
||||||
|
quoteLabel.numberOfLines = 5
|
||||||
|
quoteLabel.font = UIFont.systemFont(ofSize: 16)
|
||||||
|
quoteLabel.textColor = UIColor(red: 0.19, green: 0.20, blue: 0.23, alpha: 1)
|
||||||
|
|
||||||
|
inputCard.backgroundColor = .white
|
||||||
|
inputCard.layer.cornerRadius = 18
|
||||||
|
inputCard.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
inputCard.layer.shadowOpacity = 0.06
|
||||||
|
inputCard.layer.shadowRadius = 18
|
||||||
|
inputCard.layer.shadowOffset = CGSize(width: 0, height: 6)
|
||||||
|
textView.text = initialNote
|
||||||
|
textView.delegate = self
|
||||||
|
textView.font = UIFont.systemFont(ofSize: 17)
|
||||||
|
textView.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
|
textView.backgroundColor = .clear
|
||||||
|
textView.accessibilityIdentifier = "epub.reader.annotation.editor"
|
||||||
|
countLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular)
|
||||||
|
countLabel.textColor = .secondaryLabel
|
||||||
|
countLabel.textAlignment = .right
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
quoteCard.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
||||||
|
quoteCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
quoteCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
rail.leadingAnchor.constraint(equalTo: quoteCard.leadingAnchor, constant: 16),
|
||||||
|
rail.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
|
||||||
|
rail.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
|
||||||
|
rail.widthAnchor.constraint(equalToConstant: 4),
|
||||||
|
quoteLabel.leadingAnchor.constraint(equalTo: rail.trailingAnchor, constant: 14),
|
||||||
|
quoteLabel.trailingAnchor.constraint(equalTo: quoteCard.trailingAnchor, constant: -18),
|
||||||
|
quoteLabel.topAnchor.constraint(equalTo: quoteCard.topAnchor, constant: 16),
|
||||||
|
quoteLabel.bottomAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: -16),
|
||||||
|
|
||||||
|
inputCard.topAnchor.constraint(equalTo: quoteCard.bottomAnchor, constant: 18),
|
||||||
|
inputCard.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
inputCard.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
inputCard.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -16),
|
||||||
|
inputCard.heightAnchor.constraint(greaterThanOrEqualToConstant: 210),
|
||||||
|
textView.topAnchor.constraint(equalTo: inputCard.topAnchor, constant: 14),
|
||||||
|
textView.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 14),
|
||||||
|
textView.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -14),
|
||||||
|
textView.bottomAnchor.constraint(equalTo: countLabel.topAnchor, constant: -8),
|
||||||
|
countLabel.leadingAnchor.constraint(equalTo: inputCard.leadingAnchor, constant: 16),
|
||||||
|
countLabel.trailingAnchor.constraint(equalTo: inputCard.trailingAnchor, constant: -16),
|
||||||
|
countLabel.bottomAnchor.constraint(equalTo: inputCard.bottomAnchor, constant: -12)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
|
if textView.text.count > 1000 {
|
||||||
|
textView.text = String(textView.text.prefix(1000))
|
||||||
|
}
|
||||||
|
updateCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCount() {
|
||||||
|
countLabel.text = "\(textView.text.count)/1000"
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelAction() {
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func saveAction() {
|
||||||
|
let note = textView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
onSave(note.isEmpty ? nil : note)
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ final class RDEPUBReaderBottomToolView: RDEPUBReaderToolView, RDEPUBReaderBottom
|
|||||||
|
|
||||||
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
configureButton(chapterButton, systemName: "list.bullet", fallbackTitle: "目录")
|
||||||
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
configureButton(bookmarksButton, systemName: "bookmark", fallbackTitle: "书签")
|
||||||
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "批注")
|
configureButton(highlightsButton, systemName: "note.text", fallbackTitle: "标注")
|
||||||
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
configureButton(addHighlightButton, systemName: "highlighter", fallbackTitle: "标注")
|
||||||
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
configureButton(settingsButton, systemName: "textformat.size", fallbackTitle: "设置")
|
||||||
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
chapterButton.accessibilityIdentifier = "epub.reader.toc"
|
||||||
|
|||||||
@@ -49,14 +49,24 @@ final class RDEPUBReaderChapterListController: UITableViewController {
|
|||||||
cell.backgroundColor = theme.contentBackgroundColor
|
cell.backgroundColor = theme.contentBackgroundColor
|
||||||
cell.textLabel?.numberOfLines = 2
|
cell.textLabel?.numberOfLines = 2
|
||||||
cell.textLabel?.text = item.title
|
cell.textLabel?.text = item.title
|
||||||
cell.textLabel?.textColor = isCurrentItem(item) ? .systemBlue : theme.contentTextColor
|
if isCurrentItem(item) {
|
||||||
|
cell.textLabel?.textColor = .systemBlue
|
||||||
|
} else if item.isReadable {
|
||||||
|
cell.textLabel?.textColor = theme.contentTextColor
|
||||||
|
} else {
|
||||||
|
cell.textLabel?.textColor = .systemGray
|
||||||
|
}
|
||||||
|
cell.selectionStyle = item.isReadable ? .default : .none
|
||||||
|
cell.isUserInteractionEnabled = item.isReadable
|
||||||
cell.indentationLevel = item.depth
|
cell.indentationLevel = item.depth
|
||||||
cell.indentationWidth = 18
|
cell.indentationWidth = 18
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
onSelectItem?(items[indexPath.row])
|
let item = items[indexPath.row]
|
||||||
|
guard item.isReadable else { return }
|
||||||
|
onSelectItem?(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
|
private func isCurrentItem(_ item: RDEPUBReaderTableOfContentsItem) -> Bool {
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
|||||||
|
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||||
sourceRect: CGRect
|
highlight: RDEPUBHighlight
|
||||||
) {
|
) {
|
||||||
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
|
runtime.handleHighlightMenuAction(action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
private func presentNotePopupIfPossible(for location: RDEPUBLocation, fromSpineIndex: Int) -> Bool {
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
|||||||
pageIdentifier(readerView: readerView, pageNum: index)
|
pageIdentifier(readerView: readerView, pageNum: index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func readerView(
|
||||||
|
_ readerView: RDEpubReaderView,
|
||||||
|
shouldPreloadPageAt index: Int
|
||||||
|
) -> Bool {
|
||||||
|
// 试读墙由宿主提供唯一 UIView,不允许预加载创建另一容器并抢走它。
|
||||||
|
guard index != trialWallPageIndex else { return false }
|
||||||
|
// 交互型固定版式页会在文档加载阶段触发页内音频/时间轴。
|
||||||
|
// 预加载会把相邻章节一并载入,导致当前章打开时多段音频同时播放。
|
||||||
|
// 这些页面只在真正展示时再创建,确保仅当前查看章节发声。
|
||||||
|
return request(for: index)?.isFixedLayout != true
|
||||||
|
}
|
||||||
|
|
||||||
public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
public func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView? {
|
||||||
topToolView(readerView: readerView)
|
topToolView(readerView: readerView)
|
||||||
}
|
}
|
||||||
@@ -66,6 +78,7 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
|||||||
pageNumber: pageNum + 1,
|
pageNumber: pageNum + 1,
|
||||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
|
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum),
|
||||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||||
highlights: textHighlights(for: resolvedPage.page),
|
highlights: textHighlights(for: resolvedPage.page),
|
||||||
@@ -89,7 +102,8 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
|||||||
contentView.configureLoading(
|
contentView.configureLoading(
|
||||||
pageNumber: pageNum + 1,
|
pageNumber: pageNum + 1,
|
||||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||||
configuration: configuration
|
configuration: configuration,
|
||||||
|
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum)
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@@ -111,6 +125,7 @@ extension RDEPUBReaderController: RDEpubReaderDataSource, RDEpubReaderPageProvid
|
|||||||
pageNumber: pageNum + 1,
|
pageNumber: pageNum + 1,
|
||||||
totalPages: textBook.pages.count,
|
totalPages: textBook.pages.count,
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
|
contentInsets: readerContext.currentTextContentInsets(pageIndex: pageNum),
|
||||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||||
highlights: textHighlights(for: page),
|
highlights: textHighlights(for: page),
|
||||||
|
|||||||
@@ -48,12 +48,7 @@ extension RDEPUBReaderController {
|
|||||||
forPageNumber: pageIndex + 1,
|
forPageNumber: pageIndex + 1,
|
||||||
spineIndex: page.spineIndex
|
spineIndex: page.spineIndex
|
||||||
)
|
)
|
||||||
var preferences = currentPreferences()
|
let preferences = readerContext.currentPreferences(pageIndex: pageIndex)
|
||||||
if publication.layout == .fixed, readerView.pagesPerScreen == 2 {
|
|
||||||
// 横屏双页:左右页各让出 5pt,拼出 10pt 的书脊间距
|
|
||||||
preferences.fixedContentInset.left += 5
|
|
||||||
preferences.fixedContentInset.right += 5
|
|
||||||
}
|
|
||||||
return preferences.renderRequest(
|
return preferences.renderRequest(
|
||||||
for: page,
|
for: page,
|
||||||
publication: publication,
|
publication: publication,
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ extension RDEPUBReaderController {
|
|||||||
|
|
||||||
func applyReaderViewConfiguration() {
|
func applyReaderViewConfiguration() {
|
||||||
let resolvedDirection = resolvedPageDirection()
|
let resolvedDirection = resolvedPageDirection()
|
||||||
|
let allowsLandscapeDualPage = configuration.landscapeDualPageEnabled
|
||||||
|
&& publication?.requiresSinglePagePresentation != true
|
||||||
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
let presentationDidChange = readerView.currentDisplayType != configuration.displayType
|
||||||
|| readerView.landscapeDualPageEnabled != configuration.landscapeDualPageEnabled
|
|| readerView.landscapeDualPageEnabled != allowsLandscapeDualPage
|
||||||
|| readerView.pageDirection != resolvedDirection
|
|| readerView.pageDirection != resolvedDirection
|
||||||
let preservedLocation = presentationDidChange
|
let preservedLocation = presentationDidChange
|
||||||
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
? (runtime.viewportMonitor.consumePendingPresentationRestoreLocation() ?? currentVisibleLocation() ?? persistenceLocation())
|
||||||
: nil
|
: nil
|
||||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
readerView.landscapeDualPageEnabled = allowsLandscapeDualPage
|
||||||
readerView.pageDirection = resolvedDirection
|
readerView.pageDirection = resolvedDirection
|
||||||
updateReaderChrome()
|
updateReaderChrome()
|
||||||
if presentationDidChange {
|
if presentationDidChange {
|
||||||
|
|||||||
@@ -50,12 +50,14 @@ extension RDEPUBReaderController {
|
|||||||
items.flatMap { item in
|
items.flatMap { item in
|
||||||
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
let location = RDEPUBLocation(bookIdentifier: currentBookIdentifier, href: item.href, progression: 0)
|
||||||
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
|
let pageNumber = includePageNumbers ? resolvedTableOfContentsPageNumber(for: location) : nil
|
||||||
|
let isReadable = isReadableTableOfContentsLocation(location)
|
||||||
|
|
||||||
let current = RDEPUBReaderTableOfContentsItem(
|
let current = RDEPUBReaderTableOfContentsItem(
|
||||||
title: item.title,
|
title: item.title,
|
||||||
href: item.href,
|
href: item.href,
|
||||||
depth: depth,
|
depth: depth,
|
||||||
pageNumber: pageNumber
|
pageNumber: pageNumber,
|
||||||
|
isReadable: isReadable
|
||||||
)
|
)
|
||||||
return [current] + flattenedTableOfContentsItems(
|
return [current] + flattenedTableOfContentsItems(
|
||||||
from: item.children,
|
from: item.children,
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ extension RDEPUBReaderController {
|
|||||||
return readableContentPageCount + 1
|
return readableContentPageCount + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isReadableTableOfContentsLocation(_ location: RDEPUBLocation) -> Bool {
|
||||||
|
guard let policy = configuration.trialPolicy,
|
||||||
|
let readableChapterCount = policy.readableChapterCount else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard let spineIndex = readerContext.normalizedSpineIndex(for: location) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return spineIndex < readableChapterCount
|
||||||
|
}
|
||||||
|
|
||||||
/// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图)
|
/// 构建试读墙页承载视图(铺满一页,居中放置宿主提供的墙视图)
|
||||||
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
|
func makeTrialWallPageView(reusableView: UIView?) -> UIView {
|
||||||
let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
|
let container = (reusableView as? RDEPUBTrialWallContainerView) ?? RDEPUBTrialWallContainerView()
|
||||||
@@ -65,12 +76,19 @@ extension RDEPUBReaderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 试读墙页容器:让宿主墙视图铺满整页
|
/// 试读墙页容器:让宿主墙视图铺满整页
|
||||||
final class RDEPUBTrialWallContainerView: UIView {
|
final class RDEPUBTrialWallContainerView: UIView, RDEpubReaderCachePolicyProviding {
|
||||||
|
|
||||||
|
/// 宿主只提供一份试读墙 View。预加载会同时构建多个页容器,若缓存该容器会
|
||||||
|
/// 把同一 View 从实际可见页移走,最终表现为横竖翻页的试读墙白屏。
|
||||||
|
var shouldAvoidReaderPageCaching: Bool { true }
|
||||||
|
|
||||||
private weak var wallView: UIView?
|
private weak var wallView: UIView?
|
||||||
|
|
||||||
func setWallView(_ view: UIView?) {
|
func setWallView(_ view: UIView?) {
|
||||||
guard wallView !== view else { return }
|
// 即使 View 对象相同,只要它已被其他复用容器接管,也必须重新挂回当前页。
|
||||||
|
if wallView === view, view?.superview === self {
|
||||||
|
return
|
||||||
|
}
|
||||||
wallView?.removeFromSuperview()
|
wallView?.removeFromSuperview()
|
||||||
guard let view else { return }
|
guard let view else { return }
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|||||||
@@ -149,6 +149,10 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
|
/// 试读墙视图缓存(经 delegate 获取一次后复用;见 RDEPUBReaderController+Trial)
|
||||||
var cachedTrialWallView: UIView?
|
var cachedTrialWallView: UIView?
|
||||||
|
|
||||||
|
/// 阅读器进入前宿主导航栏的真实状态。详情页使用自定义导航栏时系统栏原本
|
||||||
|
/// 就是隐藏的,退出阅读器必须恢复该状态,不能无条件显示系统导航栏。
|
||||||
|
private var navigationBarHiddenBeforeReader: Bool?
|
||||||
|
|
||||||
private(set) var isSearchBarVisible = false
|
private(set) var isSearchBarVisible = false
|
||||||
|
|
||||||
var currentBookIdentifier: String? {
|
var currentBookIdentifier: String? {
|
||||||
@@ -275,6 +279,9 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
public override func viewWillAppear(_ animated: Bool) {
|
public override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
if navigationBarHiddenBeforeReader == nil {
|
||||||
|
navigationBarHiddenBeforeReader = navigationController?.isNavigationBarHidden
|
||||||
|
}
|
||||||
navigationController?.setNavigationBarHidden(true, animated: animated)
|
navigationController?.setNavigationBarHidden(true, animated: animated)
|
||||||
navigationController?.interactivePopGestureRecognizer?.delegate = self
|
navigationController?.interactivePopGestureRecognizer?.delegate = self
|
||||||
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
|
navigationController?.interactivePopGestureRecognizer?.isEnabled = true
|
||||||
@@ -282,7 +289,12 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
if let navigationBarHiddenBeforeReader {
|
||||||
|
navigationController?.setNavigationBarHidden(
|
||||||
|
navigationBarHiddenBeforeReader,
|
||||||
|
animated: animated
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewDidAppear(_ animated: Bool) {
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
@@ -362,14 +374,11 @@ public final class RDEPUBReaderController: UIViewController {
|
|||||||
|
|
||||||
readerView.addSubview(searchBarView)
|
readerView.addSubview(searchBarView)
|
||||||
readerView.searchBarView = searchBarView
|
readerView.searchBarView = searchBarView
|
||||||
let bottomAnchor = bottomToolView.superview == nil
|
|
||||||
? readerView.bottomAnchor
|
|
||||||
: bottomToolView.topAnchor
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
searchBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
|
||||||
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
searchBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
|
||||||
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
searchBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
|
||||||
searchBarView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
searchBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor)
|
||||||
])
|
])
|
||||||
|
|
||||||
searchBarView.alpha = 0
|
searchBarView.alpha = 0
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
|
|
||||||
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
private let sectionTitleProvider: (RDEPUBHighlight) -> String?
|
||||||
|
|
||||||
private let filterControl = UISegmentedControl(items: ["全部", "批注", "高亮"])
|
private let filterControl = UISegmentedControl(items: ["全部", "注释", "划线"])
|
||||||
|
|
||||||
private var filteredHighlights: [RDEPUBHighlight] {
|
private var filteredHighlights: [RDEPUBHighlight] {
|
||||||
switch filterControl.selectedSegmentIndex {
|
switch filterControl.selectedSegmentIndex {
|
||||||
case 1:
|
case 1:
|
||||||
return highlights.filter(\.hasNote)
|
return highlights.filter(\.hasNote)
|
||||||
case 2:
|
case 2:
|
||||||
return highlights.filter { $0.style == .highlight }
|
return highlights.filter { !$0.hasNote }
|
||||||
default:
|
default:
|
||||||
return highlights
|
return highlights
|
||||||
}
|
}
|
||||||
@@ -48,7 +48,11 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
view.accessibilityIdentifier = "epub.reader.highlights.panel"
|
||||||
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
tableView.accessibilityIdentifier = "epub.reader.highlights.table"
|
||||||
tableView.tableFooterView = UIView(frame: .zero)
|
tableView.tableFooterView = UIView(frame: .zero)
|
||||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
tableView.separatorStyle = .none
|
||||||
|
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||||||
|
navigationItem.rightBarButtonItem = editButtonItem
|
||||||
|
editButtonItem.title = "编辑"
|
||||||
|
tableView.allowsMultipleSelectionDuringEditing = false
|
||||||
configureFilterControl()
|
configureFilterControl()
|
||||||
applyTheme()
|
applyTheme()
|
||||||
updateEmptyState()
|
updateEmptyState()
|
||||||
@@ -63,23 +67,42 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||||
let highlight = filteredHighlights[indexPath.row]
|
let highlight = filteredHighlights[indexPath.row]
|
||||||
|
|
||||||
cell.backgroundColor = theme.contentBackgroundColor
|
cell.backgroundColor = .clear
|
||||||
cell.textLabel?.textColor = theme.contentTextColor
|
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
|
||||||
|
cell.contentView.layer.cornerRadius = 14
|
||||||
|
cell.contentView.layer.masksToBounds = true
|
||||||
|
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
cell.textLabel?.numberOfLines = 2
|
cell.textLabel?.numberOfLines = 2
|
||||||
cell.textLabel?.text = titleText(for: highlight)
|
cell.textLabel?.text = titleText(for: highlight)
|
||||||
|
|
||||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
|
||||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||||
cell.detailTextLabel?.numberOfLines = 3
|
cell.detailTextLabel?.numberOfLines = 3
|
||||||
cell.detailTextLabel?.text = detailText(for: highlight)
|
cell.detailTextLabel?.text = detailText(for: highlight)
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryType = .none
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 112 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
cell.contentView.frame = cell.bounds.inset(by: UIEdgeInsets(top: 6, left: 18, bottom: 6, right: 18))
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
guard !tableView.isEditing else { return }
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
presentActions(for: filteredHighlights[indexPath.row], sourceIndexPath: indexPath)
|
onSelectHighlight?(filteredHighlights[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||||
|
guard editingStyle == .delete else { return }
|
||||||
|
deleteHighlight(filteredHighlights[indexPath.row])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureFilterControl() {
|
private func configureFilterControl() {
|
||||||
@@ -90,10 +113,10 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme() {
|
private func applyTheme() {
|
||||||
tableView.backgroundColor = theme.contentBackgroundColor
|
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.highlights.navbar"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +143,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func emptyStateText() -> String {
|
private func emptyStateText() -> String {
|
||||||
switch filterControl.selectedSegmentIndex {
|
switch filterControl.selectedSegmentIndex {
|
||||||
case 1:
|
case 1:
|
||||||
return "暂无批注"
|
return "暂无注释"
|
||||||
case 2:
|
case 2:
|
||||||
return "暂无划线"
|
return "暂无划线"
|
||||||
default:
|
default:
|
||||||
@@ -142,7 +165,7 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
private func titleText(for highlight: RDEPUBHighlight) -> String {
|
||||||
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = highlight.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if highlight.hasNote {
|
if highlight.hasNote {
|
||||||
return "批注: \(text)"
|
return "注释:\(text)"
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
@@ -150,58 +173,12 @@ final class RDEPUBReaderHighlightsViewController: UITableViewController {
|
|||||||
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
private func styleDescription(for highlight: RDEPUBHighlight) -> String {
|
||||||
switch highlight.style {
|
switch highlight.style {
|
||||||
case .highlight:
|
case .highlight:
|
||||||
return highlight.hasNote ? "高亮批注" : "高亮"
|
return highlight.hasNote ? "划线注释" : "划线"
|
||||||
case .underline:
|
case .underline:
|
||||||
return highlight.hasNote ? "划线批注" : "划线"
|
return highlight.hasNote ? "划线注释" : "划线"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentActions(for highlight: RDEPUBHighlight, sourceIndexPath: IndexPath) {
|
|
||||||
let alert = UIAlertController(title: "标注管理", message: nil, preferredStyle: .actionSheet)
|
|
||||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
|
||||||
self?.onSelectHighlight?(highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "编辑批注", style: .default) { [weak self] _ in
|
|
||||||
self?.presentNoteEditor(for: highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "删除标注", style: .destructive) { [weak self] _ in
|
|
||||||
self?.deleteHighlight(highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController,
|
|
||||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
|
||||||
popover.sourceView = cell
|
|
||||||
popover.sourceRect = cell.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func presentNoteEditor(for highlight: RDEPUBHighlight) {
|
|
||||||
let alert = UIAlertController(title: "编辑批注", message: nil, preferredStyle: .alert)
|
|
||||||
alert.addTextField { textField in
|
|
||||||
textField.placeholder = "输入批注内容"
|
|
||||||
textField.text = highlight.note
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
|
||||||
guard let self,
|
|
||||||
let note = alert?.textFields?.first?.text,
|
|
||||||
let index = self.highlights.firstIndex(where: { $0.id == highlight.id }) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var updated = highlight
|
|
||||||
updated.note = self.normalizedNote(note)
|
|
||||||
self.highlights[index] = updated
|
|
||||||
self.tableView.reloadData()
|
|
||||||
self.onUpdateHighlight?(updated)
|
|
||||||
self.updateEmptyState()
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
private func deleteHighlight(_ highlight: RDEPUBHighlight) {
|
||||||
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
guard let index = highlights.firstIndex(where: { $0.id == highlight.id }) else { return }
|
||||||
highlights.remove(at: index)
|
highlights.remove(at: index)
|
||||||
@@ -248,7 +225,10 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
view.accessibilityIdentifier = "epub.reader.bookmarks.panel"
|
||||||
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
tableView.accessibilityIdentifier = "epub.reader.bookmarks.table"
|
||||||
tableView.tableFooterView = UIView(frame: .zero)
|
tableView.tableFooterView = UIView(frame: .zero)
|
||||||
tableView.separatorInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
tableView.separatorStyle = .none
|
||||||
|
tableView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 20, right: 0)
|
||||||
|
navigationItem.rightBarButtonItem = editButtonItem
|
||||||
|
editButtonItem.title = "编辑"
|
||||||
applyTheme()
|
applyTheme()
|
||||||
updateEmptyState()
|
updateEmptyState()
|
||||||
}
|
}
|
||||||
@@ -262,30 +242,40 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) ?? UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
|
||||||
let bookmark = bookmarks[indexPath.row]
|
let bookmark = bookmarks[indexPath.row]
|
||||||
|
|
||||||
cell.backgroundColor = theme.contentBackgroundColor
|
cell.backgroundColor = .clear
|
||||||
cell.textLabel?.textColor = theme.contentTextColor
|
cell.contentView.backgroundColor = UIColor.white.withAlphaComponent(0.76)
|
||||||
|
cell.contentView.layer.cornerRadius = 14
|
||||||
|
cell.contentView.layer.masksToBounds = true
|
||||||
|
cell.textLabel?.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
cell.textLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
||||||
cell.textLabel?.numberOfLines = 2
|
cell.textLabel?.numberOfLines = 2
|
||||||
cell.textLabel?.text = titleText(for: bookmark)
|
cell.textLabel?.text = titleText(for: bookmark)
|
||||||
|
|
||||||
cell.detailTextLabel?.textColor = theme.contentTextColor.withAlphaComponent(0.7)
|
cell.detailTextLabel?.textColor = UIColor(red: 0.34, green: 0.36, blue: 0.40, alpha: 1)
|
||||||
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
cell.detailTextLabel?.font = UIFont.systemFont(ofSize: 12)
|
||||||
cell.detailTextLabel?.numberOfLines = 3
|
cell.detailTextLabel?.numberOfLines = 3
|
||||||
cell.detailTextLabel?.text = detailText(for: bookmark)
|
cell.detailTextLabel?.text = detailText(for: bookmark)
|
||||||
cell.accessoryType = .disclosureIndicator
|
cell.accessoryType = .none
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { 104 }
|
||||||
|
|
||||||
|
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
|
||||||
|
guard editingStyle == .delete else { return }
|
||||||
|
deleteBookmark(bookmarks[indexPath.row])
|
||||||
|
}
|
||||||
|
|
||||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
presentActions(for: bookmarks[indexPath.row], sourceIndexPath: indexPath)
|
onSelectBookmark?(bookmarks[indexPath.row])
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme() {
|
private func applyTheme() {
|
||||||
tableView.backgroundColor = theme.contentBackgroundColor
|
tableView.backgroundColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
navigationController?.navigationBar.tintColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
navigationController?.navigationBar.barTintColor = tableView.backgroundColor
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)]
|
||||||
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
navigationController?.navigationBar.accessibilityIdentifier = "epub.reader.bookmarks.navbar"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,25 +312,6 @@ final class RDEPUBReaderBookmarksViewController: UITableViewController {
|
|||||||
return parts.joined(separator: "\n")
|
return parts.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentActions(for bookmark: RDEPUBBookmark, sourceIndexPath: IndexPath) {
|
|
||||||
let alert = UIAlertController(title: "书签管理", message: nil, preferredStyle: .actionSheet)
|
|
||||||
alert.addAction(UIAlertAction(title: "跳转到位置", style: .default) { [weak self] _ in
|
|
||||||
self?.onSelectBookmark?(bookmark)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "删除书签", style: .destructive) { [weak self] _ in
|
|
||||||
self?.deleteBookmark(bookmark)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController,
|
|
||||||
let cell = tableView.cellForRow(at: sourceIndexPath) {
|
|
||||||
popover.sourceView = cell
|
|
||||||
popover.sourceRect = cell.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
private func deleteBookmark(_ bookmark: RDEPUBBookmark) {
|
||||||
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
guard let index = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else { return }
|
||||||
bookmarks.remove(at: index)
|
bookmarks.remove(at: index)
|
||||||
|
|||||||
@@ -119,21 +119,13 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
super.apply(theme: theme)
|
super.apply(theme: theme)
|
||||||
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
let isDarkBackground = theme.contentBackgroundColor.rd_isDarkBackground
|
||||||
|
|
||||||
let overlayColor = isDarkBackground
|
let overlayColor = UIColor(white: 0.05, alpha: 0.32)
|
||||||
? UIColor(white: 0.12, alpha: 0.92)
|
let panelColor = UIColor(red: 0.975, green: 0.965, blue: 0.935, alpha: 1)
|
||||||
: UIColor(white: 0.08, alpha: 0.82)
|
let rowColor = UIColor.white.withAlphaComponent(0.88)
|
||||||
let panelColor = isDarkBackground
|
let cardColor = UIColor.white.withAlphaComponent(0.78)
|
||||||
? UIColor(red: 0.18, green: 0.18, blue: 0.19, alpha: 1)
|
let activeCardColor = UIColor(red: 0.90, green: 0.94, blue: 1.0, alpha: 1)
|
||||||
: UIColor(red: 0.15, green: 0.15, blue: 0.16, alpha: 1)
|
let textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
let rowColor = isDarkBackground
|
let secondaryTextColor = UIColor(red: 0.38, green: 0.40, blue: 0.44, alpha: 1)
|
||||||
? UIColor(white: 0.18, alpha: 1)
|
|
||||||
: UIColor(white: 0.14, alpha: 0.96)
|
|
||||||
let cardColor = isDarkBackground
|
|
||||||
? UIColor(white: 0.12, alpha: 1)
|
|
||||||
: UIColor(white: 0.10, alpha: 0.98)
|
|
||||||
let activeCardColor = UIColor(red: 0.17, green: 0.28, blue: 0.38, alpha: 1)
|
|
||||||
let textColor = UIColor(white: 0.96, alpha: 1)
|
|
||||||
let secondaryTextColor = UIColor(white: 0.72, alpha: 1)
|
|
||||||
|
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
backgroundButton.backgroundColor = overlayColor
|
backgroundButton.backgroundColor = overlayColor
|
||||||
@@ -141,7 +133,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
grabberView.backgroundColor = UIColor(white: 0.75, alpha: 0.7)
|
||||||
searchRowView.backgroundColor = rowColor
|
searchRowView.backgroundColor = rowColor
|
||||||
searchFieldContainer.backgroundColor = .clear
|
searchFieldContainer.backgroundColor = .clear
|
||||||
searchFieldDivider.backgroundColor = UIColor(white: 1, alpha: 0.12)
|
searchFieldDivider.backgroundColor = UIColor.black.withAlphaComponent(0.10)
|
||||||
searchIcon.tintColor = secondaryTextColor
|
searchIcon.tintColor = secondaryTextColor
|
||||||
cancelButton.tintColor = textColor
|
cancelButton.tintColor = textColor
|
||||||
cancelButton.setTitleColor(textColor, for: .normal)
|
cancelButton.setTitleColor(textColor, for: .normal)
|
||||||
@@ -165,8 +157,8 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
RDEPUBReaderSearchResultCell.cardBackgroundColor = cardColor
|
||||||
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
RDEPUBReaderSearchResultCell.activeCardBackgroundColor = activeCardColor
|
||||||
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
RDEPUBReaderSearchResultCell.primaryTextColor = textColor
|
||||||
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor.systemBlue
|
RDEPUBReaderSearchResultCell.highlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.40, green: 0.77, blue: 1, alpha: 1)
|
RDEPUBReaderSearchResultCell.activeHighlightTextColor = UIColor(red: 0.10, green: 0.34, blue: 0.78, alpha: 1)
|
||||||
|
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
@@ -274,10 +266,10 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
searchIcon.contentMode = .scaleAspectFit
|
searchIcon.contentMode = .scaleAspectFit
|
||||||
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
searchIcon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
|
||||||
|
|
||||||
panelView.layer.cornerRadius = 28
|
panelView.layer.cornerRadius = 0
|
||||||
panelView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
|
||||||
panelView.clipsToBounds = true
|
panelView.clipsToBounds = true
|
||||||
|
|
||||||
|
grabberView.isHidden = true
|
||||||
grabberView.layer.cornerRadius = 3
|
grabberView.layer.cornerRadius = 3
|
||||||
searchRowView.layer.cornerRadius = 22
|
searchRowView.layer.cornerRadius = 22
|
||||||
searchFieldContainer.layer.cornerRadius = 22
|
searchFieldContainer.layer.cornerRadius = 22
|
||||||
@@ -317,7 +309,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
panelView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
panelView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
panelView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor, constant: 8),
|
panelView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
panelView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
|
||||||
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
grabberView.topAnchor.constraint(equalTo: panelView.topAnchor, constant: 10),
|
||||||
@@ -327,7 +319,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
|
|||||||
|
|
||||||
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
searchRowView.leadingAnchor.constraint(equalTo: panelView.leadingAnchor, constant: 20),
|
||||||
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
searchRowView.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -20),
|
||||||
searchRowView.topAnchor.constraint(equalTo: grabberView.bottomAnchor, constant: 18),
|
searchRowView.topAnchor.constraint(equalTo: panelView.safeAreaLayoutGuide.topAnchor, constant: 12),
|
||||||
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
searchRowView.heightAnchor.constraint(equalToConstant: 52),
|
||||||
|
|
||||||
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
searchFieldContainer.leadingAnchor.constraint(equalTo: searchRowView.leadingAnchor, constant: 12),
|
||||||
@@ -480,7 +472,7 @@ extension RDEPUBReaderSearchBarView: UITableViewDataSource, UITableViewDelegate
|
|||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
label.font = UIFont.systemFont(ofSize: 19, weight: .bold)
|
||||||
label.textColor = UIColor(white: 0.96, alpha: 1)
|
label.textColor = UIColor(red: 0.08, green: 0.10, blue: 0.14, alpha: 1)
|
||||||
label.text = searchSections[section].title
|
label.text = searchSections[section].title
|
||||||
label.numberOfLines = 2
|
label.numberOfLines = 2
|
||||||
container.addSubview(label)
|
container.addSubview(label)
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ public struct RDEPUBReaderTableOfContentsItem: Equatable {
|
|||||||
|
|
||||||
public var pageNumber: Int?
|
public var pageNumber: Int?
|
||||||
|
|
||||||
public init(title: String, href: String, depth: Int, pageNumber: Int? = nil) {
|
public var isReadable: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
href: String,
|
||||||
|
depth: Int,
|
||||||
|
pageNumber: Int? = nil,
|
||||||
|
isReadable: Bool = true
|
||||||
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
self.href = href
|
self.href = href
|
||||||
self.depth = depth
|
self.depth = depth
|
||||||
self.pageNumber = pageNumber
|
self.pageNumber = pageNumber
|
||||||
|
self.isReadable = isReadable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,7 @@ protocol RDEPUBWebContentViewDelegate: AnyObject {
|
|||||||
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
|
func epubWebContentView(_ contentView: RDEPUBWebContentView, didTapFootnoteWithAltText altText: String, sourceRect: CGRect?)
|
||||||
}
|
}
|
||||||
|
|
||||||
final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing {
|
final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing, RDEpubReaderPageVisibilityHandling {
|
||||||
weak var delegate: RDEPUBWebContentViewDelegate?
|
weak var delegate: RDEPUBWebContentViewDelegate?
|
||||||
|
|
||||||
/// The current href of the loaded chapter (exposed for image resolution).
|
/// The current href of the loaded chapter (exposed for image resolution).
|
||||||
@@ -90,6 +90,10 @@ final class RDEPUBWebContentView: UIView, RDEpubReaderPageResourceReleasing {
|
|||||||
self?.decorationOverlayView.applyDecorations(decorations)
|
self?.decorationOverlayView.applyDecorations(decorations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readerPageVisibilityDidChange(isVisible: Bool) {
|
||||||
|
epubWebView.setReaderPageVisible(isVisible)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
extension RDEPUBWebContentView: RDEPUBWebViewDelegate {
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ public final class RDEpubURLReaderController: UIViewController {
|
|||||||
let state = [
|
let state = [
|
||||||
"reader=opened",
|
"reader=opened",
|
||||||
"page=\(page)",
|
"page=\(page)",
|
||||||
|
"pagesPerScreen=\(readerController?.readerView.pagesPerScreen ?? 1)",
|
||||||
"display=\(display)",
|
"display=\(display)",
|
||||||
"toolbar=\(toolbar)",
|
"toolbar=\(toolbar)",
|
||||||
"highlights=\(highlights)",
|
"highlights=\(highlights)",
|
||||||
|
|||||||
+133
-43
@@ -53,7 +53,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil
|
note: String? = nil
|
||||||
) -> RDEPUBHighlight? {
|
) -> RDEPUBHighlight? {
|
||||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
addAnnotation(from: selection, style: .underline, color: color, note: note)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -61,7 +61,8 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
from selection: RDEPUBSelection? = nil,
|
from selection: RDEPUBSelection? = nil,
|
||||||
style: RDEPUBHighlightStyle,
|
style: RDEPUBHighlightStyle,
|
||||||
color: String = "#F8E16C",
|
color: String = "#F8E16C",
|
||||||
note: String? = nil
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false
|
||||||
) -> RDEPUBHighlight? {
|
) -> RDEPUBHighlight? {
|
||||||
guard let controller else { return nil }
|
guard let controller else { return nil }
|
||||||
let sourceSelection = selection ?? controller.currentSelection
|
let sourceSelection = selection ?? controller.currentSelection
|
||||||
@@ -71,22 +72,35 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let newHighlight = RDEPUBHighlight(
|
var newHighlight = RDEPUBHighlight(
|
||||||
bookIdentifier: controller.currentBookIdentifier,
|
bookIdentifier: controller.currentBookIdentifier,
|
||||||
location: scopedSelection.location,
|
location: scopedSelection.location,
|
||||||
text: scopedSelection.text,
|
text: scopedSelection.text,
|
||||||
rangeInfo: scopedSelection.rangeInfo,
|
rangeInfo: scopedSelection.rangeInfo,
|
||||||
style: style,
|
style: .underline,
|
||||||
color: color,
|
color: color,
|
||||||
note: note
|
note: note,
|
||||||
|
isAnnotationOnly: isAnnotationOnly
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let mergeCandidates = controller.activeHighlights.filter {
|
||||||
|
shouldMerge($0, with: newHighlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mergeCandidates.isEmpty {
|
||||||
|
newHighlight = mergedHighlight(newHighlight, with: mergeCandidates)
|
||||||
|
controller.activeHighlights.removeAll { candidate in
|
||||||
|
mergeCandidates.contains { $0.id == candidate.id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let isDuplicate = controller.activeHighlights.contains { highlight in
|
let isDuplicate = controller.activeHighlights.contains { highlight in
|
||||||
highlight.location.href == newHighlight.location.href &&
|
highlight.location.href == newHighlight.location.href &&
|
||||||
highlight.location.fragment == newHighlight.location.fragment &&
|
highlight.location.fragment == newHighlight.location.fragment &&
|
||||||
highlight.text == newHighlight.text &&
|
highlight.text == newHighlight.text &&
|
||||||
highlight.rangeInfo == newHighlight.rangeInfo &&
|
highlight.rangeInfo == newHighlight.rangeInfo &&
|
||||||
highlight.style == newHighlight.style
|
highlight.style == newHighlight.style &&
|
||||||
|
highlight.isAnnotationOnly == newHighlight.isAnnotationOnly
|
||||||
}
|
}
|
||||||
guard !isDuplicate else {
|
guard !isDuplicate else {
|
||||||
return nil
|
return nil
|
||||||
@@ -219,25 +233,29 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
presentAnnotationActionSheet(for: currentSelection)
|
presentAnnotationActionSheet(for: currentSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||||
guard let controller else { return }
|
switch action {
|
||||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
case .copy:
|
||||||
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
|
UIPasteboard.general.string = highlight.text
|
||||||
_ = self?.removeHighlight(id: highlight.id)
|
case .createUnderline:
|
||||||
})
|
guard highlight.isAnnotationOnly,
|
||||||
if highlight.hasNote {
|
let controller,
|
||||||
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
|
let index = controller.activeHighlights.firstIndex(where: { $0.id == highlight.id }) else {
|
||||||
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
|
return
|
||||||
})
|
}
|
||||||
|
controller.activeHighlights[index].isAnnotationOnly = false
|
||||||
|
persistHighlightsAndRefreshContent()
|
||||||
|
case .deleteUnderline:
|
||||||
|
_ = removeHighlight(id: highlight.id)
|
||||||
|
case .annotate:
|
||||||
|
presentAnnotationNoteEditor(for: highlight)
|
||||||
|
case .deleteAnnotation:
|
||||||
|
if highlight.isAnnotationOnly {
|
||||||
|
_ = removeHighlight(id: highlight.id)
|
||||||
|
} else {
|
||||||
|
_ = updateHighlightNote(id: highlight.id, note: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
|
|
||||||
if let popover = alert.popoverPresentationController {
|
|
||||||
popover.sourceView = sourceView
|
|
||||||
popover.sourceRect = sourceRect
|
|
||||||
}
|
|
||||||
|
|
||||||
controller.present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
@@ -247,7 +265,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
UIPasteboard.general.string = selection.text
|
UIPasteboard.general.string = selection.text
|
||||||
updateCurrentSelection(nil)
|
updateCurrentSelection(nil)
|
||||||
case .highlight:
|
case .highlight:
|
||||||
createAnnotation(from: selection, style: .highlight)
|
createAnnotation(from: selection, style: .underline)
|
||||||
case .annotate:
|
case .annotate:
|
||||||
presentAnnotationNoteEditor(for: selection)
|
presentAnnotationNoteEditor(for: selection)
|
||||||
}
|
}
|
||||||
@@ -365,13 +383,65 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
location: normalizedLocation,
|
location: normalizedLocation,
|
||||||
text: highlight.text,
|
text: highlight.text,
|
||||||
rangeInfo: highlight.rangeInfo,
|
rangeInfo: highlight.rangeInfo,
|
||||||
style: highlight.style,
|
style: .underline,
|
||||||
color: highlight.color,
|
color: highlight.color,
|
||||||
note: highlight.note,
|
note: highlight.note,
|
||||||
|
isAnnotationOnly: highlight.isAnnotationOnly,
|
||||||
createdAt: highlight.createdAt
|
createdAt: highlight.createdAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func shouldMerge(_ existing: RDEPUBHighlight, with incoming: RDEPUBHighlight) -> Bool {
|
||||||
|
guard existing.location.href == incoming.location.href,
|
||||||
|
let existingRange = RDEPUBTextOffsetRangeInfo.decode(from: existing.rangeInfo)?.nsRange,
|
||||||
|
let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo)?.nsRange else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Only merge identical selections. Contained or partially-overlapping
|
||||||
|
// ranges remain independent so a reader can still manage each mark.
|
||||||
|
return existingRange.location == incomingRange.location
|
||||||
|
&& existingRange.length == incomingRange.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergedHighlight(
|
||||||
|
_ incoming: RDEPUBHighlight,
|
||||||
|
with existingHighlights: [RDEPUBHighlight]
|
||||||
|
) -> RDEPUBHighlight {
|
||||||
|
var result = incoming
|
||||||
|
let allHighlights = existingHighlights + [incoming]
|
||||||
|
let ranges = allHighlights.compactMap {
|
||||||
|
RDEPUBTextOffsetRangeInfo.decode(from: $0.rangeInfo)
|
||||||
|
}
|
||||||
|
if let first = ranges.first {
|
||||||
|
let start = ranges.map(\.start).min() ?? first.start
|
||||||
|
let end = ranges.map(\.end).max() ?? first.end
|
||||||
|
result.rangeInfo = RDEPUBTextOffsetRangeInfo(href: first.href, start: start, end: end).jsonString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest selection is exact whenever it encloses the merged range;
|
||||||
|
// otherwise keep every distinct excerpt rather than silently losing
|
||||||
|
// text that may later be copied from the marking list.
|
||||||
|
if let incomingRange = RDEPUBTextOffsetRangeInfo.decode(from: incoming.rangeInfo),
|
||||||
|
let mergedRange = RDEPUBTextOffsetRangeInfo.decode(from: result.rangeInfo),
|
||||||
|
incomingRange.start <= mergedRange.start,
|
||||||
|
incomingRange.end >= mergedRange.end {
|
||||||
|
result.text = incoming.text
|
||||||
|
} else {
|
||||||
|
result.text = allHighlights.map(\.text).reduce(into: [String]()) { texts, text in
|
||||||
|
if !texts.contains(text) { texts.append(text) }
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
let notes = allHighlights.compactMap(\.note).map {
|
||||||
|
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}.filter { !$0.isEmpty }
|
||||||
|
let uniqueNotes = notes.reduce(into: [String]()) { values, note in
|
||||||
|
if !values.contains(note) { values.append(note) }
|
||||||
|
}
|
||||||
|
result.note = uniqueNotes.isEmpty ? nil : uniqueNotes.joined(separator: "\n\n")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
private func navigate(to highlight: RDEPUBHighlight, animated: Bool) -> Bool {
|
||||||
guard let controller else { return false }
|
guard let controller else { return false }
|
||||||
@@ -410,13 +480,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: "创建标注", message: nil, preferredStyle: .actionSheet)
|
||||||
alert.addAction(UIAlertAction(title: "高亮", style: .default) { [weak self] _ in
|
|
||||||
self?.createAnnotation(from: selection, style: .highlight)
|
|
||||||
})
|
|
||||||
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "划线", style: .default) { [weak self] _ in
|
||||||
self?.createAnnotation(from: selection, style: .underline)
|
self?.createAnnotation(from: selection, style: .underline)
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "批注", style: .default) { [weak self] _ in
|
alert.addAction(UIAlertAction(title: "注释", style: .default) { [weak self] _ in
|
||||||
self?.presentAnnotationNoteEditor(for: selection)
|
self?.presentAnnotationNoteEditor(for: selection)
|
||||||
})
|
})
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
@@ -429,25 +496,48 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
controller.present(alert, animated: true)
|
controller.present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createAnnotation(from selection: RDEPUBSelection, style: RDEPUBHighlightStyle, note: String? = nil) {
|
private func createAnnotation(
|
||||||
_ = addAnnotation(from: selection, style: style, note: note)
|
from selection: RDEPUBSelection,
|
||||||
|
style: RDEPUBHighlightStyle,
|
||||||
|
note: String? = nil,
|
||||||
|
isAnnotationOnly: Bool = false
|
||||||
|
) {
|
||||||
|
_ = addAnnotation(
|
||||||
|
from: selection,
|
||||||
|
style: style,
|
||||||
|
note: note,
|
||||||
|
isAnnotationOnly: isAnnotationOnly
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
private func presentAnnotationNoteEditor(for selection: RDEPUBSelection) {
|
||||||
guard let controller else { return }
|
presentNoteEditor(quote: selection.text, initialNote: nil) { [weak self] note in
|
||||||
let alert = UIAlertController(title: "添加批注", message: selection.text, preferredStyle: .alert)
|
guard note != nil else { return }
|
||||||
alert.addTextField { textField in
|
|
||||||
textField.placeholder = "输入批注内容"
|
|
||||||
}
|
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "保存", style: .default) { [weak self, weak alert] _ in
|
|
||||||
self?.createAnnotation(
|
self?.createAnnotation(
|
||||||
from: selection,
|
from: selection,
|
||||||
style: .highlight,
|
style: .underline,
|
||||||
note: alert?.textFields?.first?.text
|
note: note,
|
||||||
|
isAnnotationOnly: true
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
controller.present(alert, animated: true)
|
}
|
||||||
|
|
||||||
|
private func presentAnnotationNoteEditor(for highlight: RDEPUBHighlight) {
|
||||||
|
presentNoteEditor(quote: highlight.text, initialNote: highlight.note) { [weak self] note in
|
||||||
|
_ = self?.updateHighlightNote(id: highlight.id, note: note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentNoteEditor(quote: String, initialNote: String?, onSave: @escaping (String?) -> Void) {
|
||||||
|
guard let controller else { return }
|
||||||
|
let editor = RDEPUBAnnotationEditorViewController(
|
||||||
|
quote: quote,
|
||||||
|
initialNote: initialNote,
|
||||||
|
onSave: onSave
|
||||||
|
)
|
||||||
|
let navigationController = UINavigationController(rootViewController: editor)
|
||||||
|
navigationController.modalPresentationStyle = .pageSheet
|
||||||
|
controller.present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
private func titleForHighlight(_ highlight: RDEPUBHighlight) -> String? {
|
||||||
|
|||||||
+83
-5
@@ -4,6 +4,8 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
|
|
||||||
private unowned let context: RDEPUBReaderContext
|
private unowned let context: RDEPUBReaderContext
|
||||||
|
|
||||||
|
private var settingsTransitionDelegate: RDEPUBReaderSettingsTransitionDelegate?
|
||||||
|
|
||||||
init(context: RDEPUBReaderContext) {
|
init(context: RDEPUBReaderContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
}
|
}
|
||||||
@@ -122,9 +124,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
settingsController.onLineHeightChange = { [weak controller] lineHeightMultiple in
|
||||||
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
controller?.updateConfiguration { $0.lineHeightMultiple = lineHeightMultiple }
|
||||||
}
|
}
|
||||||
settingsController.onColumnCountChange = { [weak controller] numberOfColumns in
|
|
||||||
controller?.updateConfiguration { $0.numberOfColumns = numberOfColumns }
|
|
||||||
}
|
|
||||||
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
settingsController.onDisplayTypeChange = { [weak controller] displayType in
|
||||||
controller?.updateConfiguration { $0.displayType = displayType }
|
controller?.updateConfiguration { $0.displayType = displayType }
|
||||||
}
|
}
|
||||||
@@ -137,8 +136,13 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
let navigationController = UINavigationController(rootViewController: settingsController)
|
let navigationController = UINavigationController(rootViewController: settingsController)
|
||||||
navigationController.modalPresentationStyle = .pageSheet
|
let transitionDelegate = RDEPUBReaderSettingsTransitionDelegate()
|
||||||
navigationController.presentationController?.delegate = self
|
settingsTransitionDelegate = transitionDelegate
|
||||||
|
navigationController.setNavigationBarHidden(true, animated: false)
|
||||||
|
navigationController.view.backgroundColor = .clear
|
||||||
|
navigationController.view.isOpaque = false
|
||||||
|
navigationController.modalPresentationStyle = .custom
|
||||||
|
navigationController.transitioningDelegate = transitionDelegate
|
||||||
controller.present(navigationController, animated: true)
|
controller.present(navigationController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,3 +237,77 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class RDEPUBReaderSettingsTransitionDelegate: NSObject, UIViewControllerTransitioningDelegate {
|
||||||
|
|
||||||
|
func presentationController(
|
||||||
|
forPresented presented: UIViewController,
|
||||||
|
presenting: UIViewController?,
|
||||||
|
source: UIViewController
|
||||||
|
) -> UIPresentationController? {
|
||||||
|
RDEPUBReaderSettingsPresentationController(
|
||||||
|
presentedViewController: presented,
|
||||||
|
presenting: presenting ?? source
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RDEPUBReaderSettingsPresentationController: UIPresentationController {
|
||||||
|
|
||||||
|
private let dimmingView = UIControl()
|
||||||
|
|
||||||
|
override var frameOfPresentedViewInContainerView: CGRect {
|
||||||
|
guard let containerView else { return .zero }
|
||||||
|
let bounds = containerView.bounds
|
||||||
|
let isLandscape = bounds.width > bounds.height
|
||||||
|
if isLandscape {
|
||||||
|
let width = min(390, max(300, bounds.width * 0.32))
|
||||||
|
let height = min(340, bounds.height - 32)
|
||||||
|
return CGRect(
|
||||||
|
x: bounds.maxX - width - 16,
|
||||||
|
y: bounds.midY - height / 2,
|
||||||
|
width: width,
|
||||||
|
height: height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let height = min(350, max(280, bounds.height * 0.40))
|
||||||
|
return CGRect(x: 0, y: bounds.maxY - height, width: bounds.width, height: height)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func presentationTransitionWillBegin() {
|
||||||
|
guard let containerView else { return }
|
||||||
|
dimmingView.backgroundColor = UIColor.black.withAlphaComponent(0.18)
|
||||||
|
dimmingView.alpha = 0
|
||||||
|
dimmingView.addTarget(self, action: #selector(dismissPresentedController), for: .touchUpInside)
|
||||||
|
dimmingView.frame = containerView.bounds
|
||||||
|
containerView.insertSubview(dimmingView, at: 0)
|
||||||
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||||
|
self.dimmingView.alpha = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dismissalTransitionWillBegin() {
|
||||||
|
presentedViewController.transitionCoordinator?.animate(alongsideTransition: { _ in
|
||||||
|
self.dimmingView.alpha = 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override func containerViewWillLayoutSubviews() {
|
||||||
|
super.containerViewWillLayoutSubviews()
|
||||||
|
dimmingView.frame = containerView?.bounds ?? .zero
|
||||||
|
presentedView?.frame = frameOfPresentedViewInContainerView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func dismissalTransitionDidEnd(_ completed: Bool) {
|
||||||
|
super.dismissalTransitionDidEnd(completed)
|
||||||
|
if completed { dimmingView.removeFromSuperview() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissPresentedController() {
|
||||||
|
if let navigationController = presentedViewController as? UINavigationController,
|
||||||
|
let settingsController = navigationController.topViewController as? RDEPUBReaderSettingsViewController {
|
||||||
|
settingsController.notifyDismissalIfNeeded()
|
||||||
|
}
|
||||||
|
presentedViewController.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -162,9 +162,16 @@ final class RDEPUBReaderContext {
|
|||||||
environment.currentLayoutContext(configuration: configuration)
|
environment.currentLayoutContext(configuration: configuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentPreferences() -> RDEPUBPreferences {
|
func currentPreferences(pageIndex: Int? = nil) -> RDEPUBPreferences {
|
||||||
let safeInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
var preferences = environment.currentPreferences(configuration: configuration, pageIndex: pageIndex)
|
||||||
return configuration.makePreferences(safeAreaInsets: safeInsets)
|
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.
|
/// Captures all layout parameters needed for background chapter loading.
|
||||||
|
|||||||
@@ -10,14 +10,59 @@ enum RDEPUBTextPageLayoutMetrics {
|
|||||||
|
|
||||||
static func contentInsets(
|
static func contentInsets(
|
||||||
configuration: RDEPUBReaderConfiguration,
|
configuration: RDEPUBReaderConfiguration,
|
||||||
safeAreaInsets: UIEdgeInsets
|
safeAreaInsets: UIEdgeInsets,
|
||||||
|
isLandscapeDualPage: Bool = false,
|
||||||
|
isLeadingPage: Bool = true
|
||||||
) -> UIEdgeInsets {
|
) -> UIEdgeInsets {
|
||||||
let configInsets = configuration.reflowableContentInsets
|
let configInsets = configuration.reflowableContentInsets
|
||||||
|
guard isLandscapeDualPage else {
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(configInsets.top, safeAreaInsets.top),
|
||||||
|
left: max(configInsets.left, safeAreaInsets.left),
|
||||||
|
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||||||
|
right: max(configInsets.right, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outerHorizontalInset = max(
|
||||||
|
max(configInsets.left, configInsets.right),
|
||||||
|
max(safeAreaInsets.left, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
let spineInset = RDEpubReaderView.landscapeDualPageGap / 2
|
||||||
return UIEdgeInsets(
|
return UIEdgeInsets(
|
||||||
top: max(configInsets.top, safeAreaInsets.top),
|
top: max(safeAreaInsets.top, min(configInsets.top, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||||
left: max(configInsets.left, safeAreaInsets.left),
|
left: isLeadingPage ? outerHorizontalInset : spineInset,
|
||||||
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
bottom: max(safeAreaInsets.bottom, min(configInsets.bottom, RDEPUBSafeArea.landscapeVerticalTextMargin)) + pageNumberReservedHeight,
|
||||||
right: max(configInsets.right, safeAreaInsets.right)
|
right: isLeadingPage ? spineInset : outerHorizontalInset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fixedContentInsets(
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
safeAreaInsets: UIEdgeInsets,
|
||||||
|
isLandscapeDualPage: Bool,
|
||||||
|
isLeadingPage: Bool
|
||||||
|
) -> UIEdgeInsets {
|
||||||
|
let configInsets = configuration.fixedContentInset
|
||||||
|
guard isLandscapeDualPage else {
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(configInsets.top, safeAreaInsets.top),
|
||||||
|
left: max(configInsets.left, safeAreaInsets.left),
|
||||||
|
bottom: max(configInsets.bottom, safeAreaInsets.bottom),
|
||||||
|
right: max(configInsets.right, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outerHorizontalInset = max(
|
||||||
|
max(configInsets.left, configInsets.right),
|
||||||
|
max(safeAreaInsets.left, safeAreaInsets.right)
|
||||||
|
)
|
||||||
|
let spineInset = RDEpubReaderView.landscapeDualPageGap / 2
|
||||||
|
return UIEdgeInsets(
|
||||||
|
top: max(safeAreaInsets.top, min(configInsets.top, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||||
|
left: isLeadingPage ? outerHorizontalInset : spineInset,
|
||||||
|
bottom: max(safeAreaInsets.bottom, min(configInsets.bottom, RDEPUBSafeArea.landscapeVerticalTextMargin)),
|
||||||
|
right: isLeadingPage ? spineInset : outerHorizontalInset
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +119,8 @@ final class RDEPUBReaderEnvironment {
|
|||||||
frameHeight: max(pageSize.height, 1),
|
frameHeight: max(pageSize.height, 1),
|
||||||
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: safeAreaInsets
|
safeAreaInsets: safeAreaInsets,
|
||||||
|
isLandscapeDualPage: readerView?.pagesPerScreen == 2
|
||||||
),
|
),
|
||||||
numberOfColumns: configuration.numberOfColumns,
|
numberOfColumns: configuration.numberOfColumns,
|
||||||
columnGap: configuration.columnGap,
|
columnGap: configuration.columnGap,
|
||||||
@@ -87,6 +133,29 @@ final class RDEPUBReaderEnvironment {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func currentPreferences(
|
||||||
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
pageIndex: Int? = nil
|
||||||
|
) -> RDEPUBPreferences {
|
||||||
|
let safeAreaInsets = RDEPUBSafeArea.resolve(controller?.view.safeAreaInsets)
|
||||||
|
let isLandscapeDualPage = readerView?.pagesPerScreen == 2
|
||||||
|
let isLeadingPage = pageIndex.map { readerView?.isLeadingPage($0) ?? true } ?? true
|
||||||
|
var preferences = configuration.makePreferences(safeAreaInsets: safeAreaInsets)
|
||||||
|
preferences.reflowableContentInsets = RDEPUBTextPageLayoutMetrics.contentInsets(
|
||||||
|
configuration: configuration,
|
||||||
|
safeAreaInsets: safeAreaInsets,
|
||||||
|
isLandscapeDualPage: isLandscapeDualPage,
|
||||||
|
isLeadingPage: isLeadingPage
|
||||||
|
)
|
||||||
|
preferences.fixedContentInset = RDEPUBTextPageLayoutMetrics.fixedContentInsets(
|
||||||
|
configuration: configuration,
|
||||||
|
safeAreaInsets: safeAreaInsets,
|
||||||
|
isLandscapeDualPage: isLandscapeDualPage,
|
||||||
|
isLeadingPage: isLeadingPage
|
||||||
|
)
|
||||||
|
return preferences
|
||||||
|
}
|
||||||
|
|
||||||
var currentBrightness: CGFloat {
|
var currentBrightness: CGFloat {
|
||||||
get { displayEnvironment.currentBrightness }
|
get { displayEnvironment.currentBrightness }
|
||||||
set { displayEnvironment.currentBrightness = newValue }
|
set { displayEnvironment.currentBrightness = newValue }
|
||||||
|
|||||||
-4
@@ -354,10 +354,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
|
||||||
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
||||||
guard publication.spine.indices.contains(index) else { return false }
|
guard publication.spine.indices.contains(index) else { return false }
|
||||||
let item = publication.spine[index]
|
let item = publication.spine[index]
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ final class RDEPUBReaderRuntime {
|
|||||||
annotationCoordinator.presentAnnotationCreation()
|
annotationCoordinator.presentAnnotationCreation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
func handleHighlightMenuAction(_ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight) {
|
||||||
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
|
annotationCoordinator.handleHighlightMenuAction(action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||||
|
|||||||
@@ -297,6 +297,9 @@ final class RDEPUBReaderSearchCoordinator {
|
|||||||
private func notifySearchStateChanged() {
|
private func notifySearchStateChanged() {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
let state = controller.searchState
|
let state = controller.searchState
|
||||||
|
// 搜索在后台队列执行,结果落地后需要同步刷新搜索面板计数;
|
||||||
|
// 否则正文高亮已更新时,列表仍可能停留在“搜索中”或空状态。
|
||||||
|
controller.updateSearchCount()
|
||||||
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
||||||
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
||||||
}
|
}
|
||||||
|
|||||||
+215
-248
@@ -1,30 +1,19 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// Compact reader settings drawer. It deliberately keeps the book visible and
|
||||||
|
/// exposes only the controls currently supported by the reader configuration.
|
||||||
final class RDEPUBReaderSettingsViewController: UIViewController {
|
final class RDEPUBReaderSettingsViewController: UIViewController {
|
||||||
|
|
||||||
var onBrightnessChange: ((CGFloat) -> Void)?
|
var onBrightnessChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onFontSizeChange: ((CGFloat) -> Void)?
|
var onFontSizeChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
var onFontChoiceChange: ((RDEPUBReaderFontChoice) -> Void)?
|
||||||
|
|
||||||
var onLineHeightChange: ((CGFloat) -> Void)?
|
var onLineHeightChange: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
var onColumnCountChange: ((Int) -> Void)?
|
|
||||||
|
|
||||||
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
var onDisplayTypeChange: ((RDEpubReaderView.DisplayType) -> Void)?
|
||||||
|
|
||||||
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
var onThemeChange: ((RDEPUBReaderTheme) -> Void)?
|
||||||
|
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
private enum ThemePreset: Int, CaseIterable {
|
private enum ThemePreset: Int, CaseIterable {
|
||||||
case light
|
case light, yellow, green, pink, blue, dark
|
||||||
case yellow
|
|
||||||
case green
|
|
||||||
case pink
|
|
||||||
case blue
|
|
||||||
case dark
|
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -49,50 +38,24 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let scrollView = UIScrollView()
|
private let handleView = UIView()
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
private let contentStack: UIStackView = {
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .vertical
|
|
||||||
stackView.spacing = 20
|
|
||||||
return stackView
|
|
||||||
}()
|
|
||||||
|
|
||||||
private let brightnessSlider = UISlider()
|
private let brightnessSlider = UISlider()
|
||||||
|
private let fontSizeSlider = UISlider()
|
||||||
private let fontValueLabel = UILabel()
|
private let fontSizeLabel = UILabel()
|
||||||
|
private let fontChoiceButton = UIButton(type: .system)
|
||||||
private let decreaseFontButton = UIButton(type: .system)
|
private let lineHeightButton = UIButton(type: .system)
|
||||||
|
private let displayTypeButton = UIButton(type: .system)
|
||||||
private let increaseFontButton = UIButton(type: .system)
|
private let themeStack = UIStackView()
|
||||||
|
|
||||||
private let fontChoiceControl = UISegmentedControl(items: RDEPUBReaderFontChoice.allCases.map(\.displayName))
|
|
||||||
|
|
||||||
private let lineHeightControl = UISegmentedControl(items: ["紧凑", "标准", "宽松"])
|
|
||||||
|
|
||||||
private let columnCountControl = UISegmentedControl(items: ["单栏", "双栏"])
|
|
||||||
|
|
||||||
private let displayTypeControl = UISegmentedControl(items: ["仿真", "横滑", "竖滑"])
|
|
||||||
|
|
||||||
private let themeStackView: UIStackView = {
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.distribution = .fillEqually
|
|
||||||
stackView.spacing = 12
|
|
||||||
return stackView
|
|
||||||
}()
|
|
||||||
|
|
||||||
private var themeButtons: [UIButton] = []
|
private var themeButtons: [UIButton] = []
|
||||||
|
|
||||||
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
|
||||||
|
|
||||||
private var currentConfiguration: RDEPUBReaderConfiguration
|
private var currentConfiguration: RDEPUBReaderConfiguration
|
||||||
|
private let lineHeightValues: [CGFloat] = [1.3, 1.6, 1.9]
|
||||||
|
private var hasNotifiedDismissal = false
|
||||||
|
|
||||||
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
init(configuration: RDEPUBReaderConfiguration, brightness: CGFloat) {
|
||||||
self.currentConfiguration = configuration
|
currentConfiguration = configuration
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
brightnessSlider.value = Float(brightness)
|
brightnessSlider.value = Float(brightness)
|
||||||
title = "样式设置"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@@ -101,262 +64,266 @@ final class RDEPUBReaderSettingsViewController: UIViewController {
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupNavigationItems()
|
|
||||||
setupViews()
|
setupViews()
|
||||||
syncControls()
|
syncControls()
|
||||||
applyTheme(currentConfiguration.theme)
|
applyTheme(currentConfiguration.theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupNavigationItems() {
|
|
||||||
let doneItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(doneAction))
|
|
||||||
doneItem.accessibilityIdentifier = "epub.reader.settings.done"
|
|
||||||
navigationItem.rightBarButtonItem = doneItem
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupViews() {
|
private func setupViews() {
|
||||||
view.addSubview(scrollView)
|
view.accessibilityIdentifier = "epub.reader.settings.panel"
|
||||||
scrollView.accessibilityIdentifier = "epub.reader.settings.scroll"
|
view.layer.cornerRadius = 24
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
view.layer.cornerCurve = .continuous
|
||||||
scrollView.addSubview(contentStack)
|
view.clipsToBounds = true
|
||||||
contentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
[handleView, closeButton, brightnessSlider, fontSizeSlider, fontSizeLabel,
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
fontChoiceButton, lineHeightButton, displayTypeButton, themeStack].forEach {
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
$0.translatesAutoresizingMaskIntoConstraints = false
|
||||||
scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
view.addSubview($0)
|
||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
}
|
||||||
|
|
||||||
contentStack.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor, constant: 16),
|
handleView.layer.cornerRadius = 3
|
||||||
contentStack.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor, constant: -16),
|
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
contentStack.topAnchor.constraint(equalTo: scrollView.topAnchor, constant: 20),
|
closeButton.accessibilityIdentifier = "epub.reader.settings.done"
|
||||||
contentStack.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: -24),
|
closeButton.accessibilityLabel = "完成"
|
||||||
contentStack.widthAnchor.constraint(equalTo: scrollView.widthAnchor, constant: -32)
|
closeButton.addTarget(self, action: #selector(doneAction), for: .touchUpInside)
|
||||||
])
|
|
||||||
|
|
||||||
brightnessSlider.minimumValue = 0
|
brightnessSlider.minimumValue = 0
|
||||||
brightnessSlider.maximumValue = 1
|
brightnessSlider.maximumValue = 1
|
||||||
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
brightnessSlider.accessibilityIdentifier = "epub.reader.settings.brightness"
|
||||||
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
brightnessSlider.addTarget(self, action: #selector(brightnessChanged(_:)), for: .valueChanged)
|
||||||
|
|
||||||
fontValueLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 16, weight: .semibold)
|
fontSizeSlider.minimumValue = 12
|
||||||
fontValueLabel.textAlignment = .center
|
fontSizeSlider.maximumValue = 36
|
||||||
fontValueLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
fontSizeSlider.accessibilityIdentifier = "epub.reader.settings.font.size"
|
||||||
fontValueLabel.setContentHuggingPriority(.required, for: .horizontal)
|
fontSizeSlider.addTarget(self, action: #selector(fontSizeChanged(_:)), for: .valueChanged)
|
||||||
|
fontSizeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: 14, weight: .semibold)
|
||||||
|
fontSizeLabel.textAlignment = .center
|
||||||
|
fontSizeLabel.accessibilityIdentifier = "epub.reader.settings.font.value"
|
||||||
|
|
||||||
configureFontButton(decreaseFontButton, title: "A-")
|
configurePill(fontChoiceButton, title: "字体")
|
||||||
configureFontButton(increaseFontButton, title: "A+")
|
configurePill(lineHeightButton, title: "行距")
|
||||||
decreaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.decrease"
|
configurePill(displayTypeButton, title: "翻页方式")
|
||||||
increaseFontButton.accessibilityIdentifier = "epub.reader.settings.font.increase"
|
fontChoiceButton.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
||||||
decreaseFontButton.addTarget(self, action: #selector(decreaseFontAction), for: .touchUpInside)
|
lineHeightButton.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
||||||
increaseFontButton.addTarget(self, action: #selector(increaseFontAction), for: .touchUpInside)
|
displayTypeButton.accessibilityIdentifier = "epub.reader.settings.displayType"
|
||||||
|
fontChoiceButton.addTarget(self, action: #selector(fontChoiceAction), for: .touchUpInside)
|
||||||
lineHeightControl.accessibilityIdentifier = "epub.reader.settings.lineHeight"
|
lineHeightButton.addTarget(self, action: #selector(lineHeightAction), for: .touchUpInside)
|
||||||
fontChoiceControl.accessibilityIdentifier = "epub.reader.settings.font.choice"
|
displayTypeButton.addTarget(self, action: #selector(displayTypeAction), for: .touchUpInside)
|
||||||
columnCountControl.accessibilityIdentifier = "epub.reader.settings.columns"
|
|
||||||
displayTypeControl.accessibilityIdentifier = "epub.reader.settings.displayType"
|
|
||||||
fontChoiceControl.addTarget(self, action: #selector(fontChoiceChanged(_:)), for: .valueChanged)
|
|
||||||
lineHeightControl.addTarget(self, action: #selector(lineHeightChanged(_:)), for: .valueChanged)
|
|
||||||
columnCountControl.addTarget(self, action: #selector(columnCountChanged(_:)), for: .valueChanged)
|
|
||||||
displayTypeControl.addTarget(self, action: #selector(displayTypeChanged(_:)), for: .valueChanged)
|
|
||||||
|
|
||||||
|
themeStack.axis = .horizontal
|
||||||
|
themeStack.distribution = .fillEqually
|
||||||
|
themeStack.spacing = 14
|
||||||
ThemePreset.allCases.forEach { preset in
|
ThemePreset.allCases.forEach { preset in
|
||||||
let button = UIButton(type: .system)
|
let button = UIButton(type: .system)
|
||||||
button.tag = preset.rawValue
|
button.tag = preset.rawValue
|
||||||
button.layer.cornerRadius = 18
|
|
||||||
button.layer.borderWidth = 1.5
|
|
||||||
button.backgroundColor = preset.theme.contentBackgroundColor
|
button.backgroundColor = preset.theme.contentBackgroundColor
|
||||||
|
button.layer.cornerRadius = 15
|
||||||
|
button.layer.borderWidth = 1
|
||||||
button.accessibilityLabel = preset.title
|
button.accessibilityLabel = preset.title
|
||||||
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
button.accessibilityIdentifier = "epub.reader.settings.theme.\(preset.rawValue)"
|
||||||
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
button.addTarget(self, action: #selector(themeButtonAction(_:)), for: .touchUpInside)
|
||||||
themeButtons.append(button)
|
themeButtons.append(button)
|
||||||
themeStackView.addArrangedSubview(button)
|
themeStack.addArrangedSubview(button)
|
||||||
NSLayoutConstraint.activate([
|
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
||||||
button.heightAnchor.constraint(equalToConstant: 36)
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
contentStack.addArrangedSubview(makeSection(title: "亮度", content: brightnessSlider))
|
let brightnessLabel = makeLabel("亮度")
|
||||||
contentStack.addArrangedSubview(makeSection(title: "字号", content: makeFontSizeRow()))
|
let fontSizeTitle = makeLabel("字体大小")
|
||||||
contentStack.addArrangedSubview(makeSection(title: "字体", content: fontChoiceControl))
|
let smallA = makeLabel("A", font: 18)
|
||||||
contentStack.addArrangedSubview(makeSection(title: "行距", content: lineHeightControl))
|
let largeA = makeLabel("A", font: 25)
|
||||||
contentStack.addArrangedSubview(makeSection(title: "分栏", content: columnCountControl))
|
[brightnessLabel, fontSizeTitle, smallA, largeA].forEach(view.addSubview)
|
||||||
contentStack.addArrangedSubview(makeSection(title: "翻页方式", content: displayTypeControl))
|
|
||||||
contentStack.addArrangedSubview(makeSection(title: "主题", content: themeStackView))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeSection(title: String, content: UIView) -> UIView {
|
let selectorStack = UIStackView(arrangedSubviews: [fontChoiceButton, lineHeightButton, displayTypeButton])
|
||||||
let container = UIStackView()
|
selectorStack.axis = .horizontal
|
||||||
container.axis = .vertical
|
selectorStack.distribution = .fillEqually
|
||||||
container.spacing = 10
|
selectorStack.spacing = 10
|
||||||
|
selectorStack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(selectorStack)
|
||||||
|
|
||||||
let titleLabel = UILabel()
|
let dividerOne = makeDivider()
|
||||||
titleLabel.text = title
|
let dividerTwo = makeDivider()
|
||||||
titleLabel.font = UIFont.systemFont(ofSize: 14, weight: .semibold)
|
[dividerOne, dividerTwo].forEach(view.addSubview)
|
||||||
|
|
||||||
content.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
container.addArrangedSubview(titleLabel)
|
|
||||||
container.addArrangedSubview(content)
|
|
||||||
return container
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeFontSizeRow() -> UIView {
|
|
||||||
let stackView = UIStackView(arrangedSubviews: [decreaseFontButton, fontValueLabel, increaseFontButton])
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.alignment = .center
|
|
||||||
stackView.distribution = .fill
|
|
||||||
stackView.spacing = 12
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
decreaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
handleView.topAnchor.constraint(equalTo: view.topAnchor, constant: 10),
|
||||||
increaseFontButton.widthAnchor.constraint(equalToConstant: 64),
|
handleView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
decreaseFontButton.heightAnchor.constraint(equalToConstant: 36),
|
handleView.widthAnchor.constraint(equalToConstant: 42),
|
||||||
increaseFontButton.heightAnchor.constraint(equalToConstant: 36)
|
handleView.heightAnchor.constraint(equalToConstant: 5),
|
||||||
|
closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
||||||
|
closeButton.centerYAnchor.constraint(equalTo: handleView.centerYAnchor),
|
||||||
|
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
||||||
|
closeButton.heightAnchor.constraint(equalToConstant: 32),
|
||||||
|
|
||||||
|
brightnessLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
brightnessLabel.topAnchor.constraint(equalTo: handleView.bottomAnchor, constant: 14),
|
||||||
|
brightnessSlider.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
brightnessSlider.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
brightnessSlider.topAnchor.constraint(equalTo: brightnessLabel.bottomAnchor, constant: 5),
|
||||||
|
dividerOne.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
dividerOne.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
dividerOne.topAnchor.constraint(equalTo: brightnessSlider.bottomAnchor, constant: 10),
|
||||||
|
|
||||||
|
fontSizeTitle.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
fontSizeTitle.topAnchor.constraint(equalTo: dividerOne.bottomAnchor, constant: 10),
|
||||||
|
smallA.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
smallA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
|
||||||
|
fontSizeSlider.leadingAnchor.constraint(equalTo: smallA.trailingAnchor, constant: 10),
|
||||||
|
fontSizeSlider.trailingAnchor.constraint(equalTo: largeA.leadingAnchor, constant: -10),
|
||||||
|
fontSizeSlider.topAnchor.constraint(equalTo: fontSizeTitle.bottomAnchor, constant: 4),
|
||||||
|
largeA.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
largeA.centerYAnchor.constraint(equalTo: fontSizeSlider.centerYAnchor),
|
||||||
|
fontSizeLabel.centerXAnchor.constraint(equalTo: fontSizeSlider.centerXAnchor),
|
||||||
|
fontSizeLabel.bottomAnchor.constraint(equalTo: fontSizeSlider.topAnchor, constant: -1),
|
||||||
|
dividerTwo.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
dividerTwo.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
dividerTwo.topAnchor.constraint(equalTo: fontSizeSlider.bottomAnchor, constant: 10),
|
||||||
|
|
||||||
|
selectorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
|
||||||
|
selectorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
|
||||||
|
selectorStack.topAnchor.constraint(equalTo: dividerTwo.bottomAnchor, constant: 12),
|
||||||
|
selectorStack.heightAnchor.constraint(equalToConstant: 42),
|
||||||
|
themeStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
||||||
|
themeStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
||||||
|
themeStack.topAnchor.constraint(equalTo: selectorStack.bottomAnchor, constant: 16),
|
||||||
|
themeStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -14)
|
||||||
])
|
])
|
||||||
return stackView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureFontButton(_ button: UIButton, title: String) {
|
private func makeLabel(_ text: String, font: CGFloat = 13) -> UILabel {
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = text
|
||||||
|
label.font = UIFont.systemFont(ofSize: font, weight: font > 16 ? .regular : .semibold)
|
||||||
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDivider() -> UIView {
|
||||||
|
let divider = UIView()
|
||||||
|
divider.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale).isActive = true
|
||||||
|
return divider
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configurePill(_ button: UIButton, title: String) {
|
||||||
button.setTitle(title, for: .normal)
|
button.setTitle(title, for: .normal)
|
||||||
button.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold)
|
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .semibold)
|
||||||
button.layer.cornerRadius = 18
|
button.layer.cornerRadius = 13
|
||||||
button.layer.borderWidth = 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncControls() {
|
private func syncControls() {
|
||||||
fontValueLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
fontSizeSlider.value = Float(currentConfiguration.fontSize)
|
||||||
fontChoiceControl.selectedSegmentIndex = RDEPUBReaderFontChoice.allCases.firstIndex(of: currentConfiguration.fontChoice) ?? 0
|
fontSizeLabel.text = String(Int(currentConfiguration.fontSize.rounded()))
|
||||||
|
updateSelectorTitles()
|
||||||
let lineHeightIndex = lineHeightValues.enumerated().min { abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple) }?.offset ?? 1
|
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light)
|
||||||
lineHeightControl.selectedSegmentIndex = lineHeightIndex
|
}
|
||||||
columnCountControl.selectedSegmentIndex = currentConfiguration.numberOfColumns > 1 ? 1 : 0
|
|
||||||
|
|
||||||
|
private func updateSelectorTitles() {
|
||||||
|
fontChoiceButton.setTitle("\(currentConfiguration.fontChoice.displayName) ›", for: .normal)
|
||||||
|
let lineIndex = lineHeightValues.enumerated().min {
|
||||||
|
abs($0.element - currentConfiguration.lineHeightMultiple) < abs($1.element - currentConfiguration.lineHeightMultiple)
|
||||||
|
}?.offset ?? 1
|
||||||
|
fontChoiceButton.accessibilityValue = currentConfiguration.fontChoice.displayName
|
||||||
|
let lineTitles = ["紧凑", "标准", "宽松"]
|
||||||
|
lineHeightButton.setTitle("\(lineTitles[lineIndex]) ›", for: .normal)
|
||||||
|
lineHeightButton.accessibilityValue = lineTitles[lineIndex]
|
||||||
|
let displayTitle: String
|
||||||
switch currentConfiguration.displayType {
|
switch currentConfiguration.displayType {
|
||||||
case .pageCurl:
|
case .pageCurl: displayTitle = "仿真"
|
||||||
displayTypeControl.selectedSegmentIndex = 0
|
case .horizontalScroll: displayTitle = "横滑"
|
||||||
case .horizontalScroll:
|
case .verticalScroll: displayTitle = "竖滑"
|
||||||
displayTypeControl.selectedSegmentIndex = 1
|
|
||||||
case .verticalScroll:
|
|
||||||
displayTypeControl.selectedSegmentIndex = 2
|
|
||||||
}
|
}
|
||||||
|
displayTypeButton.setTitle("\(displayTitle) ›", for: .normal)
|
||||||
let selectedPreset = ThemePreset.allCases.first(where: { $0.theme == currentConfiguration.theme }) ?? .light
|
displayTypeButton.accessibilityValue = displayTitle
|
||||||
updateThemeSelection(selectedPreset)
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
private func applyTheme(_ theme: RDEPUBReaderTheme) {
|
||||||
view.backgroundColor = theme.contentBackgroundColor
|
view.backgroundColor = theme.toolBackgroundColor.withAlphaComponent(0.98)
|
||||||
scrollView.backgroundColor = theme.contentBackgroundColor
|
let textColor = theme.toolControlTextColor
|
||||||
contentStack.arrangedSubviews
|
handleView.backgroundColor = textColor.withAlphaComponent(0.45)
|
||||||
.compactMap { $0 as? UIStackView }
|
closeButton.tintColor = textColor
|
||||||
.flatMap { $0.arrangedSubviews }
|
view.subviews.compactMap { $0 as? UILabel }.forEach { $0.textColor = textColor }
|
||||||
.compactMap { $0 as? UILabel }
|
view.subviews.filter { $0 !== handleView && $0 !== closeButton && !($0 is UISlider) && !($0 is UIStackView) && !($0 is UILabel) }
|
||||||
.forEach { $0.textColor = theme.contentTextColor }
|
.forEach { $0.backgroundColor = textColor.withAlphaComponent(0.12) }
|
||||||
|
[fontChoiceButton, lineHeightButton, displayTypeButton].forEach {
|
||||||
fontValueLabel.textColor = theme.contentTextColor
|
$0.setTitleColor(textColor, for: .normal)
|
||||||
[decreaseFontButton, increaseFontButton].forEach { button in
|
$0.backgroundColor = textColor.withAlphaComponent(0.10)
|
||||||
button.setTitleColor(theme.toolControlTextColor, for: .normal)
|
|
||||||
button.layer.borderColor = theme.toolControlBorderUnselectColor.cgColor
|
|
||||||
button.backgroundColor = theme.toolBackgroundColor
|
|
||||||
}
|
}
|
||||||
|
brightnessSlider.minimumTrackTintColor = .systemBlue
|
||||||
[fontChoiceControl, lineHeightControl, columnCountControl, displayTypeControl].forEach { control in
|
fontSizeSlider.minimumTrackTintColor = .systemBlue
|
||||||
control.backgroundColor = theme.toolBackgroundColor
|
brightnessSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||||
if #available(iOS 13.0, *) {
|
fontSizeSlider.maximumTrackTintColor = textColor.withAlphaComponent(0.22)
|
||||||
control.selectedSegmentTintColor = theme.toolControlTextColor.withAlphaComponent(0.14)
|
|
||||||
} else {
|
|
||||||
control.tintColor = theme.toolControlTextColor
|
|
||||||
}
|
|
||||||
control.setTitleTextAttributes([.foregroundColor: theme.contentTextColor], for: .normal)
|
|
||||||
control.setTitleTextAttributes([.foregroundColor: theme.toolControlTextColor], for: .selected)
|
|
||||||
}
|
|
||||||
|
|
||||||
navigationController?.navigationBar.tintColor = theme.toolControlTextColor
|
|
||||||
navigationController?.navigationBar.barTintColor = theme.toolBackgroundColor
|
|
||||||
navigationController?.navigationBar.titleTextAttributes = [.foregroundColor: theme.toolControlTextColor]
|
|
||||||
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
updateThemeSelection(ThemePreset.allCases.first(where: { $0.theme == theme }) ?? .light)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateThemeSelection(_ preset: ThemePreset) {
|
private func updateThemeSelection(_ preset: ThemePreset) {
|
||||||
themeButtons.forEach { button in
|
themeButtons.forEach { button in
|
||||||
let isSelected = button.tag == preset.rawValue
|
let selected = button.tag == preset.rawValue
|
||||||
button.layer.borderWidth = isSelected ? 2 : 1
|
button.layer.borderWidth = selected ? 2.5 : 1
|
||||||
button.layer.borderColor = isSelected ? currentConfiguration.theme.toolControlTextColor.cgColor : currentConfiguration.theme.toolControlBorderUnselectColor.cgColor
|
button.layer.borderColor = selected ? UIColor.systemBlue.cgColor : UIColor.white.withAlphaComponent(0.28).cgColor
|
||||||
button.accessibilityValue = isSelected ? "selected" : "unselected"
|
button.accessibilityValue = selected ? "selected" : "unselected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateControlAccessibilityValues() {
|
func notifyDismissalIfNeeded() {
|
||||||
fontChoiceControl.accessibilityValue = fontChoiceControl.titleForSegment(at: fontChoiceControl.selectedSegmentIndex)
|
guard !hasNotifiedDismissal else { return }
|
||||||
lineHeightControl.accessibilityValue = lineHeightControl.titleForSegment(at: lineHeightControl.selectedSegmentIndex)
|
hasNotifiedDismissal = true
|
||||||
columnCountControl.accessibilityValue = columnCountControl.titleForSegment(at: columnCountControl.selectedSegmentIndex)
|
onDismiss?()
|
||||||
displayTypeControl.accessibilityValue = displayTypeControl.titleForSegment(at: displayTypeControl.selectedSegmentIndex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func doneAction() {
|
@objc private func doneAction() {
|
||||||
dismiss(animated: true) { [weak self] in
|
notifyDismissalIfNeeded()
|
||||||
self?.onDismiss?()
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
@objc private func brightnessChanged(_ sender: UISlider) { onBrightnessChange?(CGFloat(sender.value)) }
|
||||||
|
|
||||||
|
@objc private func fontSizeChanged(_ sender: UISlider) {
|
||||||
|
let value = CGFloat(sender.value.rounded())
|
||||||
|
guard value != currentConfiguration.fontSize else { return }
|
||||||
|
currentConfiguration.fontSize = value
|
||||||
|
fontSizeLabel.text = String(Int(value))
|
||||||
|
onFontSizeChange?(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func fontChoiceAction() {
|
||||||
|
let alert = UIAlertController(title: "字体", message: nil, preferredStyle: .actionSheet)
|
||||||
|
RDEPUBReaderFontChoice.allCases.forEach { choice in
|
||||||
|
alert.addAction(UIAlertAction(title: choice.displayName, style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.currentConfiguration.fontChoice = choice
|
||||||
|
self.updateSelectorTitles()
|
||||||
|
self.onFontChoiceChange?(choice)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func brightnessChanged(_ slider: UISlider) {
|
@objc private func lineHeightAction() {
|
||||||
onBrightnessChange?(CGFloat(slider.value))
|
let alert = UIAlertController(title: "行距", message: nil, preferredStyle: .actionSheet)
|
||||||
}
|
zip(["紧凑", "标准", "宽松"], lineHeightValues).forEach { title, value in
|
||||||
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
@objc private func decreaseFontAction() {
|
guard let self else { return }
|
||||||
let nextValue = max(12, currentConfiguration.fontSize - 1)
|
self.currentConfiguration.lineHeightMultiple = value
|
||||||
guard nextValue != currentConfiguration.fontSize else { return }
|
self.updateSelectorTitles()
|
||||||
currentConfiguration.fontSize = nextValue
|
self.onLineHeightChange?(value)
|
||||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
})
|
||||||
onFontSizeChange?(nextValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func increaseFontAction() {
|
|
||||||
let nextValue = min(36, currentConfiguration.fontSize + 1)
|
|
||||||
guard nextValue != currentConfiguration.fontSize else { return }
|
|
||||||
currentConfiguration.fontSize = nextValue
|
|
||||||
fontValueLabel.text = String(Int(nextValue.rounded()))
|
|
||||||
onFontSizeChange?(nextValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func fontChoiceChanged(_ control: UISegmentedControl) {
|
|
||||||
let choices = RDEPUBReaderFontChoice.allCases
|
|
||||||
let index = max(0, min(control.selectedSegmentIndex, choices.count - 1))
|
|
||||||
let choice = choices[index]
|
|
||||||
guard choice != currentConfiguration.fontChoice else { return }
|
|
||||||
currentConfiguration.fontChoice = choice
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onFontChoiceChange?(choice)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func lineHeightChanged(_ control: UISegmentedControl) {
|
|
||||||
let index = max(0, min(control.selectedSegmentIndex, lineHeightValues.count - 1))
|
|
||||||
let value = lineHeightValues[index]
|
|
||||||
currentConfiguration.lineHeightMultiple = value
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onLineHeightChange?(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func columnCountChanged(_ control: UISegmentedControl) {
|
|
||||||
let numberOfColumns = control.selectedSegmentIndex == 1 ? 2 : 1
|
|
||||||
currentConfiguration.numberOfColumns = numberOfColumns
|
|
||||||
updateControlAccessibilityValues()
|
|
||||||
onColumnCountChange?(numberOfColumns)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func displayTypeChanged(_ control: UISegmentedControl) {
|
|
||||||
let displayType: RDEpubReaderView.DisplayType
|
|
||||||
switch control.selectedSegmentIndex {
|
|
||||||
case 1:
|
|
||||||
displayType = .horizontalScroll
|
|
||||||
case 2:
|
|
||||||
displayType = .verticalScroll
|
|
||||||
default:
|
|
||||||
displayType = .pageCurl
|
|
||||||
}
|
}
|
||||||
currentConfiguration.displayType = displayType
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
updateControlAccessibilityValues()
|
present(alert, animated: true)
|
||||||
onDisplayTypeChange?(displayType)
|
}
|
||||||
|
|
||||||
|
@objc private func displayTypeAction() {
|
||||||
|
let alert = UIAlertController(title: "翻页方式", message: nil, preferredStyle: .actionSheet)
|
||||||
|
[("仿真", RDEpubReaderView.DisplayType.pageCurl), ("横滑", .horizontalScroll), ("竖滑", .verticalScroll)].forEach { title, type in
|
||||||
|
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.currentConfiguration.displayType = type
|
||||||
|
self.updateSelectorTitles()
|
||||||
|
self.onDisplayTypeChange?(type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func themeButtonAction(_ sender: UIButton) {
|
@objc private func themeButtonAction(_ sender: UIButton) {
|
||||||
|
|||||||
@@ -28,11 +28,19 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
|||||||
)
|
)
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction,
|
||||||
sourceRect: CGRect
|
highlight: RDEPUBHighlight
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RDEPUBExistingHighlightMenuAction {
|
||||||
|
case copy
|
||||||
|
case createUnderline
|
||||||
|
case deleteUnderline
|
||||||
|
case annotate
|
||||||
|
case deleteAnnotation
|
||||||
|
}
|
||||||
|
|
||||||
extension RDEPUBTextContentViewDelegate {
|
extension RDEPUBTextContentViewDelegate {
|
||||||
func textContentView(
|
func textContentView(
|
||||||
_ contentView: RDEPUBTextContentView,
|
_ contentView: RDEPUBTextContentView,
|
||||||
@@ -60,6 +68,15 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
|
|
||||||
private var menuSelection: RDEPUBSelection?
|
private var menuSelection: RDEPUBSelection?
|
||||||
|
|
||||||
|
/// The underline whose compact text menu is currently visible. Keeping this
|
||||||
|
/// separate from `currentSelection` means a marked paragraph is still fully
|
||||||
|
/// selectable with a long press.
|
||||||
|
private var menuHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
|
private var menuUnderlineHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
|
private var menuAnnotationHighlight: RDEPUBHighlight?
|
||||||
|
|
||||||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||||||
// Stored as Any? to avoid @available on stored property restriction.
|
// Stored as Any? to avoid @available on stored property restriction.
|
||||||
private var _editMenuInteraction: Any?
|
private var _editMenuInteraction: Any?
|
||||||
@@ -347,23 +364,52 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
action == #selector(rd_copy(_:))
|
if menuHighlight != nil {
|
||||||
|
return action == #selector(rd_copy(_:))
|
||||||
|
|| action == #selector(rd_highlight(_:))
|
||||||
|
|| action == #selector(rd_deleteUnderline(_:))
|
||||||
|
|| action == #selector(rd_annotate(_:))
|
||||||
|
|| action == #selector(rd_deleteAnnotation(_:))
|
||||||
|
}
|
||||||
|
return action == #selector(rd_copy(_:))
|
||||||
|| action == #selector(rd_highlight(_:))
|
|| action == #selector(rd_highlight(_:))
|
||||||
|| action == #selector(rd_annotate(_:))
|
|| action == #selector(rd_annotate(_:))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_copy(_ sender: Any?) {
|
@objc func rd_copy(_ sender: Any?) {
|
||||||
|
if let highlight = menuHighlight {
|
||||||
|
performHighlightMenuAction(.copy, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.copy)
|
performSelectionAction(.copy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_highlight(_ sender: Any?) {
|
@objc func rd_highlight(_ sender: Any?) {
|
||||||
|
if let highlight = menuAnnotationHighlight {
|
||||||
|
performHighlightMenuAction(.createUnderline, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.highlight)
|
performSelectionAction(.highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func rd_annotate(_ sender: Any?) {
|
@objc func rd_annotate(_ sender: Any?) {
|
||||||
|
if let highlight = menuHighlight {
|
||||||
|
performHighlightMenuAction(.annotate, highlight: highlight)
|
||||||
|
return
|
||||||
|
}
|
||||||
performSelectionAction(.annotate)
|
performSelectionAction(.annotate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func rd_deleteUnderline(_ sender: Any?) {
|
||||||
|
guard let highlight = menuUnderlineHighlight else { return }
|
||||||
|
performHighlightMenuAction(.deleteUnderline, highlight: highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func rd_deleteAnnotation(_ sender: Any?) {
|
||||||
|
guard let highlight = menuAnnotationHighlight else { return }
|
||||||
|
performHighlightMenuAction(.deleteAnnotation, highlight: highlight)
|
||||||
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
@@ -398,6 +444,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
pageNumber: Int,
|
pageNumber: Int,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
configuration: RDEPUBReaderConfiguration,
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
contentInsets: UIEdgeInsets? = nil,
|
||||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||||
chapterFragmentOffsets: [String: Int] = [:],
|
chapterFragmentOffsets: [String: Int] = [:],
|
||||||
highlights: [RDEPUBHighlight] = [],
|
highlights: [RDEPUBHighlight] = [],
|
||||||
@@ -414,7 +461,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
currentHighlights = highlights
|
currentHighlights = highlights
|
||||||
currentSearchState = searchState
|
currentSearchState = searchState
|
||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
self.contentInsets = contentInsets ?? Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
@@ -491,7 +538,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
func configureLoading(
|
func configureLoading(
|
||||||
pageNumber: Int,
|
pageNumber: Int,
|
||||||
totalPages: Int,
|
totalPages: Int,
|
||||||
configuration: RDEPUBReaderConfiguration
|
configuration: RDEPUBReaderConfiguration,
|
||||||
|
contentInsets: UIEdgeInsets? = nil
|
||||||
) {
|
) {
|
||||||
currentPage = nil
|
currentPage = nil
|
||||||
currentChapterCFIMap = nil
|
currentChapterCFIMap = nil
|
||||||
@@ -502,7 +550,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
currentHighlights = []
|
currentHighlights = []
|
||||||
currentSearchState = nil
|
currentSearchState = nil
|
||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
contentInsets = Self.safeContentInsets(
|
self.contentInsets = contentInsets ?? Self.safeContentInsets(
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||||||
)
|
)
|
||||||
@@ -853,50 +901,84 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let highlight = highlight(at: point),
|
let highlights = highlights(at: point)
|
||||||
|
guard let highlight = highlights.first,
|
||||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
||||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))")
|
||||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
showHighlightMenu(for: highlights, sourceRect: sourceRect)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) {
|
||||||
|
// A short tap opens the compact system menu. Long-press selection is
|
||||||
|
// intentionally handled by the separate recognizer, which the tap
|
||||||
|
// recognizer already waits on, so text inside a marking remains
|
||||||
|
// selectable.
|
||||||
|
let underline = highlights.first { !$0.isAnnotationOnly }
|
||||||
|
let annotation = highlights.first { $0.isAnnotationOnly }
|
||||||
|
?? highlights.first { $0.hasNote }
|
||||||
|
menuHighlight = annotation ?? underline
|
||||||
|
menuUnderlineHighlight = underline
|
||||||
|
menuAnnotationHighlight = annotation
|
||||||
|
guard menuHighlight != nil else { return }
|
||||||
|
becomeFirstResponder()
|
||||||
|
let menuController = UIMenuController.shared
|
||||||
|
var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))]
|
||||||
|
if underline != nil {
|
||||||
|
items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:))))
|
||||||
|
} else if annotation != nil {
|
||||||
|
items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))))
|
||||||
|
}
|
||||||
|
if let annotation, annotation.hasNote {
|
||||||
|
items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:))))
|
||||||
|
} else if let underline, !underline.hasNote {
|
||||||
|
items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:))))
|
||||||
|
}
|
||||||
|
menuController.menuItems = items
|
||||||
|
menuController.setTargetRect(sourceRect, in: self)
|
||||||
|
menuController.setMenuVisible(true, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performHighlightMenuAction(
|
||||||
|
_ action: RDEPUBExistingHighlightMenuAction,
|
||||||
|
highlight: RDEPUBHighlight
|
||||||
|
) {
|
||||||
|
menuHighlight = nil
|
||||||
|
menuUnderlineHighlight = nil
|
||||||
|
menuAnnotationHighlight = nil
|
||||||
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
|
delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showSelectionMenuIfNeeded() {
|
private func showSelectionMenuIfNeeded() {
|
||||||
|
menuHighlight = nil
|
||||||
|
menuUnderlineHighlight = nil
|
||||||
|
menuAnnotationHighlight = nil
|
||||||
guard currentSelection != nil,
|
guard currentSelection != nil,
|
||||||
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||||||
!targetRect.isEmpty else {
|
!targetRect.isEmpty else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
||||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
becomeFirstResponder()
|
||||||
becomeFirstResponder()
|
let menuController = UIMenuController.shared
|
||||||
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
menuController.menuItems = [
|
||||||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
UIMenuItem(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
DispatchQueue.main.async { [weak self, weak interaction] in
|
UIMenuItem(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
guard let self, self.currentSelection != nil else { return }
|
UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
interaction?.presentEditMenu(with: config)
|
]
|
||||||
}
|
menuController.setTargetRect(resolvedTargetRect, in: self)
|
||||||
} else {
|
menuController.setMenuVisible(true, animated: true)
|
||||||
becomeFirstResponder()
|
|
||||||
let menuController = UIMenuController.shared
|
|
||||||
menuController.menuItems = [
|
|
||||||
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
|
|
||||||
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
|
|
||||||
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
|
|
||||||
]
|
|
||||||
menuController.setTargetRect(resolvedTargetRect, in: self)
|
|
||||||
menuController.setMenuVisible(true, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hideSelectionMenu() {
|
private func hideSelectionMenu() {
|
||||||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
menuHighlight = nil
|
||||||
interaction.dismissMenu()
|
menuUnderlineHighlight = nil
|
||||||
} else {
|
menuAnnotationHighlight = nil
|
||||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSelectionLoupe(for point: CGPoint) {
|
private func updateSelectionLoupe(for point: CGPoint) {
|
||||||
@@ -935,7 +1017,11 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||||
guard let page = currentPage else { return nil }
|
highlights(at: point).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func highlights(at point: CGPoint) -> [RDEPUBHighlight] {
|
||||||
|
guard let page = currentPage else { return [] }
|
||||||
let matches = currentHighlights.filter { highlight in
|
let matches = currentHighlights.filter { highlight in
|
||||||
guard highlight.location.href == page.href,
|
guard highlight.location.href == page.href,
|
||||||
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||||
@@ -950,11 +1036,13 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDEpubRe
|
|||||||
return matches.sorted { lhs, rhs in
|
return matches.sorted { lhs, rhs in
|
||||||
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
|
||||||
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
|
||||||
if lhs.hasNote != rhs.hasNote {
|
if lhsRange != rhsRange {
|
||||||
return lhs.hasNote && !rhs.hasNote
|
// In an overlap, the narrower marking is the one the reader
|
||||||
|
// most likely meant to touch (e.g. underline A inside note B).
|
||||||
|
return lhsRange < rhsRange
|
||||||
}
|
}
|
||||||
return lhsRange < rhsRange
|
return lhs.createdAt > rhs.createdAt
|
||||||
}.first
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
|
||||||
@@ -1131,9 +1219,9 @@ extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
|||||||
suggestedActions: [UIMenuElement]
|
suggestedActions: [UIMenuElement]
|
||||||
) -> UIMenu? {
|
) -> UIMenu? {
|
||||||
UIMenu(children: [
|
UIMenu(children: [
|
||||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ protocol RDEpubReaderPageResourceReleasing {
|
|||||||
func releaseResources()
|
func releaseResources()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 页面进入或离开当前阅读视口时的生命周期通知。
|
||||||
|
/// 持有音视频等持续运行资源的页面应在离开时暂停,在重新进入时恢复。
|
||||||
|
protocol RDEpubReaderPageVisibilityHandling: AnyObject {
|
||||||
|
func readerPageVisibilityDidChange(isVisible: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
final class RDEpubReaderPreloadController {
|
final class RDEpubReaderPreloadController {
|
||||||
|
|
||||||
var radius: Int = 2
|
var radius: Int = 2
|
||||||
@@ -121,6 +127,22 @@ final class RDEpubReaderPreloadController {
|
|||||||
return preloaded
|
return preloaded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updatePageVisibility(visibleViewIdentifiers: Set<ObjectIdentifier>) {
|
||||||
|
let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current }
|
||||||
|
var notifiedViews = Set<ObjectIdentifier>()
|
||||||
|
|
||||||
|
// Cache entries are never the authority for activation: UIPageViewController
|
||||||
|
// can create two views for the same page number. Keep every non-winning
|
||||||
|
// instance suspended; the reader activates only its actually attached view.
|
||||||
|
for view in cachedViews.values {
|
||||||
|
let identifier = ObjectIdentifier(view)
|
||||||
|
guard !visibleViewIdentifiers.contains(identifier),
|
||||||
|
notifiedViews.insert(identifier).inserted else { continue }
|
||||||
|
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func prime(
|
func prime(
|
||||||
around pageNum: Int,
|
around pageNum: Int,
|
||||||
preferredForward: Bool? = nil,
|
preferredForward: Bool? = nil,
|
||||||
@@ -130,7 +152,7 @@ final class RDEpubReaderPreloadController {
|
|||||||
) {
|
) {
|
||||||
refreshCacheSignatureIfNeeded(environment)
|
refreshCacheSignatureIfNeeded(environment)
|
||||||
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment)
|
||||||
let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment))
|
let keepSet = Set(targets).union(spreadPageNumbers(startingAt: pageNum, environment: environment))
|
||||||
trimCachedPageViews(keeping: keepSet)
|
trimCachedPageViews(keeping: keepSet)
|
||||||
guard !targets.isEmpty else { return }
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
@@ -164,12 +186,21 @@ final class RDEpubReaderPreloadController {
|
|||||||
"cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))"
|
"cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))"
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
contentView = contentViewProvider(targetPage, nil) ?? UIView()
|
guard let providedView = contentViewProvider(targetPage, nil) else {
|
||||||
|
// 页面提供者明确禁止预加载时不能塞入空白占位缓存,
|
||||||
|
// 否则真实展示会直接命中白页而不再请求实际内容。
|
||||||
|
preloadedPageViews.removeValue(forKey: targetPage)
|
||||||
|
pageCurlCachedViews.removeValue(forKey: targetPage)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
contentView = providedView
|
||||||
RDEpubReaderTapDebug.log(
|
RDEpubReaderTapDebug.log(
|
||||||
"PreloadController.prime",
|
"PreloadController.prime",
|
||||||
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
"cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
|
||||||
if shouldCacheContentView {
|
if shouldCacheContentView {
|
||||||
preloadedPageViews[targetPage] = contentView
|
preloadedPageViews[targetPage] = contentView
|
||||||
@@ -285,10 +316,6 @@ final class RDEpubReaderPreloadController {
|
|||||||
).left
|
).left
|
||||||
}
|
}
|
||||||
|
|
||||||
private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set<Int> {
|
|
||||||
spreadPageNumbers(startingAt: anchorPage, environment: environment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
private func detachedReusablePageView(for pageNum: Int) -> UIView? {
|
||||||
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) {
|
||||||
preloaded.removeFromSuperview()
|
preloaded.removeFromSuperview()
|
||||||
@@ -327,6 +354,8 @@ final class RDEpubReaderPreloadController {
|
|||||||
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
// 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方
|
||||||
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
// 释放后经 deinit 自然回收(弱代理已保证无保留环)。
|
||||||
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView
|
||||||
|
(view as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
if !isHeldOutsidePreloadHost {
|
if !isHeldOutsidePreloadHost {
|
||||||
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
(view as? RDEpubReaderPageResourceReleasing)?.releaseResources()
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ extension RDEpubReaderView: UICollectionViewDataSource, RDEpubReaderFlowLayoutDe
|
|||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDEpubReaderContentCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: identifer, for: IndexPath(item: indexPath.row, section: 0)) as! RDEpubReaderContentCell
|
||||||
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
|
let preloadedView = preloadController.takePreloadedView(for: indexPath.row)
|
||||||
let reusableView = cell.containerView ?? preloadedView
|
let reusableView = cell.containerView ?? preloadedView
|
||||||
cell.containerView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
let contentView = contentViewForPage(indexPath.row, reusableView: reusableView)
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
cell.containerView = contentView
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.updatePageContentVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
if pageDirection == .rightToLeft && currentDisplayType != .verticalScroll {
|
||||||
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
cell.contentView.transform = CGAffineTransform(scaleX: -1, y: 1)
|
||||||
|
|||||||
@@ -96,7 +96,28 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
|
||||||
let resolvedPage = (pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController)?.pageNum ?? -1
|
let visibleControllers = pageViewController.viewControllers ?? []
|
||||||
|
let visibleControllerIdentifiers = Set(visibleControllers.map(ObjectIdentifier.init))
|
||||||
|
let transitionControllers = previousViewControllers + [
|
||||||
|
willPreviousTransitionToViewController,
|
||||||
|
willNextTransitionToViewController,
|
||||||
|
willTransitionToViewController
|
||||||
|
].compactMap { $0 }
|
||||||
|
var hiddenContentViews = Set<ObjectIdentifier>()
|
||||||
|
|
||||||
|
// UIPageViewController can ask its data source for a second instance of
|
||||||
|
// the same page and leave the first one outside our cache dictionaries.
|
||||||
|
// Explicitly stop every controller that did not win the transition so
|
||||||
|
// an orphaned Hype WebView cannot keep narrating in the background.
|
||||||
|
for controller in transitionControllers where !visibleControllerIdentifiers.contains(ObjectIdentifier(controller)) {
|
||||||
|
guard let childController = controller as? RDEpubReaderPageChildViewController,
|
||||||
|
let contentView = childController.contentView else { continue }
|
||||||
|
let identifier = ObjectIdentifier(contentView)
|
||||||
|
guard hiddenContentViews.insert(identifier).inserted else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
|
if completed, let firstVC = pageViewController.viewControllers?.first as? RDEpubReaderPageChildViewController {
|
||||||
let pn = firstVC.pageNum
|
let pn = firstVC.pageNum
|
||||||
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
|
if pn != RDEpubReaderView.blankPageNum && pn != RDEpubReaderView.blankEndPageNum {
|
||||||
@@ -105,6 +126,14 @@ extension RDEpubReaderView: UIPageViewControllerDataSource, UIPageViewController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On cancellation currentPage does not change, so its didSet cannot
|
||||||
|
// restore visibility. Reassert the actual controllers in both paths.
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in visibleControllers {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: isReaderPageVisible(childController.pageNum))
|
||||||
|
}
|
||||||
|
|
||||||
predictedPageDirection = nil
|
predictedPageDirection = nil
|
||||||
finishPageCurlTransition()
|
finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ enum RDEpubReaderTapDebug {
|
|||||||
|
|
||||||
public class RDEpubReaderView: UIView {
|
public class RDEpubReaderView: UIView {
|
||||||
|
|
||||||
|
/// The readable-content gutter between two landscape pages. Each page
|
||||||
|
/// contributes half of this value from its inner content inset.
|
||||||
|
public static let landscapeDualPageGap: CGFloat = 10
|
||||||
|
|
||||||
enum TapEvent {
|
enum TapEvent {
|
||||||
|
|
||||||
case none
|
case none
|
||||||
@@ -93,6 +97,9 @@ public class RDEpubReaderView: UIView {
|
|||||||
|
|
||||||
public var currentPage: Int = -1 {
|
public var currentPage: Int = -1 {
|
||||||
didSet {
|
didSet {
|
||||||
|
if currentPage != oldValue {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
|
if let delegate = delegate, currentPage != oldValue, delegate.responds(to: #selector(RDEpubReaderDelegate.pageNum(readerView:pageNum:))) {
|
||||||
delegate.pageNum(readerView: self, pageNum: currentPage)
|
delegate.pageNum(readerView: self, pageNum: currentPage)
|
||||||
}
|
}
|
||||||
@@ -233,6 +240,13 @@ public class RDEpubReaderView: UIView {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a page occupies the leading (left in LTR) side of a landscape
|
||||||
|
/// spread. Cover pages remain full-screen and are treated as leading.
|
||||||
|
public func isLeadingPage(_ pageNum: Int) -> Bool {
|
||||||
|
guard pagesPerScreen > 1, !isFullScreenPage(pageNum) else { return true }
|
||||||
|
return dualPagePair(for: pageNum).left == pageNum
|
||||||
|
}
|
||||||
|
|
||||||
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
private func dualPagePair(for pageNum: Int) -> (left: Int, right: Int?) {
|
||||||
let totalPages = numberOfPages()
|
let totalPages = numberOfPages()
|
||||||
return spreadResolver.dualPagePair(
|
return spreadResolver.dualPagePair(
|
||||||
@@ -380,6 +394,13 @@ public class RDEpubReaderView: UIView {
|
|||||||
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
layout.isLandscapeDualPage = landscapeDualPageEnabled && isNowLandscape
|
||||||
layout.coverPageIndex = coverPageIndex
|
layout.coverPageIndex = coverPageIndex
|
||||||
|
|
||||||
|
// Cached page views are configured with orientation-dependent content
|
||||||
|
// insets and Web render requests. Reusing a portrait-preloaded page in
|
||||||
|
// the rebuilt landscape page controller leaves the portrait safe-area
|
||||||
|
// inset on all four edges (and keeps fixed-layout Web content under the
|
||||||
|
// landscape sensor area). Drop them before requesting the new spread.
|
||||||
|
invalidatePageCaches()
|
||||||
|
|
||||||
switch currentDisplayType {
|
switch currentDisplayType {
|
||||||
case .pageCurl:
|
case .pageCurl:
|
||||||
|
|
||||||
@@ -503,11 +524,17 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pageViewForDisplay(pageNum: Int) -> UIView {
|
func pageViewForDisplay(pageNum: Int) -> UIView {
|
||||||
preloadController.pageViewForDisplay(
|
let pageView = preloadController.pageViewForDisplay(
|
||||||
pageNum: pageNum,
|
pageNum: pageNum,
|
||||||
environment: preloadEnvironment,
|
environment: preloadEnvironment,
|
||||||
contentViewProvider: pageContentViewForPreload(pageNum:reusableView:)
|
contentViewProvider: contentViewForPage(_:reusableView:)
|
||||||
)
|
)
|
||||||
|
// A data-source request is only a candidate. UIPageViewController may
|
||||||
|
// ask for duplicate instances of the current page; activation happens
|
||||||
|
// after the winning controller is actually installed.
|
||||||
|
(pageView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
return pageView
|
||||||
}
|
}
|
||||||
|
|
||||||
func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
func primePageCache(around pageNum: Int, preferredForward: Bool? = nil) {
|
||||||
@@ -521,7 +548,83 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
|
private func pageContentViewForPreload(pageNum: Int, reusableView: UIView?) -> UIView? {
|
||||||
contentViewForPage(pageNum, reusableView: reusableView)
|
let allowsPreloading = resolvedPageProvider?
|
||||||
|
.readerView?(self, shouldPreloadPageAt: pageNum) ?? true
|
||||||
|
guard allowsPreloading else { return nil }
|
||||||
|
return contentViewForPage(pageNum, reusableView: reusableView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReaderPageVisible(_ pageNum: Int) -> Bool {
|
||||||
|
guard currentPage >= 0 else { return pageNum == 0 }
|
||||||
|
if landscapeDualPageEnabled && isLandscape {
|
||||||
|
let pair = dualPagePair(for: currentPage)
|
||||||
|
return pageNum == pair.left || pageNum == pair.right
|
||||||
|
}
|
||||||
|
return pageNum == currentPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePageContentVisibility() {
|
||||||
|
var visiblePageNumbers = Set<Int>()
|
||||||
|
if currentPage >= 0 {
|
||||||
|
visiblePageNumbers.insert(currentPage)
|
||||||
|
if landscapeDualPageEnabled && isLandscape,
|
||||||
|
let rightPage = dualPagePair(for: currentPage).right {
|
||||||
|
visiblePageNumbers.insert(rightPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageControllers: [RDEpubReaderPageChildViewController]
|
||||||
|
let visibleCellContents: [(pageNumber: Int, contentView: UIView)]
|
||||||
|
if currentDisplayType == .pageCurl {
|
||||||
|
pageControllers = (pageViewController.viewControllers ?? []).compactMap {
|
||||||
|
$0 as? RDEpubReaderPageChildViewController
|
||||||
|
}
|
||||||
|
visibleCellContents = []
|
||||||
|
} else {
|
||||||
|
pageControllers = []
|
||||||
|
visibleCellContents = collectionView.indexPathsForVisibleItems.compactMap { indexPath in
|
||||||
|
guard let cell = collectionView.cellForItem(at: indexPath) as? RDEpubReaderContentCell,
|
||||||
|
let contentView = cell.containerView else { return nil }
|
||||||
|
return (indexPath.item, contentView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pageController in pageControllers where !visiblePageNumbers.contains(pageController.pageNum) {
|
||||||
|
guard let contentView = pageController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in visibleCellContents where !visiblePageNumbers.contains(entry.pageNumber) {
|
||||||
|
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suspend every cached non-winner before the attached winner below is
|
||||||
|
// activated. Object identity matters because a page number can have
|
||||||
|
// multiple UIPageViewController candidates.
|
||||||
|
let visibleViewIdentifiers = Set(
|
||||||
|
pageControllers.compactMap { pageController -> ObjectIdentifier? in
|
||||||
|
guard visiblePageNumbers.contains(pageController.pageNum),
|
||||||
|
let contentView = pageController.contentView else { return nil }
|
||||||
|
return ObjectIdentifier(contentView)
|
||||||
|
} + visibleCellContents.compactMap { entry -> ObjectIdentifier? in
|
||||||
|
guard visiblePageNumbers.contains(entry.pageNumber) else { return nil }
|
||||||
|
return ObjectIdentifier(entry.contentView)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
preloadController.updatePageVisibility(visibleViewIdentifiers: visibleViewIdentifiers)
|
||||||
|
|
||||||
|
for pageController in pageControllers where visiblePageNumbers.contains(pageController.pageNum) {
|
||||||
|
guard let contentView = pageController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in visibleCellContents where visiblePageNumbers.contains(entry.pageNumber) {
|
||||||
|
(entry.contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func didMoveToSuperview() {
|
public override func didMoveToSuperview() {
|
||||||
@@ -562,6 +665,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func detachPageViewControllerIfNeeded() {
|
private func detachPageViewControllerIfNeeded() {
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
if pageViewController.parent != nil {
|
if pageViewController.parent != nil {
|
||||||
pageViewController.willMove(toParent: nil)
|
pageViewController.willMove(toParent: nil)
|
||||||
}
|
}
|
||||||
@@ -815,6 +923,17 @@ public class RDEpubReaderView: UIView {
|
|||||||
}
|
}
|
||||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||||
attachPageViewControllerIfNeeded()
|
attachPageViewControllerIfNeeded()
|
||||||
|
|
||||||
|
// Programmatic setViewControllers transitions do not call the
|
||||||
|
// UIPageViewControllerDelegate didFinishAnimating callback. Stop
|
||||||
|
// the outgoing controllers explicitly before replacing them, even
|
||||||
|
// when a duplicate page instance has fallen out of our cache map.
|
||||||
|
for case let childController as RDEpubReaderPageChildViewController in pageViewController.viewControllers ?? [] {
|
||||||
|
guard let contentView = childController.contentView else { continue }
|
||||||
|
(contentView as? RDEpubReaderPageVisibilityHandling)?
|
||||||
|
.readerPageVisibilityDidChange(isVisible: false)
|
||||||
|
}
|
||||||
|
|
||||||
if isDualPage {
|
if isDualPage {
|
||||||
let pair = dualPagePair(for: safePageNum)
|
let pair = dualPagePair(for: safePageNum)
|
||||||
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
let leftContent = pageViewForDisplay(pageNum: pair.left)
|
||||||
@@ -836,7 +955,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
self.finishPageCurlTransition()
|
self.finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let pageChanged = currentPage != pair.left
|
||||||
currentPage = pair.left
|
currentPage = pair.left
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
primePageCache(around: pair.left, preferredForward: predictedPageDirection)
|
||||||
} else {
|
} else {
|
||||||
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
let contentView = pageViewForDisplay(pageNum: safePageNum)
|
||||||
@@ -846,7 +969,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.finishPageCurlTransition()
|
self.finishPageCurlTransition()
|
||||||
}
|
}
|
||||||
|
let pageChanged = currentPage != safePageNum
|
||||||
currentPage = safePageNum
|
currentPage = safePageNum
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -854,7 +981,11 @@ public class RDEpubReaderView: UIView {
|
|||||||
collectionView.layoutIfNeeded()
|
collectionView.layoutIfNeeded()
|
||||||
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
predictedPageDirection = currentPage >= 0 ? safePageNum >= currentPage : nil
|
||||||
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
collectionView.setContentOffset(layout.currentContentOffset(count: safePageNum), animated: animated)
|
||||||
|
let pageChanged = currentPage != safePageNum
|
||||||
currentPage = safePageNum
|
currentPage = safePageNum
|
||||||
|
if !pageChanged {
|
||||||
|
updatePageContentVisibility()
|
||||||
|
}
|
||||||
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
primePageCache(around: safePageNum, preferredForward: predictedPageDirection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import UIKit
|
|||||||
|
|
||||||
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
@objc optional func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String?
|
||||||
|
|
||||||
|
/// 指定页面是否允许进入阅读器预加载。默认允许。
|
||||||
|
/// 包含不可复制宿主 View 的页面应返回 false,避免预加载抢占可见页面内容。
|
||||||
|
@objc optional func readerView(_ readerView: RDEpubReaderView, shouldPreloadPageAt index: Int) -> Bool
|
||||||
|
|
||||||
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
@objc optional func readerViewTopChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
|
||||||
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
@objc optional func readerViewBottomChrome(_ readerView: RDEpubReaderView) -> UIView?
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFI: Codable, Equatable, Hashable {
|
|
||||||
public var packagePath: RDEPUBCFIPath
|
|
||||||
public var contentPath: RDEPUBCFIPath
|
|
||||||
public var characterOffset: Int?
|
|
||||||
public var sideBias: RDEPUBCFISideBias?
|
|
||||||
public var textAssertion: RDEPUBCFITextAssertion?
|
|
||||||
|
|
||||||
/// Always computed from components — no stale cached value risk.
|
|
||||||
public var rawValue: String {
|
|
||||||
RDEPUBCFISerializer.serialize(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize from components. The `rawValue` parameter is ignored — rawValue is always
|
|
||||||
/// computed from the components. To parse a CFI string, use `RDEPUBCFIParser.parse(_:)`.
|
|
||||||
public init(
|
|
||||||
rawValue: String = "",
|
|
||||||
packagePath: RDEPUBCFIPath = RDEPUBCFIPath(),
|
|
||||||
contentPath: RDEPUBCFIPath = RDEPUBCFIPath(),
|
|
||||||
characterOffset: Int? = nil,
|
|
||||||
sideBias: RDEPUBCFISideBias? = nil,
|
|
||||||
textAssertion: RDEPUBCFITextAssertion? = nil
|
|
||||||
) {
|
|
||||||
self.packagePath = packagePath
|
|
||||||
self.contentPath = contentPath
|
|
||||||
self.characterOffset = characterOffset
|
|
||||||
self.sideBias = sideBias
|
|
||||||
self.textAssertion = textAssertion
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let rawValue = try container.decode(String.self)
|
|
||||||
let parsed = try RDEPUBCFIParser.parse(rawValue)
|
|
||||||
self.packagePath = parsed.packagePath
|
|
||||||
self.contentPath = parsed.contentPath
|
|
||||||
self.characterOffset = parsed.characterOffset
|
|
||||||
self.sideBias = parsed.sideBias
|
|
||||||
self.textAssertion = parsed.textAssertion
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBCFISideBias: String, Codable, Equatable, Hashable {
|
|
||||||
case before = "b"
|
|
||||||
case after = "a"
|
|
||||||
}
|
|
||||||
-29
@@ -1,29 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFICompatibility {
|
|
||||||
public static func parseLossy(_ rawValue: String?) -> RDEPUBCFI? {
|
|
||||||
try? RDEPUBCFIParser.parse(rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func parseRangeLossy(_ rawValue: String?) -> RDEPUBCFIRange? {
|
|
||||||
if let parsed = try? RDEPUBCFIParser.parseRange(rawValue) {
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rawValue,
|
|
||||||
!rawValue.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let separators = ["..", "-"]
|
|
||||||
let separator = separators.first(where: { rawValue.contains($0) })
|
|
||||||
guard let separator else { return nil }
|
|
||||||
let parts = rawValue.components(separatedBy: separator)
|
|
||||||
guard parts.count == 2,
|
|
||||||
let start = parseLossy(parts[0]),
|
|
||||||
let end = parseLossy(parts[1]) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return RDEPUBCFIRange(parent: nil, start: start, end: end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-575
@@ -1,575 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFIDOMPathBuilder {
|
|
||||||
public static func fragmentPaths(in html: String) -> [String: RDEPUBCFIPath] {
|
|
||||||
// Pre-strip HTML comments and CDATA to avoid false tag matches inside them
|
|
||||||
let cleaned = stripCommentsAndCDATA(html)
|
|
||||||
|
|
||||||
guard let regex = try? NSRegularExpression(
|
|
||||||
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
) else {
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
let nsHTML = cleaned as NSString
|
|
||||||
var stack: [RDEPUBCFIStep] = []
|
|
||||||
var childCountsByDepth: [Int: Int] = [:]
|
|
||||||
var paths: [String: RDEPUBCFIPath] = [:]
|
|
||||||
|
|
||||||
for match in regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)) {
|
|
||||||
let rawTag = nsHTML.substring(with: match.range)
|
|
||||||
guard match.numberOfRanges > 2 else { continue }
|
|
||||||
let tagName = nsHTML.substring(with: match.range(at: 1)).lowercased()
|
|
||||||
guard !isIgnorableTag(tagName) else { continue }
|
|
||||||
|
|
||||||
if rawTag.hasPrefix("</") {
|
|
||||||
if tagName == "html" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !stack.isEmpty {
|
|
||||||
stack.removeLast()
|
|
||||||
childCountsByDepth[stack.count + 1] = nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if tagName == "html" {
|
|
||||||
childCountsByDepth[0] = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let attributes = nsHTML.substring(with: match.range(at: 2))
|
|
||||||
let depth = stack.count
|
|
||||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
|
||||||
childCountsByDepth[depth] = nextChildIndex
|
|
||||||
|
|
||||||
let fragmentID = idAttribute(in: attributes)
|
|
||||||
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
|
|
||||||
let currentPath = RDEPUBCFIPath(steps: stack + [step])
|
|
||||||
if let fragmentID, paths[fragmentID] == nil {
|
|
||||||
paths[fragmentID] = currentPath
|
|
||||||
}
|
|
||||||
|
|
||||||
if !rawTag.hasSuffix("/>"), !isVoidTag(tagName) {
|
|
||||||
stack.append(step)
|
|
||||||
childCountsByDepth[stack.count] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
static func idAttribute(in attributes: String) -> String? {
|
|
||||||
guard let regex = try? NSRegularExpression(
|
|
||||||
pattern: #"(?:^|\s)(?:id|xml:id)\s*=\s*(['"])(.*?)\1"#,
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let nsAttributes = attributes as NSString
|
|
||||||
guard let match = regex.firstMatch(
|
|
||||||
in: attributes,
|
|
||||||
range: NSRange(location: 0, length: nsAttributes.length)
|
|
||||||
),
|
|
||||||
match.numberOfRanges > 2 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nsAttributes.substring(with: match.range(at: 2)).rd_nilIfEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isIgnorableTag(_ tagName: String) -> Bool {
|
|
||||||
tagName == "!doctype" || tagName.hasPrefix("?")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strip HTML comments `<!--...-->` and CDATA sections `<![CDATA[...]]>` from HTML
|
|
||||||
/// to prevent the tag regex from matching false tags inside them.
|
|
||||||
static func stripCommentsAndCDATA(_ html: String) -> String {
|
|
||||||
var result = html
|
|
||||||
// Strip comments: <!--...-->
|
|
||||||
if let commentRegex = try? NSRegularExpression(pattern: #"<!--[\s\S]*?-->"#, options: []) {
|
|
||||||
let nsResult = result as NSString
|
|
||||||
let matches = commentRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
|
|
||||||
for match in matches.reversed() {
|
|
||||||
result = nsResult.replacingCharacters(in: match.range, with: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Strip CDATA: <![CDATA[...]]>
|
|
||||||
if let cdataRegex = try? NSRegularExpression(pattern: #"<!\[CDATA\[[\s\S]*?\]\]>"#, options: []) {
|
|
||||||
let nsResult = result as NSString
|
|
||||||
let matches = cdataRegex.matches(in: result, range: NSRange(location: 0, length: nsResult.length))
|
|
||||||
for match in matches.reversed() {
|
|
||||||
result = nsResult.replacingCharacters(in: match.range, with: "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isVoidTag(_ tagName: String) -> Bool {
|
|
||||||
[
|
|
||||||
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
||||||
"link", "meta", "param", "source", "track", "wbr"
|
|
||||||
].contains(tagName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RDEPUBCFITextNodeMapBuilder {
|
|
||||||
static func normalizedText(from source: String) -> String {
|
|
||||||
RDEPUBNormalizedTextIndex.normalize(source)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeMap(
|
|
||||||
href: String,
|
|
||||||
rawHTML: String,
|
|
||||||
chapterText: String,
|
|
||||||
fragmentOffsets: [String: Int]
|
|
||||||
) -> RDEPUBCFIMap {
|
|
||||||
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
|
|
||||||
let fragmentPaths = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
|
|
||||||
let markers = makeMarkers(
|
|
||||||
href: href,
|
|
||||||
rawHTML: rawHTML,
|
|
||||||
chapterText: chapterText,
|
|
||||||
fragmentOffsets: fragmentOffsets
|
|
||||||
)
|
|
||||||
let pathRanges = makePathRanges(from: markers)
|
|
||||||
return RDEPUBCFIMap(
|
|
||||||
href: href,
|
|
||||||
markers: markers,
|
|
||||||
pathRanges: pathRanges,
|
|
||||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
|
||||||
domFingerprint: rawHTML.rd_sha256Hex,
|
|
||||||
normalizedTextChecksum: normalizedChapter.normalizedText.rd_sha256Hex,
|
|
||||||
tokenIndex: makeTokenAnchors(
|
|
||||||
normalizedChapter: normalizedChapter,
|
|
||||||
markers: markers,
|
|
||||||
pathRanges: pathRanges
|
|
||||||
),
|
|
||||||
fragmentPathMap: fragmentPaths
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeMarkers(
|
|
||||||
href: String,
|
|
||||||
rawHTML: String,
|
|
||||||
chapterText: String,
|
|
||||||
fragmentOffsets: [String: Int]
|
|
||||||
) -> [RDEPUBCFIMarker] {
|
|
||||||
let normalizedChapter = RDEPUBNormalizedTextIndex(source: chapterText)
|
|
||||||
guard !normalizedChapter.normalizedText.isEmpty else {
|
|
||||||
return fragmentMarkers(fragmentOffsets: fragmentOffsets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use comment/CDATA-stripped HTML for both path building and tag matching
|
|
||||||
let cleanedHTML = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(rawHTML)
|
|
||||||
|
|
||||||
let fragments = RDEPUBCFIDOMPathBuilder.fragmentPaths(in: rawHTML)
|
|
||||||
var markers: [RDEPUBCFIMarker] = fragmentMarkers(fragmentOffsets: fragmentOffsets, paths: fragments)
|
|
||||||
var searchLocation = 0
|
|
||||||
var stack: [RDEPUBCFIStep] = []
|
|
||||||
var childCountsByDepth: [Int: Int] = [:]
|
|
||||||
var lastTagEnd = cleanedHTML.startIndex
|
|
||||||
|
|
||||||
for tagMatch in RDEPUBHTMLTagMatch.matches(in: cleanedHTML) {
|
|
||||||
let textChunk = String(cleanedHTML[lastTagEnd..<tagMatch.range.lowerBound])
|
|
||||||
if let marker = textMarker(
|
|
||||||
for: textChunk,
|
|
||||||
stack: stack,
|
|
||||||
childCountsByDepth: &childCountsByDepth,
|
|
||||||
normalizedChapter: normalizedChapter,
|
|
||||||
searchLocation: &searchLocation
|
|
||||||
) {
|
|
||||||
markers.append(marker)
|
|
||||||
}
|
|
||||||
|
|
||||||
processTag(
|
|
||||||
tagMatch,
|
|
||||||
stack: &stack,
|
|
||||||
childCountsByDepth: &childCountsByDepth
|
|
||||||
)
|
|
||||||
lastTagEnd = tagMatch.range.upperBound
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastTagEnd < cleanedHTML.endIndex {
|
|
||||||
let trailingText = String(cleanedHTML[lastTagEnd..<cleanedHTML.endIndex])
|
|
||||||
if let marker = textMarker(
|
|
||||||
for: trailingText,
|
|
||||||
stack: stack,
|
|
||||||
childCountsByDepth: &childCountsByDepth,
|
|
||||||
normalizedChapter: normalizedChapter,
|
|
||||||
searchLocation: &searchLocation
|
|
||||||
) {
|
|
||||||
markers.append(marker)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return markers
|
|
||||||
.filter { $0.chapterOffset != nil }
|
|
||||||
.sorted { lhs, rhs in
|
|
||||||
let leftOffset = lhs.chapterOffset ?? Int.max
|
|
||||||
let rightOffset = rhs.chapterOffset ?? Int.max
|
|
||||||
if leftOffset != rightOffset {
|
|
||||||
return leftOffset < rightOffset
|
|
||||||
}
|
|
||||||
return lhs.cfiPath.steps.count < rhs.cfiPath.steps.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func fragmentMarkers(
|
|
||||||
fragmentOffsets: [String: Int],
|
|
||||||
paths: [String: RDEPUBCFIPath] = [:]
|
|
||||||
) -> [RDEPUBCFIMarker] {
|
|
||||||
fragmentOffsets
|
|
||||||
.sorted { $0.value < $1.value }
|
|
||||||
.map { fragmentID, offset in
|
|
||||||
RDEPUBCFIMarker(
|
|
||||||
cfiPath: paths[fragmentID] ?? RDEPUBCFIPath(),
|
|
||||||
chapterOffset: offset,
|
|
||||||
fragmentID: fragmentID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func textMarker(
|
|
||||||
for rawText: String,
|
|
||||||
stack: [RDEPUBCFIStep],
|
|
||||||
childCountsByDepth: inout [Int: Int],
|
|
||||||
normalizedChapter: RDEPUBNormalizedTextIndex,
|
|
||||||
searchLocation: inout Int
|
|
||||||
) -> RDEPUBCFIMarker? {
|
|
||||||
let normalizedNodeText = RDEPUBNormalizedTextIndex.normalize(rawText)
|
|
||||||
guard !normalizedNodeText.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let depth = stack.count
|
|
||||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
|
||||||
childCountsByDepth[depth] = nextChildIndex
|
|
||||||
let textStep = RDEPUBCFIStep(index: nextChildIndex * 2 - 1)
|
|
||||||
let path = RDEPUBCFIPath(steps: stack + [textStep])
|
|
||||||
|
|
||||||
// Search using UTF-16 offsets to match normalizedToChapterOffsets indices
|
|
||||||
let nsNormalized = normalizedChapter.normalizedText as NSString
|
|
||||||
let searchRange = NSRange(location: searchLocation, length: nsNormalized.length - searchLocation)
|
|
||||||
guard let foundRange = nsNormalized.range(of: normalizedNodeText, options: [], range: searchRange).toRange(),
|
|
||||||
foundRange.lowerBound >= searchLocation else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
searchLocation = foundRange.upperBound
|
|
||||||
let normalizedOffset = foundRange.lowerBound
|
|
||||||
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: normalizedOffset) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBCFIMarker(
|
|
||||||
cfiPath: path,
|
|
||||||
chapterOffset: chapterOffset,
|
|
||||||
textNodeLength: normalizedNodeText.utf16.count,
|
|
||||||
textNodeChecksum: normalizedNodeText.fnv1a64,
|
|
||||||
normalizedTextPreview: previewText(for: normalizedNodeText),
|
|
||||||
domSiblingSignature: siblingSignature(stack: stack, childIndex: nextChildIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func processTag(
|
|
||||||
_ match: RDEPUBHTMLTagMatch,
|
|
||||||
stack: inout [RDEPUBCFIStep],
|
|
||||||
childCountsByDepth: inout [Int: Int]
|
|
||||||
) {
|
|
||||||
let tagName = match.name.lowercased()
|
|
||||||
guard !RDEPUBCFIDOMPathBuilder.isIgnorableTag(tagName) else { return }
|
|
||||||
|
|
||||||
if match.isClosing {
|
|
||||||
if tagName == "html" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !stack.isEmpty {
|
|
||||||
stack.removeLast()
|
|
||||||
childCountsByDepth[stack.count + 1] = nil
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if tagName == "html" {
|
|
||||||
childCountsByDepth[0] = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let depth = stack.count
|
|
||||||
let nextChildIndex = (childCountsByDepth[depth] ?? 0) + 1
|
|
||||||
childCountsByDepth[depth] = nextChildIndex
|
|
||||||
|
|
||||||
let fragmentID = RDEPUBCFIDOMPathBuilder.idAttribute(in: match.attributes)
|
|
||||||
let step = RDEPUBCFIStep(index: nextChildIndex * 2, idAssertion: fragmentID)
|
|
||||||
if !match.isSelfClosing && !RDEPUBCFIDOMPathBuilder.isVoidTag(tagName) {
|
|
||||||
stack.append(step)
|
|
||||||
childCountsByDepth[stack.count] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makePathRanges(from markers: [RDEPUBCFIMarker]) -> [RDEPUBCFIPathRange] {
|
|
||||||
markers.compactMap { marker in
|
|
||||||
guard let startOffset = marker.chapterOffset,
|
|
||||||
let textNodeLength = marker.textNodeLength,
|
|
||||||
textNodeLength > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return RDEPUBCFIPathRange(
|
|
||||||
cfiPath: marker.cfiPath,
|
|
||||||
startOffset: startOffset,
|
|
||||||
endOffset: startOffset + max(textNodeLength - 1, 0),
|
|
||||||
textNodeLength: textNodeLength
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeTokenAnchors(
|
|
||||||
normalizedChapter: RDEPUBNormalizedTextIndex,
|
|
||||||
markers: [RDEPUBCFIMarker],
|
|
||||||
pathRanges: [RDEPUBCFIPathRange]
|
|
||||||
) -> [RDEPUBCFITokenAnchor] {
|
|
||||||
guard !normalizedChapter.normalizedText.isEmpty else { return [] }
|
|
||||||
|
|
||||||
let sortedRanges = pathRanges.sorted { $0.startOffset < $1.startOffset }
|
|
||||||
var occurrenceByToken: [String: Int] = [:]
|
|
||||||
var anchors: [RDEPUBCFITokenAnchor] = []
|
|
||||||
let samples = normalizedChapter.tokenSamples(maxSamples: 64, stride: 96, window: 12)
|
|
||||||
|
|
||||||
for sample in samples {
|
|
||||||
guard let chapterOffset = normalizedChapter.chapterOffset(forNormalizedOffset: sample.offset),
|
|
||||||
let path = path(for: chapterOffset, ranges: sortedRanges, markers: markers) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let occurrence = (occurrenceByToken[sample.token] ?? 0) + 1
|
|
||||||
occurrenceByToken[sample.token] = occurrence
|
|
||||||
anchors.append(
|
|
||||||
RDEPUBCFITokenAnchor(
|
|
||||||
token: sample.token,
|
|
||||||
occurrence: occurrence,
|
|
||||||
chapterOffset: chapterOffset,
|
|
||||||
cfiPath: path
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return anchors
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func path(
|
|
||||||
for chapterOffset: Int,
|
|
||||||
ranges: [RDEPUBCFIPathRange],
|
|
||||||
markers: [RDEPUBCFIMarker]
|
|
||||||
) -> RDEPUBCFIPath? {
|
|
||||||
if let range = ranges.first(where: { chapterOffset >= $0.startOffset && chapterOffset <= $0.endOffset }) {
|
|
||||||
return range.cfiPath
|
|
||||||
}
|
|
||||||
return markers
|
|
||||||
.filter { $0.chapterOffset != nil }
|
|
||||||
.min(by: {
|
|
||||||
abs(($0.chapterOffset ?? 0) - chapterOffset) < abs(($1.chapterOffset ?? 0) - chapterOffset)
|
|
||||||
})?
|
|
||||||
.cfiPath
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewText(for normalizedText: String, limit: Int = 24) -> String {
|
|
||||||
String(normalizedText.prefix(limit))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func siblingSignature(stack: [RDEPUBCFIStep], childIndex: Int) -> String {
|
|
||||||
let parent = stack.map { String($0.index) }.joined(separator: "/")
|
|
||||||
return "\(parent)#\(childIndex)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RDEPUBHTMLTagMatch {
|
|
||||||
let range: Range<String.Index>
|
|
||||||
let name: String
|
|
||||||
let attributes: String
|
|
||||||
let isClosing: Bool
|
|
||||||
let isSelfClosing: Bool
|
|
||||||
|
|
||||||
static func matches(in html: String) -> [RDEPUBHTMLTagMatch] {
|
|
||||||
let cleaned = RDEPUBCFIDOMPathBuilder.stripCommentsAndCDATA(html)
|
|
||||||
|
|
||||||
guard let regex = try? NSRegularExpression(
|
|
||||||
pattern: #"</?\s*([A-Za-z][A-Za-z0-9:_-]*)([^>]*)>"#,
|
|
||||||
options: [.caseInsensitive]
|
|
||||||
) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let nsHTML = cleaned as NSString
|
|
||||||
return regex.matches(in: cleaned, range: NSRange(location: 0, length: nsHTML.length)).compactMap { match in
|
|
||||||
guard match.numberOfRanges > 2,
|
|
||||||
let range = Range(match.range, in: cleaned),
|
|
||||||
let nameRange = Range(match.range(at: 1), in: cleaned),
|
|
||||||
let attributesRange = Range(match.range(at: 2), in: cleaned) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let rawTag = String(cleaned[range])
|
|
||||||
return RDEPUBHTMLTagMatch(
|
|
||||||
range: range,
|
|
||||||
name: String(cleaned[nameRange]),
|
|
||||||
attributes: String(cleaned[attributesRange]),
|
|
||||||
isClosing: rawTag.hasPrefix("</"),
|
|
||||||
isSelfClosing: rawTag.hasSuffix("/>")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RDEPUBNormalizedTextIndex {
|
|
||||||
let normalizedText: String
|
|
||||||
private let normalizedToChapterOffsets: [Int]
|
|
||||||
|
|
||||||
init(source: String) {
|
|
||||||
var text = ""
|
|
||||||
var offsets: [Int] = []
|
|
||||||
let nsSource = source as NSString
|
|
||||||
var lastWasWhitespace = false
|
|
||||||
|
|
||||||
for index in 0..<nsSource.length {
|
|
||||||
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
|
|
||||||
let normalizedScalar = Self.normalizeCharacter(scalar)
|
|
||||||
if normalizedScalar == " " {
|
|
||||||
if lastWasWhitespace {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastWasWhitespace = true
|
|
||||||
} else {
|
|
||||||
lastWasWhitespace = false
|
|
||||||
}
|
|
||||||
text.append(normalizedScalar)
|
|
||||||
offsets.append(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.normalizedText = text
|
|
||||||
self.normalizedToChapterOffsets = offsets
|
|
||||||
}
|
|
||||||
|
|
||||||
func chapterOffset(forNormalizedOffset offset: Int) -> Int? {
|
|
||||||
guard normalizedToChapterOffsets.indices.contains(offset) else { return nil }
|
|
||||||
return normalizedToChapterOffsets[offset]
|
|
||||||
}
|
|
||||||
|
|
||||||
func tokenSamples(maxSamples: Int, stride: Int, window: Int) -> [(token: String, offset: Int)] {
|
|
||||||
guard !normalizedText.isEmpty, stride > 0, window > 0 else { return [] }
|
|
||||||
// Use NSString-based (UTF-16) iteration to match normalizedToChapterOffsets indices
|
|
||||||
let nsText = normalizedText as NSString
|
|
||||||
let length = nsText.length
|
|
||||||
var samples: [(token: String, offset: Int)] = []
|
|
||||||
var index = 0
|
|
||||||
|
|
||||||
while index < length, samples.count < maxSamples {
|
|
||||||
let end = min(index + window, length)
|
|
||||||
let token = nsText.substring(with: NSRange(location: index, length: end - index))
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if token.utf16.count >= min(window, 4) {
|
|
||||||
samples.append((token, index))
|
|
||||||
}
|
|
||||||
index += stride
|
|
||||||
}
|
|
||||||
|
|
||||||
if samples.isEmpty {
|
|
||||||
let token = nsText.substring(with: NSRange(location: 0, length: min(window, length)))
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !token.isEmpty {
|
|
||||||
samples.append((token, 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return samples
|
|
||||||
}
|
|
||||||
|
|
||||||
static func normalize(_ source: String) -> String {
|
|
||||||
var result = ""
|
|
||||||
var lastWasWhitespace = false
|
|
||||||
let decodedSource = RDEPUBHTMLEntityDecoder.decodeEntities(in: source)
|
|
||||||
let nsSource = decodedSource as NSString
|
|
||||||
|
|
||||||
for index in 0..<nsSource.length {
|
|
||||||
let scalar = nsSource.substring(with: NSRange(location: index, length: 1))
|
|
||||||
let normalizedScalar = normalizeCharacter(scalar)
|
|
||||||
if normalizedScalar == " " {
|
|
||||||
if lastWasWhitespace {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastWasWhitespace = true
|
|
||||||
} else {
|
|
||||||
lastWasWhitespace = false
|
|
||||||
}
|
|
||||||
result.append(normalizedScalar)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizeCharacter(_ scalar: String) -> Character {
|
|
||||||
if scalar == "\u{00A0}" {
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
if scalar.unicodeScalars.allSatisfy(\.properties.isWhitespace) {
|
|
||||||
return " "
|
|
||||||
}
|
|
||||||
return scalar.first ?? " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension String {
|
|
||||||
var fnv1a64: UInt64 {
|
|
||||||
let offsetBasis: UInt64 = 14695981039346656037
|
|
||||||
let prime: UInt64 = 1099511628211
|
|
||||||
return utf8.reduce(offsetBasis) { partialResult, byte in
|
|
||||||
(partialResult ^ UInt64(byte)) &* prime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum RDEPUBHTMLEntityDecoder {
|
|
||||||
private static let namedEntities: [String: String] = [
|
|
||||||
" ": " ",
|
|
||||||
"&": "&",
|
|
||||||
"<": "<",
|
|
||||||
">": ">",
|
|
||||||
""": "\"",
|
|
||||||
"'": "'"
|
|
||||||
]
|
|
||||||
|
|
||||||
static func decode(_ value: String) -> String {
|
|
||||||
if let mapped = namedEntities[value] {
|
|
||||||
return mapped
|
|
||||||
}
|
|
||||||
guard value.hasPrefix("&"), value.hasSuffix(";") else {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
let body = String(value.dropFirst().dropLast())
|
|
||||||
if body.hasPrefix("#x") || body.hasPrefix("#X"),
|
|
||||||
let scalar = UInt32(body.dropFirst(2), radix: 16).flatMap(UnicodeScalar.init) {
|
|
||||||
return String(Character(scalar))
|
|
||||||
}
|
|
||||||
if body.hasPrefix("#"),
|
|
||||||
let scalar = UInt32(body.dropFirst(), radix: 10).flatMap(UnicodeScalar.init) {
|
|
||||||
return String(Character(scalar))
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decodeEntities(in value: String) -> String {
|
|
||||||
guard value.contains("&"),
|
|
||||||
let regex = try? NSRegularExpression(pattern: #"&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);"#) else {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
let nsValue = value as NSString
|
|
||||||
var decoded = value
|
|
||||||
for match in regex.matches(in: value, range: NSRange(location: 0, length: nsValue.length)).reversed() {
|
|
||||||
let entity = nsValue.substring(with: match.range)
|
|
||||||
let replacement = decode(entity)
|
|
||||||
if let range = Range(match.range, in: decoded) {
|
|
||||||
decoded.replaceSubrange(range, with: replacement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return decoded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFIError: Error, Equatable {
|
|
||||||
case empty
|
|
||||||
case invalidWrapper(String)
|
|
||||||
case invalidPath(String)
|
|
||||||
case unsupportedRange(String)
|
|
||||||
}
|
|
||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFIGenerator {
|
|
||||||
|
|
||||||
public static func makeOffsetCFI(
|
|
||||||
href: String,
|
|
||||||
fileIndex: Int,
|
|
||||||
chapterOffset: Int,
|
|
||||||
fragmentID: String? = nil,
|
|
||||||
sideBias: RDEPUBCFISideBias? = nil,
|
|
||||||
textAssertion: RDEPUBCFITextAssertion? = nil,
|
|
||||||
contentPath: RDEPUBCFIPath? = nil
|
|
||||||
) -> RDEPUBCFI {
|
|
||||||
let packagePath = RDEPUBCFIPath(steps: [
|
|
||||||
RDEPUBCFIStep(index: 6),
|
|
||||||
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
|
|
||||||
])
|
|
||||||
let resolvedContentPath = contentPath ?? RDEPUBCFIPath(steps: [
|
|
||||||
RDEPUBCFIStep(index: 4),
|
|
||||||
RDEPUBCFIStep(index: 2, idAssertion: fragmentID)
|
|
||||||
])
|
|
||||||
return RDEPUBCFI(
|
|
||||||
packagePath: packagePath,
|
|
||||||
contentPath: resolvedContentPath,
|
|
||||||
characterOffset: max(chapterOffset, 0),
|
|
||||||
sideBias: sideBias,
|
|
||||||
textAssertion: textAssertion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func makeCFI(
|
|
||||||
href: String,
|
|
||||||
fileIndex: Int,
|
|
||||||
contentPath: RDEPUBCFIPath,
|
|
||||||
characterOffset: Int,
|
|
||||||
sideBias: RDEPUBCFISideBias? = nil,
|
|
||||||
textAssertion: RDEPUBCFITextAssertion? = nil
|
|
||||||
) -> RDEPUBCFI {
|
|
||||||
let packagePath = RDEPUBCFIPath(steps: [
|
|
||||||
RDEPUBCFIStep(index: 6),
|
|
||||||
RDEPUBCFIStep(index: max((fileIndex + 1) * 2, 2), idAssertion: href)
|
|
||||||
])
|
|
||||||
return RDEPUBCFI(
|
|
||||||
packagePath: packagePath,
|
|
||||||
contentPath: contentPath,
|
|
||||||
characterOffset: max(characterOffset, 0),
|
|
||||||
sideBias: sideBias,
|
|
||||||
textAssertion: textAssertion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func makeOffsetRangeCFI(
|
|
||||||
href: String,
|
|
||||||
fileIndex: Int,
|
|
||||||
startOffset: Int,
|
|
||||||
endOffset: Int,
|
|
||||||
fragmentID: String? = nil,
|
|
||||||
startTextAssertion: RDEPUBCFITextAssertion? = nil,
|
|
||||||
endTextAssertion: RDEPUBCFITextAssertion? = nil,
|
|
||||||
contentPath: RDEPUBCFIPath? = nil
|
|
||||||
) -> RDEPUBCFIRange {
|
|
||||||
precondition(startOffset <= endOffset, "startOffset (\(startOffset)) must be <= endOffset (\(endOffset))")
|
|
||||||
let start = makeOffsetCFI(
|
|
||||||
href: href,
|
|
||||||
fileIndex: fileIndex,
|
|
||||||
chapterOffset: startOffset,
|
|
||||||
fragmentID: fragmentID,
|
|
||||||
sideBias: .before,
|
|
||||||
textAssertion: startTextAssertion,
|
|
||||||
contentPath: contentPath
|
|
||||||
)
|
|
||||||
let end = makeOffsetCFI(
|
|
||||||
href: href,
|
|
||||||
fileIndex: fileIndex,
|
|
||||||
chapterOffset: endOffset,
|
|
||||||
fragmentID: fragmentID,
|
|
||||||
sideBias: .after,
|
|
||||||
textAssertion: endTextAssertion,
|
|
||||||
contentPath: contentPath
|
|
||||||
)
|
|
||||||
let parent = RDEPUBCFI(
|
|
||||||
packagePath: start.packagePath.commonPrefix(with: end.packagePath),
|
|
||||||
contentPath: start.contentPath.commonPrefix(with: end.contentPath)
|
|
||||||
)
|
|
||||||
return RDEPUBCFIRange(parent: parent, start: start, end: end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFIMap: Codable, Equatable {
|
|
||||||
public var href: String
|
|
||||||
public var renderVersion: Int
|
|
||||||
public var domVersion: Int
|
|
||||||
public var markers: [RDEPUBCFIMarker]
|
|
||||||
public var textAssertions: [String: RDEPUBCFITextAssertion]
|
|
||||||
public var pathRanges: [RDEPUBCFIPathRange]
|
|
||||||
public var recoveryMetadata: RDEPUBCFIRecoveryMetadata
|
|
||||||
|
|
||||||
/// Lazy O(1) lookup index for markers by cfiPath
|
|
||||||
private var markerByPath: [RDEPUBCFIPath: Int]?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
href: String,
|
|
||||||
renderVersion: Int = 1,
|
|
||||||
domVersion: Int = 1,
|
|
||||||
markers: [RDEPUBCFIMarker] = [],
|
|
||||||
textAssertions: [String: RDEPUBCFITextAssertion] = [:],
|
|
||||||
pathRanges: [RDEPUBCFIPathRange] = [],
|
|
||||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata = .empty
|
|
||||||
) {
|
|
||||||
self.href = href
|
|
||||||
self.renderVersion = renderVersion
|
|
||||||
self.domVersion = domVersion
|
|
||||||
self.markers = markers
|
|
||||||
self.textAssertions = textAssertions
|
|
||||||
self.pathRanges = pathRanges
|
|
||||||
self.recoveryMetadata = recoveryMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
public func marker(matching path: RDEPUBCFIPath) -> RDEPUBCFIMarker? {
|
|
||||||
if let index = markerByPath?[path] {
|
|
||||||
return markers[index]
|
|
||||||
}
|
|
||||||
return markers.first { $0.cfiPath == path }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build or rebuild the marker lookup index. Call after mutating `markers`.
|
|
||||||
public mutating func rebuildMarkerIndex() {
|
|
||||||
markerByPath = Dictionary(
|
|
||||||
markers.enumerated().map { ($0.element.cfiPath, $0.offset) },
|
|
||||||
uniquingKeysWith: { _, last in last }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case href
|
|
||||||
case renderVersion
|
|
||||||
case domVersion
|
|
||||||
case markers
|
|
||||||
case textAssertions
|
|
||||||
case pathRanges
|
|
||||||
case recoveryMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
href = try container.decode(String.self, forKey: .href)
|
|
||||||
renderVersion = try container.decodeIfPresent(Int.self, forKey: .renderVersion) ?? 1
|
|
||||||
domVersion = try container.decodeIfPresent(Int.self, forKey: .domVersion) ?? 1
|
|
||||||
markers = try container.decodeIfPresent([RDEPUBCFIMarker].self, forKey: .markers) ?? []
|
|
||||||
textAssertions = try container.decodeIfPresent([String: RDEPUBCFITextAssertion].self, forKey: .textAssertions) ?? [:]
|
|
||||||
pathRanges = try container.decodeIfPresent([RDEPUBCFIPathRange].self, forKey: .pathRanges) ?? []
|
|
||||||
recoveryMetadata = try container.decodeIfPresent(RDEPUBCFIRecoveryMetadata.self, forKey: .recoveryMetadata) ?? .empty
|
|
||||||
markerByPath = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(href, forKey: .href)
|
|
||||||
try container.encode(renderVersion, forKey: .renderVersion)
|
|
||||||
try container.encode(domVersion, forKey: .domVersion)
|
|
||||||
try container.encode(markers, forKey: .markers)
|
|
||||||
try container.encode(textAssertions, forKey: .textAssertions)
|
|
||||||
try container.encode(pathRanges, forKey: .pathRanges)
|
|
||||||
try container.encode(recoveryMetadata, forKey: .recoveryMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func == (lhs: RDEPUBCFIMap, rhs: RDEPUBCFIMap) -> Bool {
|
|
||||||
lhs.href == rhs.href
|
|
||||||
&& lhs.renderVersion == rhs.renderVersion
|
|
||||||
&& lhs.domVersion == rhs.domVersion
|
|
||||||
&& lhs.markers == rhs.markers
|
|
||||||
&& lhs.textAssertions == rhs.textAssertions
|
|
||||||
&& lhs.pathRanges == rhs.pathRanges
|
|
||||||
&& lhs.recoveryMetadata == rhs.recoveryMetadata
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBCFIMarker: Codable, Equatable {
|
|
||||||
public var cfiPath: RDEPUBCFIPath
|
|
||||||
public var chapterOffset: Int?
|
|
||||||
public var fragmentID: String?
|
|
||||||
public var textNodeLength: Int?
|
|
||||||
public var textNodeChecksum: UInt64?
|
|
||||||
public var normalizedTextPreview: String?
|
|
||||||
public var domSiblingSignature: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
cfiPath: RDEPUBCFIPath,
|
|
||||||
chapterOffset: Int? = nil,
|
|
||||||
fragmentID: String? = nil,
|
|
||||||
textNodeLength: Int? = nil,
|
|
||||||
textNodeChecksum: UInt64? = nil,
|
|
||||||
normalizedTextPreview: String? = nil,
|
|
||||||
domSiblingSignature: String? = nil
|
|
||||||
) {
|
|
||||||
self.cfiPath = cfiPath
|
|
||||||
self.chapterOffset = chapterOffset
|
|
||||||
self.fragmentID = fragmentID
|
|
||||||
self.textNodeLength = textNodeLength
|
|
||||||
self.textNodeChecksum = textNodeChecksum
|
|
||||||
self.normalizedTextPreview = normalizedTextPreview
|
|
||||||
self.domSiblingSignature = domSiblingSignature
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBCFIPathRange: Codable, Equatable {
|
|
||||||
public var cfiPath: RDEPUBCFIPath
|
|
||||||
public var startOffset: Int
|
|
||||||
public var endOffset: Int
|
|
||||||
public var textNodeLength: Int
|
|
||||||
|
|
||||||
public init(
|
|
||||||
cfiPath: RDEPUBCFIPath,
|
|
||||||
startOffset: Int,
|
|
||||||
endOffset: Int,
|
|
||||||
textNodeLength: Int
|
|
||||||
) {
|
|
||||||
self.cfiPath = cfiPath
|
|
||||||
self.startOffset = startOffset
|
|
||||||
self.endOffset = endOffset
|
|
||||||
self.textNodeLength = textNodeLength
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBCFIRecoveryMetadata: Codable, Equatable {
|
|
||||||
public var domFingerprint: String
|
|
||||||
public var normalizedTextChecksum: String
|
|
||||||
public var tokenIndex: [RDEPUBCFITokenAnchor]
|
|
||||||
public var fragmentPathMap: [String: RDEPUBCFIPath]
|
|
||||||
|
|
||||||
public init(
|
|
||||||
domFingerprint: String,
|
|
||||||
normalizedTextChecksum: String,
|
|
||||||
tokenIndex: [RDEPUBCFITokenAnchor] = [],
|
|
||||||
fragmentPathMap: [String: RDEPUBCFIPath] = [:]
|
|
||||||
) {
|
|
||||||
self.domFingerprint = domFingerprint
|
|
||||||
self.normalizedTextChecksum = normalizedTextChecksum
|
|
||||||
self.tokenIndex = tokenIndex
|
|
||||||
self.fragmentPathMap = fragmentPathMap
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let empty = RDEPUBCFIRecoveryMetadata(
|
|
||||||
domFingerprint: "",
|
|
||||||
normalizedTextChecksum: "",
|
|
||||||
tokenIndex: [],
|
|
||||||
fragmentPathMap: [:]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBCFITokenAnchor: Codable, Equatable {
|
|
||||||
public var token: String
|
|
||||||
public var occurrence: Int
|
|
||||||
public var chapterOffset: Int
|
|
||||||
public var cfiPath: RDEPUBCFIPath
|
|
||||||
|
|
||||||
public init(
|
|
||||||
token: String,
|
|
||||||
occurrence: Int,
|
|
||||||
chapterOffset: Int,
|
|
||||||
cfiPath: RDEPUBCFIPath
|
|
||||||
) {
|
|
||||||
self.token = token
|
|
||||||
self.occurrence = occurrence
|
|
||||||
self.chapterOffset = chapterOffset
|
|
||||||
self.cfiPath = cfiPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFIParser {
|
|
||||||
|
|
||||||
public static func parse(_ rawValue: String?) throws -> RDEPUBCFI {
|
|
||||||
|
|
||||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!rawValue.isEmpty else {
|
|
||||||
throw RDEPUBCFIError.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = try unwrap(rawValue)
|
|
||||||
|
|
||||||
let parts = body.split(separator: "!", maxSplits: 1, omittingEmptySubsequences: false)
|
|
||||||
|
|
||||||
let packagePath = try parsePath(String(parts.first ?? ""))
|
|
||||||
|
|
||||||
let contentBody = parts.count > 1 ? String(parts[1]) : ""
|
|
||||||
|
|
||||||
let parsedContent = try parsePathWithOffset(contentBody)
|
|
||||||
|
|
||||||
return RDEPUBCFI(
|
|
||||||
packagePath: packagePath,
|
|
||||||
contentPath: parsedContent.path,
|
|
||||||
characterOffset: parsedContent.characterOffset,
|
|
||||||
sideBias: parsedContent.sideBias,
|
|
||||||
textAssertion: parsedContent.textAssertion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func parseRange(_ rawValue: String?) throws -> RDEPUBCFIRange {
|
|
||||||
|
|
||||||
guard let rawValue = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
||||||
!rawValue.isEmpty else {
|
|
||||||
throw RDEPUBCFIError.empty
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = try unwrap(rawValue)
|
|
||||||
|
|
||||||
let rangeParts = body.split(separator: ",", omittingEmptySubsequences: false)
|
|
||||||
guard rangeParts.count == 3 else {
|
|
||||||
throw RDEPUBCFIError.unsupportedRange(rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
let parentRaw = "epubcfi(\(rangeParts[0]))"
|
|
||||||
|
|
||||||
let startRaw = "epubcfi(\(rangeParts[0])\(rangeParts[1]))"
|
|
||||||
|
|
||||||
let endRaw = "epubcfi(\(rangeParts[0])\(rangeParts[2]))"
|
|
||||||
return RDEPUBCFIRange(
|
|
||||||
parent: try parse(parentRaw),
|
|
||||||
start: try parse(startRaw),
|
|
||||||
end: try parse(endRaw)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func unwrap(_ rawValue: String) throws -> String {
|
|
||||||
guard rawValue.hasPrefix("epubcfi("), rawValue.hasSuffix(")") else {
|
|
||||||
throw RDEPUBCFIError.invalidWrapper(rawValue)
|
|
||||||
}
|
|
||||||
return String(rawValue.dropFirst("epubcfi(".count).dropLast())
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parsePathWithOffset(_ body: String) throws -> (
|
|
||||||
path: RDEPUBCFIPath,
|
|
||||||
characterOffset: Int?,
|
|
||||||
sideBias: RDEPUBCFISideBias?,
|
|
||||||
textAssertion: RDEPUBCFITextAssertion?
|
|
||||||
) {
|
|
||||||
|
|
||||||
guard let offsetSeparator = firstIndexOutsideBrackets(of: ":", in: body) else {
|
|
||||||
|
|
||||||
return (try parsePath(body), nil, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathPart = String(body[..<offsetSeparator])
|
|
||||||
let offsetAndQualifiers = String(body[body.index(after: offsetSeparator)...])
|
|
||||||
let path = try parsePath(pathPart)
|
|
||||||
|
|
||||||
let offsetDigits = offsetAndQualifiers.prefix { $0.isNumber }
|
|
||||||
let offset = Int(offsetDigits)
|
|
||||||
|
|
||||||
let qualifierStart = offsetAndQualifiers.index(
|
|
||||||
offsetAndQualifiers.startIndex,
|
|
||||||
offsetBy: offsetDigits.count
|
|
||||||
)
|
|
||||||
let qualifiers = String(offsetAndQualifiers[qualifierStart...])
|
|
||||||
return (
|
|
||||||
path,
|
|
||||||
offset,
|
|
||||||
parseSideBias(from: qualifiers),
|
|
||||||
parseTextAssertion(from: qualifiers)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parsePath(_ path: String) throws -> RDEPUBCFIPath {
|
|
||||||
|
|
||||||
guard path.isEmpty || path.hasPrefix("/") else {
|
|
||||||
throw RDEPUBCFIError.invalidPath(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
let steps = path
|
|
||||||
.split(separator: "/", omittingEmptySubsequences: true)
|
|
||||||
.compactMap { parseStep(String($0)) }
|
|
||||||
return RDEPUBCFIPath(steps: steps)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseStep(_ rawStep: String) -> RDEPUBCFIStep? {
|
|
||||||
|
|
||||||
let indexPart = rawStep.split(separator: "[", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? rawStep
|
|
||||||
guard let index = Int(indexPart) else { return nil }
|
|
||||||
|
|
||||||
let idAssertion: String?
|
|
||||||
if let open = rawStep.firstIndex(of: "["),
|
|
||||||
let close = rawStep.lastIndex(of: "]"),
|
|
||||||
open < close {
|
|
||||||
idAssertion = String(rawStep[rawStep.index(after: open)..<close]).rd_nilIfEmpty
|
|
||||||
} else {
|
|
||||||
idAssertion = nil
|
|
||||||
}
|
|
||||||
return RDEPUBCFIStep(index: index, idAssertion: idAssertion)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseSideBias(from body: String) -> RDEPUBCFISideBias? {
|
|
||||||
|
|
||||||
guard let markerRange = body.range(of: ";s=") else { return nil }
|
|
||||||
let value = body[markerRange.upperBound...].prefix(1)
|
|
||||||
return RDEPUBCFISideBias(rawValue: String(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseTextAssertion(from body: String) -> RDEPUBCFITextAssertion? {
|
|
||||||
|
|
||||||
// Scan for the opening bracket, respecting backslash escapes
|
|
||||||
guard let open = firstUnescapedIndex(of: "[", in: body) else { return nil }
|
|
||||||
|
|
||||||
// Find the matching closing bracket, respecting backslash escapes and nesting
|
|
||||||
var depth = 1
|
|
||||||
var current = body.index(after: open)
|
|
||||||
while current < body.endIndex && depth > 0 {
|
|
||||||
if body[current] == "\\" {
|
|
||||||
// Skip escaped character
|
|
||||||
current = body.index(after: current)
|
|
||||||
if current < body.endIndex {
|
|
||||||
current = body.index(after: current)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if body[current] == "[" {
|
|
||||||
depth += 1
|
|
||||||
} else if body[current] == "]" {
|
|
||||||
depth -= 1
|
|
||||||
}
|
|
||||||
if depth > 0 {
|
|
||||||
current = body.index(after: current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard depth == 0, current <= body.endIndex else { return nil }
|
|
||||||
let close = current
|
|
||||||
|
|
||||||
// Extract content, processing escape sequences left-to-right
|
|
||||||
let raw = String(body[body.index(after: open)..<close])
|
|
||||||
let unescaped = unescapeCFIText(raw)
|
|
||||||
|
|
||||||
// Split by comma, but only commas that are not inside nested brackets
|
|
||||||
let components = splitAssertionComponents(unescaped)
|
|
||||||
guard components.count > 1 else {
|
|
||||||
return RDEPUBCFITextAssertion(exact: unescaped)
|
|
||||||
}
|
|
||||||
return RDEPUBCFITextAssertion(
|
|
||||||
prefix: components.first,
|
|
||||||
exact: components.count > 1 ? components[1] : nil,
|
|
||||||
suffix: components.count > 2 ? components[2] : nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the first occurrence of a character not preceded by a backslash
|
|
||||||
private static func firstUnescapedIndex(of character: Character, in string: String) -> String.Index? {
|
|
||||||
var index = string.startIndex
|
|
||||||
while index < string.endIndex {
|
|
||||||
if string[index] == "\\" {
|
|
||||||
// Skip escaped character
|
|
||||||
index = string.index(after: index)
|
|
||||||
if index < string.endIndex {
|
|
||||||
index = string.index(after: index)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if string[index] == character {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
index = string.index(after: index)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split assertion text by commas, but only those at the top level (not inside brackets)
|
|
||||||
private static func splitAssertionComponents(_ text: String) -> [String] {
|
|
||||||
var components: [String] = []
|
|
||||||
var current = ""
|
|
||||||
var depth = 0
|
|
||||||
var index = text.startIndex
|
|
||||||
while index < text.endIndex {
|
|
||||||
let char = text[index]
|
|
||||||
if char == "\\" {
|
|
||||||
current.append(char)
|
|
||||||
index = text.index(after: index)
|
|
||||||
if index < text.endIndex {
|
|
||||||
current.append(text[index])
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if char == "[" {
|
|
||||||
depth += 1
|
|
||||||
} else if char == "]" {
|
|
||||||
depth = max(depth - 1, 0)
|
|
||||||
} else if char == "," && depth == 0 {
|
|
||||||
components.append(current)
|
|
||||||
current = ""
|
|
||||||
index = text.index(after: index)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current.append(char)
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
components.append(current)
|
|
||||||
return components
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unescape CFI text assertion content: \[ → [, \] → ], \\ → \
|
|
||||||
private static func unescapeCFIText(_ text: String) -> String {
|
|
||||||
var result = ""
|
|
||||||
var index = text.startIndex
|
|
||||||
while index < text.endIndex {
|
|
||||||
if text[index] == "\\" {
|
|
||||||
let nextIndex = text.index(after: index)
|
|
||||||
if nextIndex < text.endIndex {
|
|
||||||
let nextChar = text[nextIndex]
|
|
||||||
if nextChar == "[" || nextChar == "]" || nextChar == "\\" {
|
|
||||||
result.append(nextChar)
|
|
||||||
index = text.index(after: nextIndex)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Not a recognized escape — keep the backslash
|
|
||||||
result.append(text[index])
|
|
||||||
} else {
|
|
||||||
result.append(text[index])
|
|
||||||
}
|
|
||||||
index = text.index(after: index)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func firstIndexOutsideBrackets(of character: Character, in body: String) -> String.Index? {
|
|
||||||
|
|
||||||
var bracketDepth = 0
|
|
||||||
for index in body.indices {
|
|
||||||
let current = body[index]
|
|
||||||
if current == "[" {
|
|
||||||
bracketDepth += 1
|
|
||||||
} else if current == "]" {
|
|
||||||
bracketDepth = max(bracketDepth - 1, 0)
|
|
||||||
} else if current == character, bracketDepth == 0 {
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFIPath: Codable, Equatable, Hashable {
|
|
||||||
|
|
||||||
public var steps: [RDEPUBCFIStep]
|
|
||||||
|
|
||||||
public init(steps: [RDEPUBCFIStep] = []) {
|
|
||||||
self.steps = steps
|
|
||||||
}
|
|
||||||
|
|
||||||
public func commonPrefix(with other: RDEPUBCFIPath) -> RDEPUBCFIPath {
|
|
||||||
var prefix: [RDEPUBCFIStep] = []
|
|
||||||
let upperBound = min(steps.count, other.steps.count)
|
|
||||||
for index in 0..<upperBound {
|
|
||||||
guard steps[index] == other.steps[index] else { break }
|
|
||||||
prefix.append(steps[index])
|
|
||||||
}
|
|
||||||
return RDEPUBCFIPath(steps: prefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func droppingPrefix(_ prefix: RDEPUBCFIPath) -> RDEPUBCFIPath {
|
|
||||||
guard prefix.steps.count <= steps.count else { return self }
|
|
||||||
let candidate = Array(steps.prefix(prefix.steps.count))
|
|
||||||
guard candidate == prefix.steps else { return self }
|
|
||||||
return RDEPUBCFIPath(steps: Array(steps.dropFirst(prefix.steps.count)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBCFIStep: Codable, Equatable, Hashable {
|
|
||||||
|
|
||||||
public var index: Int
|
|
||||||
|
|
||||||
public var idAssertion: String?
|
|
||||||
|
|
||||||
public init(index: Int, idAssertion: String? = nil) {
|
|
||||||
self.index = index
|
|
||||||
self.idAssertion = idAssertion?.rd_nilIfEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFIRange: Codable, Equatable, Hashable {
|
|
||||||
public var parent: RDEPUBCFI?
|
|
||||||
public var start: RDEPUBCFI
|
|
||||||
public var end: RDEPUBCFI
|
|
||||||
|
|
||||||
/// Always computed from components — no stale cached value risk.
|
|
||||||
public var rawValue: String {
|
|
||||||
RDEPUBCFISerializer.serializeRange(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize from components. The `rawValue` parameter is ignored — rawValue is always
|
|
||||||
/// computed from the components. To parse a CFI range string, use `RDEPUBCFIParser.parseRange(_:)`.
|
|
||||||
public init(rawValue: String = "", parent: RDEPUBCFI? = nil, start: RDEPUBCFI, end: RDEPUBCFI) {
|
|
||||||
self.parent = parent
|
|
||||||
self.start = start
|
|
||||||
self.end = end
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.singleValueContainer()
|
|
||||||
let rawValue = try container.decode(String.self)
|
|
||||||
let parsed = try RDEPUBCFIParser.parseRange(rawValue)
|
|
||||||
self.parent = parsed.parent
|
|
||||||
self.start = parsed.start
|
|
||||||
self.end = parsed.end
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-380
@@ -1,380 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFIRecoveryResult: Equatable {
|
|
||||||
public enum Confidence: Int, Codable {
|
|
||||||
case exactPath
|
|
||||||
case assertionCalibrated
|
|
||||||
case siblingRecovered
|
|
||||||
case tokenRecovered
|
|
||||||
case fragmentFallback
|
|
||||||
case offsetFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
public var chapterOffset: Int
|
|
||||||
public var confidence: Confidence
|
|
||||||
|
|
||||||
public init(chapterOffset: Int, confidence: Confidence) {
|
|
||||||
self.chapterOffset = chapterOffset
|
|
||||||
self.confidence = confidence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RDEPUBCFIRecoveryEngine {
|
|
||||||
static func recover(
|
|
||||||
cfi: RDEPUBCFI,
|
|
||||||
cfiMap: RDEPUBCFIMap?,
|
|
||||||
chapterText: String?,
|
|
||||||
fragmentOffsets: [String: Int],
|
|
||||||
fallbackOffset: Int?,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> RDEPUBCFIRecoveryResult? {
|
|
||||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
|
||||||
|
|
||||||
if let exact = exactPathResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
|
|
||||||
return calibrateIfNeeded(
|
|
||||||
exact,
|
|
||||||
cfi: cfi,
|
|
||||||
chapterText: chapterText,
|
|
||||||
lastOffset: lastOffset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sibling = siblingRecoveredResult(cfi: cfi, cfiMap: cfiMap, lastOffset: lastOffset) {
|
|
||||||
return calibrateIfNeeded(
|
|
||||||
sibling,
|
|
||||||
cfi: cfi,
|
|
||||||
chapterText: chapterText,
|
|
||||||
lastOffset: lastOffset
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let token = tokenRecoveredResult(
|
|
||||||
cfi: cfi,
|
|
||||||
cfiMap: cfiMap,
|
|
||||||
chapterText: chapterText,
|
|
||||||
fallbackOffset: resolved.chapterOffset,
|
|
||||||
lastOffset: lastOffset
|
|
||||||
) {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fragmentID = resolved.fragmentID,
|
|
||||||
let fragmentOffset = fragmentOffsets[fragmentID]
|
|
||||||
?? cfiMap?.markers.first(where: { $0.fragmentID == fragmentID })?.chapterOffset {
|
|
||||||
return RDEPUBCFIRecoveryResult(
|
|
||||||
chapterOffset: clamp(fragmentOffset, lastOffset: lastOffset),
|
|
||||||
confidence: .fragmentFallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fallbackOffset {
|
|
||||||
return RDEPUBCFIRecoveryResult(
|
|
||||||
chapterOffset: clamp(fallbackOffset, lastOffset: lastOffset),
|
|
||||||
confidence: .offsetFallback
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func exactPathResult(
|
|
||||||
cfi: RDEPUBCFI,
|
|
||||||
cfiMap: RDEPUBCFIMap?,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> RDEPUBCFIRecoveryResult? {
|
|
||||||
guard let cfiMap else { return nil }
|
|
||||||
if let marker = cfiMap.marker(matching: cfi.contentPath),
|
|
||||||
let chapterOffset = chapterOffset(for: cfi, marker: marker, lastOffset: lastOffset) {
|
|
||||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
|
|
||||||
}
|
|
||||||
if let pathRange = cfiMap.pathRanges.first(where: { $0.cfiPath == cfi.contentPath }) {
|
|
||||||
let localOffset = max(cfi.characterOffset ?? 0, 0)
|
|
||||||
let chapterOffset = clamp(pathRange.startOffset + min(localOffset, max(pathRange.textNodeLength - 1, 0)), lastOffset: lastOffset)
|
|
||||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .exactPath)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func siblingRecoveredResult(
|
|
||||||
cfi: RDEPUBCFI,
|
|
||||||
cfiMap: RDEPUBCFIMap?,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> RDEPUBCFIRecoveryResult? {
|
|
||||||
guard let cfiMap,
|
|
||||||
let targetStep = cfi.contentPath.steps.last else { return nil }
|
|
||||||
|
|
||||||
let targetParent = cfi.contentPath.steps.dropLast()
|
|
||||||
let candidates = cfiMap.markers.filter { marker in
|
|
||||||
marker.cfiPath.steps.count == cfi.contentPath.steps.count
|
|
||||||
&& Array(marker.cfiPath.steps.dropLast()) == Array(targetParent)
|
|
||||||
&& marker.chapterOffset != nil
|
|
||||||
}
|
|
||||||
guard !candidates.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let siblingSignature = siblingSignature(for: cfi.contentPath)
|
|
||||||
let best = candidates.min { lhs, rhs in
|
|
||||||
siblingScore(for: lhs, targetStep: targetStep, siblingSignature: siblingSignature)
|
|
||||||
< siblingScore(for: rhs, targetStep: targetStep, siblingSignature: siblingSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let best,
|
|
||||||
let chapterOffset = chapterOffset(for: cfi, marker: best, lastOffset: lastOffset) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBCFIRecoveryResult(chapterOffset: chapterOffset, confidence: .siblingRecovered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func tokenRecoveredResult(
|
|
||||||
cfi: RDEPUBCFI,
|
|
||||||
cfiMap: RDEPUBCFIMap?,
|
|
||||||
chapterText: String?,
|
|
||||||
fallbackOffset: Int?,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> RDEPUBCFIRecoveryResult? {
|
|
||||||
guard let cfiMap,
|
|
||||||
let chapterText,
|
|
||||||
!chapterText.isEmpty,
|
|
||||||
let exact = cfi.textAssertion?.exact,
|
|
||||||
!exact.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let minLength = 2
|
|
||||||
let exactCount = Double(max(exact.count, 1))
|
|
||||||
let candidateAnchors = cfiMap.recoveryMetadata.tokenIndex.filter { anchor in
|
|
||||||
let tokenCount = anchor.token.count
|
|
||||||
guard tokenCount >= minLength else { return false }
|
|
||||||
let ratio = Double(tokenCount) / exactCount
|
|
||||||
guard ratio >= 0.3 && ratio <= 3.0 else { return false }
|
|
||||||
let hasPrefixMatch = anchor.token.hasPrefix(exact) || exact.hasPrefix(anchor.token)
|
|
||||||
let hasSuffixMatch = anchor.token.hasSuffix(exact) || exact.hasSuffix(anchor.token)
|
|
||||||
return hasPrefixMatch || hasSuffixMatch
|
|
||||||
}
|
|
||||||
guard !candidateAnchors.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let normalizedText = RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText)
|
|
||||||
guard !normalizedText.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let preferredOffset = fallbackOffset.map { clamp($0, lastOffset: lastOffset) }
|
|
||||||
var bestOffset: Int?
|
|
||||||
var bestScore = Int.max
|
|
||||||
|
|
||||||
for anchor in candidateAnchors {
|
|
||||||
let approximateOffset = clamp(anchor.chapterOffset, lastOffset: lastOffset)
|
|
||||||
let calibrated = calibratedOffset(
|
|
||||||
approximateOffset,
|
|
||||||
assertion: cfi.textAssertion,
|
|
||||||
text: chapterText,
|
|
||||||
lastOffset: lastOffset,
|
|
||||||
windowRadius: 768
|
|
||||||
)
|
|
||||||
let score = tokenRecoveryScore(
|
|
||||||
for: anchor,
|
|
||||||
targetPath: cfi.contentPath,
|
|
||||||
approximateOffset: approximateOffset,
|
|
||||||
calibratedOffset: calibrated,
|
|
||||||
preferredOffset: preferredOffset,
|
|
||||||
allAnchors: cfiMap.recoveryMetadata.tokenIndex
|
|
||||||
)
|
|
||||||
if score < bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestOffset = calibrated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let bestOffset else { return nil }
|
|
||||||
return RDEPUBCFIRecoveryResult(chapterOffset: bestOffset, confidence: .tokenRecovered)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func calibrateIfNeeded(
|
|
||||||
_ result: RDEPUBCFIRecoveryResult,
|
|
||||||
cfi: RDEPUBCFI,
|
|
||||||
chapterText: String?,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> RDEPUBCFIRecoveryResult {
|
|
||||||
guard let chapterText,
|
|
||||||
let assertion = cfi.textAssertion,
|
|
||||||
assertion.exact?.isEmpty == false else {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
let calibrated = calibratedOffset(
|
|
||||||
result.chapterOffset,
|
|
||||||
assertion: assertion,
|
|
||||||
text: chapterText,
|
|
||||||
lastOffset: lastOffset
|
|
||||||
)
|
|
||||||
if calibrated != result.chapterOffset {
|
|
||||||
return RDEPUBCFIRecoveryResult(chapterOffset: calibrated, confidence: .assertionCalibrated)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func chapterOffset(
|
|
||||||
for cfi: RDEPUBCFI,
|
|
||||||
marker: RDEPUBCFIMarker,
|
|
||||||
lastOffset: Int
|
|
||||||
) -> Int? {
|
|
||||||
guard let baseOffset = marker.chapterOffset else { return nil }
|
|
||||||
let localOffset = max(cfi.characterOffset ?? 0, 0)
|
|
||||||
if let textNodeLength = marker.textNodeLength, textNodeLength > 0 {
|
|
||||||
return clamp(baseOffset + min(localOffset, max(textNodeLength - 1, 0)), lastOffset: lastOffset)
|
|
||||||
}
|
|
||||||
return clamp(baseOffset, lastOffset: lastOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func siblingSignature(for path: RDEPUBCFIPath) -> String {
|
|
||||||
guard let last = path.steps.last else { return "" }
|
|
||||||
let parent = path.steps.dropLast().map { String($0.index) }.joined(separator: "/")
|
|
||||||
let childIndex = max(last.index / 2, 0)
|
|
||||||
return "\(parent)#\(childIndex)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func siblingScore(
|
|
||||||
for marker: RDEPUBCFIMarker,
|
|
||||||
targetStep: RDEPUBCFIStep,
|
|
||||||
siblingSignature: String
|
|
||||||
) -> Int {
|
|
||||||
guard let candidateStep = marker.cfiPath.steps.last else { return Int.max }
|
|
||||||
var score = abs(candidateStep.index - targetStep.index)
|
|
||||||
if marker.domSiblingSignature != siblingSignature {
|
|
||||||
score += 1_000
|
|
||||||
}
|
|
||||||
if marker.fragmentID != nil {
|
|
||||||
score += 100
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func calibratedOffset(
|
|
||||||
_ offset: Int,
|
|
||||||
assertion: RDEPUBCFITextAssertion?,
|
|
||||||
text: String,
|
|
||||||
lastOffset: Int,
|
|
||||||
windowRadius: Int = 512
|
|
||||||
) -> Int {
|
|
||||||
let clampedOffset = clamp(offset, lastOffset: lastOffset)
|
|
||||||
guard let assertion,
|
|
||||||
let exact = assertion.exact,
|
|
||||||
!exact.isEmpty else {
|
|
||||||
return clampedOffset
|
|
||||||
}
|
|
||||||
|
|
||||||
let nsText = text as NSString
|
|
||||||
let length = nsText.length
|
|
||||||
guard length > 0 else { return clampedOffset }
|
|
||||||
|
|
||||||
let searchStart = max(clampedOffset - windowRadius, 0)
|
|
||||||
let searchEnd = min(clampedOffset + windowRadius + exact.utf16.count, length)
|
|
||||||
guard searchEnd > searchStart else { return clampedOffset }
|
|
||||||
|
|
||||||
let searchRange = NSRange(location: searchStart, length: searchEnd - searchStart)
|
|
||||||
var candidateRange = nsText.range(of: exact, options: [], range: searchRange)
|
|
||||||
var bestOffset: Int?
|
|
||||||
var bestScore = Int.max
|
|
||||||
|
|
||||||
while candidateRange.location != NSNotFound {
|
|
||||||
let score = assertionScore(
|
|
||||||
assertion,
|
|
||||||
candidateLocation: candidateRange.location,
|
|
||||||
exactLength: candidateRange.length,
|
|
||||||
in: nsText,
|
|
||||||
preferredOffset: clampedOffset
|
|
||||||
)
|
|
||||||
if score < bestScore {
|
|
||||||
bestScore = score
|
|
||||||
bestOffset = candidateRange.location
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextLocation = candidateRange.location + max(candidateRange.length, 1)
|
|
||||||
let upperBound = searchRange.location + searchRange.length
|
|
||||||
guard nextLocation < upperBound else { break }
|
|
||||||
candidateRange = nsText.range(
|
|
||||||
of: exact,
|
|
||||||
options: [],
|
|
||||||
range: NSRange(location: nextLocation, length: upperBound - nextLocation)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let bestOffset, bestScore < Int.max else {
|
|
||||||
return clampedOffset
|
|
||||||
}
|
|
||||||
return clamp(bestOffset, lastOffset: lastOffset)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func assertionScore(
|
|
||||||
_ assertion: RDEPUBCFITextAssertion,
|
|
||||||
candidateLocation: Int,
|
|
||||||
exactLength: Int,
|
|
||||||
in text: NSString,
|
|
||||||
preferredOffset: Int
|
|
||||||
) -> Int {
|
|
||||||
var score = abs(candidateLocation - preferredOffset)
|
|
||||||
if let prefix = assertion.prefix, !prefix.isEmpty {
|
|
||||||
let prefixStart = max(candidateLocation - prefix.utf16.count, 0)
|
|
||||||
let availableLength = candidateLocation - prefixStart
|
|
||||||
let candidatePrefix = availableLength > 0
|
|
||||||
? text.substring(with: NSRange(location: prefixStart, length: availableLength))
|
|
||||||
: ""
|
|
||||||
score += candidatePrefix.hasSuffix(prefix) ? 0 : 10_000
|
|
||||||
}
|
|
||||||
if let suffix = assertion.suffix, !suffix.isEmpty {
|
|
||||||
let suffixStart = min(candidateLocation + exactLength, text.length)
|
|
||||||
let suffixLength = min(suffix.utf16.count, text.length - suffixStart)
|
|
||||||
let candidateSuffix = suffixLength > 0
|
|
||||||
? text.substring(with: NSRange(location: suffixStart, length: suffixLength))
|
|
||||||
: ""
|
|
||||||
score += candidateSuffix == suffix ? 0 : 10_000
|
|
||||||
}
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func clamp(_ offset: Int, lastOffset: Int) -> Int {
|
|
||||||
min(max(offset, 0), max(lastOffset, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func tokenRecoveryScore(
|
|
||||||
for anchor: RDEPUBCFITokenAnchor,
|
|
||||||
targetPath: RDEPUBCFIPath,
|
|
||||||
approximateOffset: Int,
|
|
||||||
calibratedOffset: Int,
|
|
||||||
preferredOffset: Int?,
|
|
||||||
allAnchors: [RDEPUBCFITokenAnchor]
|
|
||||||
) -> Int {
|
|
||||||
var score = abs(calibratedOffset - approximateOffset)
|
|
||||||
score += pathDistance(anchor.cfiPath, targetPath) * 1_024
|
|
||||||
|
|
||||||
if let preferredOffset {
|
|
||||||
score += abs(approximateOffset - preferredOffset)
|
|
||||||
let expectedOccurrence = nearestOccurrence(
|
|
||||||
forToken: anchor.token,
|
|
||||||
preferredOffset: preferredOffset,
|
|
||||||
anchors: allAnchors
|
|
||||||
)
|
|
||||||
score += abs(anchor.occurrence - expectedOccurrence) * 256
|
|
||||||
}
|
|
||||||
|
|
||||||
return score
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nearestOccurrence(
|
|
||||||
forToken token: String,
|
|
||||||
preferredOffset: Int,
|
|
||||||
anchors: [RDEPUBCFITokenAnchor]
|
|
||||||
) -> Int {
|
|
||||||
anchors
|
|
||||||
.filter { $0.token == token }
|
|
||||||
.min(by: { abs($0.chapterOffset - preferredOffset) < abs($1.chapterOffset - preferredOffset) })?
|
|
||||||
.occurrence ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func pathDistance(_ lhs: RDEPUBCFIPath, _ rhs: RDEPUBCFIPath) -> Int {
|
|
||||||
let prefix = lhs.commonPrefix(with: rhs).steps.count
|
|
||||||
let remainingLHS = lhs.steps.dropFirst(prefix)
|
|
||||||
let remainingRHS = rhs.steps.dropFirst(prefix)
|
|
||||||
let stepDistance = zip(remainingLHS, remainingRHS).reduce(0) { partial, pair in
|
|
||||||
partial + abs(pair.0.index - pair.1.index)
|
|
||||||
}
|
|
||||||
return stepDistance + abs(lhs.steps.count - rhs.steps.count) * 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFIResolverResult: Equatable {
|
|
||||||
|
|
||||||
public var href: String?
|
|
||||||
|
|
||||||
public var fileIndex: Int?
|
|
||||||
|
|
||||||
public var chapterOffset: Int?
|
|
||||||
|
|
||||||
public var fragmentID: String?
|
|
||||||
|
|
||||||
public init(href: String? = nil, fileIndex: Int? = nil, chapterOffset: Int? = nil, fragmentID: String? = nil) {
|
|
||||||
self.href = href
|
|
||||||
self.fileIndex = fileIndex
|
|
||||||
self.chapterOffset = chapterOffset
|
|
||||||
self.fragmentID = fragmentID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBCFIResolver {
|
|
||||||
|
|
||||||
public static func resolve(_ cfi: RDEPUBCFI) -> RDEPUBCFIResolverResult {
|
|
||||||
|
|
||||||
// EPUB CFI spec: package path structure is /6/2[spine]/2n[manifest-id]
|
|
||||||
// Manifest step is always the 3rd step (index 2) if present, or the last step with an id assertion.
|
|
||||||
let manifestStep: RDEPUBCFIStep?
|
|
||||||
if cfi.packagePath.steps.count >= 3 {
|
|
||||||
manifestStep = cfi.packagePath.steps[2]
|
|
||||||
} else {
|
|
||||||
manifestStep = cfi.packagePath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })
|
|
||||||
}
|
|
||||||
let href = manifestStep?.idAssertion
|
|
||||||
|
|
||||||
// Spine step is the 2nd step (index 1) in the package path
|
|
||||||
let fileIndex: Int?
|
|
||||||
if cfi.packagePath.steps.count >= 2 {
|
|
||||||
fileIndex = max((cfi.packagePath.steps[1].index / 2) - 1, 0)
|
|
||||||
} else {
|
|
||||||
fileIndex = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fragmentID = cfi.contentPath.steps.last(where: { $0.idAssertion?.rd_nilIfEmpty != nil })?.idAssertion
|
|
||||||
return RDEPUBCFIResolverResult(
|
|
||||||
href: href,
|
|
||||||
fileIndex: fileIndex,
|
|
||||||
chapterOffset: cfi.characterOffset,
|
|
||||||
fragmentID: fragmentID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBCFISerializer {
|
|
||||||
|
|
||||||
public static func serialize(_ cfi: RDEPUBCFI) -> String {
|
|
||||||
|
|
||||||
var body = serializePath(cfi.packagePath)
|
|
||||||
|
|
||||||
let content = serializePath(cfi.contentPath)
|
|
||||||
|
|
||||||
if !content.isEmpty || cfi.characterOffset != nil {
|
|
||||||
body += "!" + content
|
|
||||||
}
|
|
||||||
|
|
||||||
if let characterOffset = cfi.characterOffset {
|
|
||||||
body += ":\(max(characterOffset, 0))"
|
|
||||||
}
|
|
||||||
|
|
||||||
if let sideBias = cfi.sideBias {
|
|
||||||
body += ";s=\(sideBias.rawValue)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if let assertion = cfi.textAssertion {
|
|
||||||
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
|
|
||||||
}
|
|
||||||
return "epubcfi(\(body))"
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func serializeRange(_ range: RDEPUBCFIRange) -> String {
|
|
||||||
let canonical = canonicalRangeComponents(for: range)
|
|
||||||
let parentBody = serializeRangeParent(canonical.parent)
|
|
||||||
let startBody = serializeRangeTerminal(
|
|
||||||
canonical.start,
|
|
||||||
relativeToPackagePath: canonical.parent.packagePath,
|
|
||||||
contentPrefix: canonical.parent.contentPath
|
|
||||||
)
|
|
||||||
let endBody = serializeRangeTerminal(
|
|
||||||
canonical.end,
|
|
||||||
relativeToPackagePath: canonical.parent.packagePath,
|
|
||||||
contentPrefix: canonical.parent.contentPath
|
|
||||||
)
|
|
||||||
return "epubcfi(\(parentBody),\(startBody),\(endBody))"
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func serializePath(_ path: RDEPUBCFIPath) -> String {
|
|
||||||
guard !path.steps.isEmpty else { return "" }
|
|
||||||
return path.steps.map { step in
|
|
||||||
if let idAssertion = step.idAssertion {
|
|
||||||
return "/\(step.index)[\(idAssertion)]"
|
|
||||||
}
|
|
||||||
return "/\(step.index)"
|
|
||||||
}.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func canonicalRangeComponents(for range: RDEPUBCFIRange) -> (
|
|
||||||
parent: RDEPUBCFI,
|
|
||||||
start: RDEPUBCFI,
|
|
||||||
end: RDEPUBCFI
|
|
||||||
) {
|
|
||||||
if let parent = range.parent {
|
|
||||||
return (parent, range.start, range.end)
|
|
||||||
}
|
|
||||||
|
|
||||||
let sharedPackage = range.start.packagePath.commonPrefix(with: range.end.packagePath)
|
|
||||||
let sharedContent = range.start.contentPath.commonPrefix(with: range.end.contentPath)
|
|
||||||
let parent = RDEPUBCFI(
|
|
||||||
packagePath: sharedPackage,
|
|
||||||
contentPath: sharedContent,
|
|
||||||
characterOffset: nil,
|
|
||||||
sideBias: nil,
|
|
||||||
textAssertion: nil
|
|
||||||
)
|
|
||||||
return (parent, range.start, range.end)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func serializeRangeParent(_ parent: RDEPUBCFI) -> String {
|
|
||||||
var body = serializePath(parent.packagePath)
|
|
||||||
let content = serializePath(parent.contentPath)
|
|
||||||
if !content.isEmpty {
|
|
||||||
body += "!" + content
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func serializeRangeTerminal(
|
|
||||||
_ cfi: RDEPUBCFI,
|
|
||||||
relativeToPackagePath parentPackagePath: RDEPUBCFIPath,
|
|
||||||
contentPrefix: RDEPUBCFIPath
|
|
||||||
) -> String {
|
|
||||||
let relativePackage = cfi.packagePath.droppingPrefix(parentPackagePath)
|
|
||||||
let relativeContent = cfi.contentPath.droppingPrefix(contentPrefix)
|
|
||||||
|
|
||||||
var body = serializePath(relativePackage)
|
|
||||||
let content = serializePath(relativeContent)
|
|
||||||
if !content.isEmpty || cfi.characterOffset != nil || !relativePackage.steps.isEmpty {
|
|
||||||
body += content
|
|
||||||
}
|
|
||||||
if let characterOffset = cfi.characterOffset {
|
|
||||||
body += ":\(max(characterOffset, 0))"
|
|
||||||
}
|
|
||||||
if let sideBias = cfi.sideBias {
|
|
||||||
body += ";s=\(sideBias.rawValue)"
|
|
||||||
}
|
|
||||||
if let assertion = cfi.textAssertion {
|
|
||||||
body += "[\(assertion.prefix ?? ""),\(assertion.exact ?? ""),\(assertion.suffix ?? "")]"
|
|
||||||
}
|
|
||||||
return body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBCFITextAssertion: Codable, Equatable, Hashable {
|
|
||||||
|
|
||||||
public var prefix: String?
|
|
||||||
|
|
||||||
public var exact: String?
|
|
||||||
|
|
||||||
public var suffix: String?
|
|
||||||
|
|
||||||
public init(prefix: String? = nil, exact: String? = nil, suffix: String? = nil) {
|
|
||||||
self.prefix = Self.trimmedOrNil(prefix)
|
|
||||||
self.exact = Self.trimmedOrNil(exact)
|
|
||||||
self.suffix = Self.trimmedOrNil(suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func trimmedOrNil(_ value: String?) -> String? {
|
|
||||||
guard let value else { return nil }
|
|
||||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
internal extension String {
|
|
||||||
var nilIfEmpty: String? {
|
|
||||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-333
@@ -1,333 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.RDEpubReader.highlight")
|
|
||||||
|
|
||||||
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.RDEpubReader.underline")
|
|
||||||
|
|
||||||
public struct RDEPUBSelection: Codable, Equatable {
|
|
||||||
|
|
||||||
public var bookIdentifier: String?
|
|
||||||
|
|
||||||
public var location: RDEPUBLocation
|
|
||||||
|
|
||||||
public var text: String
|
|
||||||
|
|
||||||
public var rangeInfo: String?
|
|
||||||
|
|
||||||
public var createdAt: Date
|
|
||||||
|
|
||||||
public init(
|
|
||||||
bookIdentifier: String? = nil,
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
text: String,
|
|
||||||
rangeInfo: String? = nil,
|
|
||||||
createdAt: Date = Date()
|
|
||||||
) {
|
|
||||||
self.bookIdentifier = bookIdentifier
|
|
||||||
self.location = location
|
|
||||||
self.text = text
|
|
||||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
public var isEmpty: Bool {
|
|
||||||
text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBHighlightStyle: String, Codable {
|
|
||||||
case highlight
|
|
||||||
case underline
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBAnnotationKind: String, Codable, Equatable {
|
|
||||||
case bookmark
|
|
||||||
case highlight
|
|
||||||
case underline
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBAnnotationMenuAction: Equatable {
|
|
||||||
case copy
|
|
||||||
case highlight
|
|
||||||
case annotate
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBTextOffsetRangeInfo: Codable, Equatable {
|
|
||||||
|
|
||||||
public var kind: String
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var start: Int
|
|
||||||
|
|
||||||
public var end: Int
|
|
||||||
|
|
||||||
public init(href: String, start: Int, end: Int) {
|
|
||||||
self.kind = "text-offset"
|
|
||||||
self.href = href
|
|
||||||
self.start = start
|
|
||||||
self.end = end
|
|
||||||
}
|
|
||||||
|
|
||||||
public var nsRange: NSRange? {
|
|
||||||
guard end > start else { return nil }
|
|
||||||
return NSRange(location: start, length: end - start)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func jsonString() -> String? {
|
|
||||||
guard let data = try? JSONEncoder().encode(self) else { return nil }
|
|
||||||
return String(data: data, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func decode(from string: String?) -> RDEPUBTextOffsetRangeInfo? {
|
|
||||||
guard let string,
|
|
||||||
let data = string.data(using: .utf8),
|
|
||||||
let rangeInfo = try? JSONDecoder().decode(RDEPUBTextOffsetRangeInfo.self, from: data),
|
|
||||||
rangeInfo.kind == "text-offset",
|
|
||||||
rangeInfo.end > rangeInfo.start else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return rangeInfo
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBHighlight: Codable, Equatable {
|
|
||||||
|
|
||||||
public var id: String
|
|
||||||
|
|
||||||
public var bookIdentifier: String?
|
|
||||||
|
|
||||||
public var location: RDEPUBLocation
|
|
||||||
|
|
||||||
public var text: String
|
|
||||||
|
|
||||||
public var rangeInfo: String?
|
|
||||||
|
|
||||||
public var style: RDEPUBHighlightStyle
|
|
||||||
|
|
||||||
public var color: String
|
|
||||||
|
|
||||||
public var note: String?
|
|
||||||
|
|
||||||
public var createdAt: Date
|
|
||||||
|
|
||||||
public init(
|
|
||||||
id: String = UUID().uuidString,
|
|
||||||
bookIdentifier: String? = nil,
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
text: String,
|
|
||||||
rangeInfo: String? = nil,
|
|
||||||
style: RDEPUBHighlightStyle = .highlight,
|
|
||||||
color: String = "#F8E16C",
|
|
||||||
note: String? = nil,
|
|
||||||
createdAt: Date = Date()
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.bookIdentifier = bookIdentifier
|
|
||||||
self.location = location
|
|
||||||
self.text = text
|
|
||||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
||||||
self.style = style
|
|
||||||
self.color = color
|
|
||||||
self.note = note?.nilIfEmpty
|
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
public var hasNote: Bool {
|
|
||||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
||||||
}
|
|
||||||
|
|
||||||
public var uiColor: UIColor {
|
|
||||||
UIColor(rdHexString: color, alpha: 0.35)
|
|
||||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case id
|
|
||||||
case bookIdentifier
|
|
||||||
case location
|
|
||||||
case text
|
|
||||||
case rangeInfo
|
|
||||||
case style
|
|
||||||
case color
|
|
||||||
case note
|
|
||||||
case createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
id = try container.decode(String.self, forKey: .id)
|
|
||||||
bookIdentifier = try container.decodeIfPresent(String.self, forKey: .bookIdentifier)
|
|
||||||
location = try container.decode(RDEPUBLocation.self, forKey: .location)
|
|
||||||
text = try container.decode(String.self, forKey: .text)
|
|
||||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
|
||||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
|
||||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
|
||||||
style = decodedStyle
|
|
||||||
} else {
|
|
||||||
style = .highlight
|
|
||||||
}
|
|
||||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
|
||||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
|
||||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(id, forKey: .id)
|
|
||||||
try container.encodeIfPresent(bookIdentifier, forKey: .bookIdentifier)
|
|
||||||
try container.encode(location, forKey: .location)
|
|
||||||
try container.encode(text, forKey: .text)
|
|
||||||
try container.encodeIfPresent(rangeInfo, forKey: .rangeInfo)
|
|
||||||
try container.encode(style, forKey: .style)
|
|
||||||
try container.encode(color, forKey: .color)
|
|
||||||
try container.encodeIfPresent(note, forKey: .note)
|
|
||||||
try container.encode(createdAt, forKey: .createdAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var annotation: RDEPUBAnnotation {
|
|
||||||
RDEPUBAnnotation(highlight: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBBookmark: Codable, Equatable {
|
|
||||||
|
|
||||||
public var id: String
|
|
||||||
|
|
||||||
public var bookIdentifier: String?
|
|
||||||
|
|
||||||
public var location: RDEPUBLocation
|
|
||||||
|
|
||||||
public var rangeInfo: String?
|
|
||||||
|
|
||||||
public var chapterTitle: String?
|
|
||||||
|
|
||||||
public var note: String?
|
|
||||||
|
|
||||||
public var createdAt: Date
|
|
||||||
|
|
||||||
public init(
|
|
||||||
id: String = UUID().uuidString,
|
|
||||||
bookIdentifier: String? = nil,
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
rangeInfo: String? = nil,
|
|
||||||
chapterTitle: String? = nil,
|
|
||||||
note: String? = nil,
|
|
||||||
createdAt: Date = Date()
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.bookIdentifier = bookIdentifier
|
|
||||||
self.location = location
|
|
||||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
||||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
|
||||||
self.note = note?.nilIfEmpty
|
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
public var hasNote: Bool {
|
|
||||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
|
||||||
}
|
|
||||||
|
|
||||||
public var annotation: RDEPUBAnnotation {
|
|
||||||
RDEPUBAnnotation(bookmark: self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBAnnotation: Codable, Equatable {
|
|
||||||
public var id: String
|
|
||||||
public var bookIdentifier: String?
|
|
||||||
public var kind: RDEPUBAnnotationKind
|
|
||||||
public var location: RDEPUBLocation
|
|
||||||
public var text: String?
|
|
||||||
public var rangeInfo: String?
|
|
||||||
public var color: String?
|
|
||||||
public var chapterTitle: String?
|
|
||||||
public var note: String?
|
|
||||||
public var createdAt: Date
|
|
||||||
|
|
||||||
public init(
|
|
||||||
id: String = UUID().uuidString,
|
|
||||||
bookIdentifier: String? = nil,
|
|
||||||
kind: RDEPUBAnnotationKind,
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
text: String? = nil,
|
|
||||||
rangeInfo: String? = nil,
|
|
||||||
color: String? = nil,
|
|
||||||
chapterTitle: String? = nil,
|
|
||||||
note: String? = nil,
|
|
||||||
createdAt: Date = Date()
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.bookIdentifier = bookIdentifier
|
|
||||||
self.kind = kind
|
|
||||||
self.location = location
|
|
||||||
self.text = text?.nilIfEmpty
|
|
||||||
self.rangeInfo = rangeInfo?.nilIfEmpty
|
|
||||||
self.color = color?.nilIfEmpty
|
|
||||||
self.chapterTitle = chapterTitle?.nilIfEmpty
|
|
||||||
self.note = note?.nilIfEmpty
|
|
||||||
self.createdAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(highlight: RDEPUBHighlight) {
|
|
||||||
self.init(
|
|
||||||
id: highlight.id,
|
|
||||||
bookIdentifier: highlight.bookIdentifier,
|
|
||||||
kind: highlight.style == .underline ? .underline : .highlight,
|
|
||||||
location: highlight.location,
|
|
||||||
text: highlight.text,
|
|
||||||
rangeInfo: highlight.rangeInfo,
|
|
||||||
color: highlight.color,
|
|
||||||
chapterTitle: nil,
|
|
||||||
note: highlight.note,
|
|
||||||
createdAt: highlight.createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(bookmark: RDEPUBBookmark) {
|
|
||||||
self.init(
|
|
||||||
id: bookmark.id,
|
|
||||||
bookIdentifier: bookmark.bookIdentifier,
|
|
||||||
kind: .bookmark,
|
|
||||||
location: bookmark.location,
|
|
||||||
text: nil,
|
|
||||||
rangeInfo: bookmark.rangeInfo,
|
|
||||||
color: nil,
|
|
||||||
chapterTitle: bookmark.chapterTitle,
|
|
||||||
note: bookmark.note,
|
|
||||||
createdAt: bookmark.createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var bookmark: RDEPUBBookmark? {
|
|
||||||
guard kind == .bookmark else { return nil }
|
|
||||||
return RDEPUBBookmark(
|
|
||||||
id: id,
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
location: location,
|
|
||||||
rangeInfo: rangeInfo,
|
|
||||||
chapterTitle: chapterTitle,
|
|
||||||
note: note,
|
|
||||||
createdAt: createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var highlight: RDEPUBHighlight? {
|
|
||||||
guard kind == .highlight || kind == .underline,
|
|
||||||
let text else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return RDEPUBHighlight(
|
|
||||||
id: id,
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
location: location,
|
|
||||||
text: text,
|
|
||||||
rangeInfo: rangeInfo,
|
|
||||||
style: kind == .underline ? .underline : .highlight,
|
|
||||||
color: color ?? "#F8E16C",
|
|
||||||
note: note,
|
|
||||||
createdAt: createdAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-206
@@ -1,206 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBTextPageBreakReason: String, Codable, Equatable {
|
|
||||||
|
|
||||||
case chapterEnd
|
|
||||||
|
|
||||||
case frameLimit
|
|
||||||
|
|
||||||
case blockBoundary
|
|
||||||
|
|
||||||
case attachmentBoundary
|
|
||||||
|
|
||||||
case semanticBoundary
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBTextAttachmentKind: String, Codable, Equatable {
|
|
||||||
|
|
||||||
case image
|
|
||||||
|
|
||||||
case generic
|
|
||||||
|
|
||||||
case footnote
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBTextPageMetadata: Codable, Equatable {
|
|
||||||
|
|
||||||
public var breakReason: RDEPUBTextPageBreakReason
|
|
||||||
|
|
||||||
public var blockRange: NSRange?
|
|
||||||
|
|
||||||
public var attachmentRanges: [NSRange]
|
|
||||||
|
|
||||||
public var attachmentKinds: [RDEPUBTextAttachmentKind]
|
|
||||||
|
|
||||||
public var blockKinds: [RDEPUBTextBlockKind]
|
|
||||||
|
|
||||||
public var semanticHints: [RDEPUBTextSemanticHint]
|
|
||||||
|
|
||||||
public var attachmentPlacements: [RDEPUBTextAttachmentPlacement]
|
|
||||||
|
|
||||||
public var trailingFragmentID: String?
|
|
||||||
|
|
||||||
public var diagnostics: [String]
|
|
||||||
|
|
||||||
public init(
|
|
||||||
breakReason: RDEPUBTextPageBreakReason,
|
|
||||||
blockRange: NSRange? = nil,
|
|
||||||
attachmentRanges: [NSRange] = [],
|
|
||||||
attachmentKinds: [RDEPUBTextAttachmentKind] = [],
|
|
||||||
blockKinds: [RDEPUBTextBlockKind] = [],
|
|
||||||
semanticHints: [RDEPUBTextSemanticHint] = [],
|
|
||||||
attachmentPlacements: [RDEPUBTextAttachmentPlacement] = [],
|
|
||||||
trailingFragmentID: String? = nil,
|
|
||||||
diagnostics: [String] = []
|
|
||||||
) {
|
|
||||||
self.breakReason = breakReason
|
|
||||||
self.blockRange = blockRange
|
|
||||||
self.attachmentRanges = attachmentRanges
|
|
||||||
self.attachmentKinds = attachmentKinds
|
|
||||||
self.blockKinds = blockKinds
|
|
||||||
self.semanticHints = semanticHints
|
|
||||||
self.attachmentPlacements = attachmentPlacements
|
|
||||||
self.trailingFragmentID = trailingFragmentID
|
|
||||||
self.diagnostics = diagnostics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct EPUBChapterInfo: Codable, Equatable {
|
|
||||||
|
|
||||||
public var spineIndex: Int
|
|
||||||
|
|
||||||
public var title: String
|
|
||||||
|
|
||||||
public var pageCount: Int
|
|
||||||
|
|
||||||
public init(spineIndex: Int, title: String, pageCount: Int) {
|
|
||||||
self.spineIndex = spineIndex
|
|
||||||
self.title = title
|
|
||||||
self.pageCount = pageCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct EPUBPage: Codable, Equatable {
|
|
||||||
|
|
||||||
public var spineIndex: Int
|
|
||||||
|
|
||||||
public var chapterIndex: Int
|
|
||||||
|
|
||||||
public var pageIndexInChapter: Int
|
|
||||||
|
|
||||||
public var totalPagesInChapter: Int
|
|
||||||
|
|
||||||
public var chapterTitle: String
|
|
||||||
|
|
||||||
public var fixedSpread: EPUBFixedSpread?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
spineIndex: Int,
|
|
||||||
chapterIndex: Int,
|
|
||||||
pageIndexInChapter: Int,
|
|
||||||
totalPagesInChapter: Int,
|
|
||||||
chapterTitle: String,
|
|
||||||
fixedSpread: EPUBFixedSpread?
|
|
||||||
) {
|
|
||||||
self.spineIndex = spineIndex
|
|
||||||
self.chapterIndex = chapterIndex
|
|
||||||
self.pageIndexInChapter = pageIndexInChapter
|
|
||||||
self.totalPagesInChapter = totalPagesInChapter
|
|
||||||
self.chapterTitle = chapterTitle
|
|
||||||
self.fixedSpread = fixedSpread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct EPUBFixedSpreadResource: Codable, Equatable {
|
|
||||||
|
|
||||||
public var spineIndex: Int
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var title: String
|
|
||||||
|
|
||||||
public var pageSpread: RDEPUBPageSpread?
|
|
||||||
|
|
||||||
public init(spineIndex: Int, href: String, title: String, pageSpread: RDEPUBPageSpread? = nil) {
|
|
||||||
self.spineIndex = spineIndex
|
|
||||||
self.href = href
|
|
||||||
self.title = title
|
|
||||||
self.pageSpread = pageSpread
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct EPUBFixedSpread: Codable, Equatable {
|
|
||||||
|
|
||||||
public var resources: [EPUBFixedSpreadResource]
|
|
||||||
|
|
||||||
public init(resources: [EPUBFixedSpreadResource]) {
|
|
||||||
self.resources = resources
|
|
||||||
}
|
|
||||||
|
|
||||||
public var primaryResource: EPUBFixedSpreadResource {
|
|
||||||
resources.first ?? EPUBFixedSpreadResource(spineIndex: 0, href: "", title: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
public func contains(normalizedHref: String, normalizer: (String) -> String?) -> Bool {
|
|
||||||
resources.contains { resource in
|
|
||||||
normalizer(resource.href) == normalizedHref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func makeSpreads(spine: [RDEPUBSpineItem], spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
|
||||||
let resources = spine.enumerated().map { index, item in
|
|
||||||
EPUBFixedSpreadResource(
|
|
||||||
spineIndex: index,
|
|
||||||
href: item.href,
|
|
||||||
title: item.title,
|
|
||||||
pageSpread: item.pageSpread
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard spreadEnabled, resources.count > 1 else {
|
|
||||||
return resources.map { EPUBFixedSpread(resources: [$0]) }
|
|
||||||
}
|
|
||||||
|
|
||||||
var spreads: [EPUBFixedSpread] = []
|
|
||||||
var cursor = 0
|
|
||||||
|
|
||||||
while cursor < resources.count {
|
|
||||||
let current = resources[cursor]
|
|
||||||
|
|
||||||
guard cursor + 1 < resources.count else {
|
|
||||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = resources[cursor + 1]
|
|
||||||
if shouldPair(current: current, next: next) {
|
|
||||||
spreads.append(EPUBFixedSpread(resources: [current, next]))
|
|
||||||
cursor += 2
|
|
||||||
} else {
|
|
||||||
spreads.append(EPUBFixedSpread(resources: [current]))
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spreads
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func makeSpreads(parser: RDEPUBParser, spreadEnabled: Bool) -> [EPUBFixedSpread] {
|
|
||||||
makeSpreads(spine: parser.spine, spreadEnabled: spreadEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func shouldPair(current: EPUBFixedSpreadResource, next: EPUBFixedSpreadResource) -> Bool {
|
|
||||||
if current.pageSpread == .center || next.pageSpread == .center {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (current.pageSpread, next.pageSpread) {
|
|
||||||
case (.left, .right), (.right, .left):
|
|
||||||
return true
|
|
||||||
case (.left, .left), (.right, .right):
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-130
@@ -1,130 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBLocation: Codable, Equatable {
|
|
||||||
|
|
||||||
public var bookIdentifier: String?
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var progression: Double
|
|
||||||
|
|
||||||
public var lastProgression: Double?
|
|
||||||
|
|
||||||
public var fragment: String?
|
|
||||||
|
|
||||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
|
||||||
|
|
||||||
public var cfi: String?
|
|
||||||
|
|
||||||
public var lastCFI: String?
|
|
||||||
|
|
||||||
public var rangeCFI: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
bookIdentifier: String? = nil,
|
|
||||||
href: String,
|
|
||||||
progression: Double,
|
|
||||||
lastProgression: Double? = nil,
|
|
||||||
fragment: String? = nil,
|
|
||||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
|
||||||
cfi: String? = nil,
|
|
||||||
lastCFI: String? = nil,
|
|
||||||
rangeCFI: String? = nil
|
|
||||||
) {
|
|
||||||
self.bookIdentifier = bookIdentifier
|
|
||||||
self.href = href
|
|
||||||
self.progression = Self.clamp(progression)
|
|
||||||
self.lastProgression = lastProgression.map(Self.clamp)
|
|
||||||
self.fragment = fragment?.nilIfEmpty
|
|
||||||
self.rangeAnchor = rangeAnchor
|
|
||||||
self.cfi = cfi?.nilIfEmpty
|
|
||||||
self.lastCFI = lastCFI?.nilIfEmpty
|
|
||||||
self.rangeCFI = rangeCFI?.nilIfEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
public var navigationProgression: Double {
|
|
||||||
let end = lastProgression ?? progression
|
|
||||||
if end.isNaN || end.isInfinite {
|
|
||||||
return progression
|
|
||||||
}
|
|
||||||
return Self.clamp((progression + end) / 2.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func clamp(_ value: Double) -> Double {
|
|
||||||
guard value.isFinite else { return 0 }
|
|
||||||
return min(1, max(0, value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBViewportResource: Codable, Equatable {
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var spineIndex: Int
|
|
||||||
|
|
||||||
public var progression: Double
|
|
||||||
|
|
||||||
public var lastProgression: Double?
|
|
||||||
|
|
||||||
public var fragment: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
href: String,
|
|
||||||
spineIndex: Int,
|
|
||||||
progression: Double,
|
|
||||||
lastProgression: Double? = nil,
|
|
||||||
fragment: String? = nil
|
|
||||||
) {
|
|
||||||
self.href = href
|
|
||||||
self.spineIndex = spineIndex
|
|
||||||
self.progression = progression
|
|
||||||
self.lastProgression = lastProgression
|
|
||||||
self.fragment = fragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBViewport: Codable, Equatable {
|
|
||||||
|
|
||||||
public var resources: [RDEPUBViewportResource]
|
|
||||||
|
|
||||||
public var visiblePageNumber: Int
|
|
||||||
|
|
||||||
public var chapterIndex: Int?
|
|
||||||
|
|
||||||
public var isFixedLayout: Bool
|
|
||||||
|
|
||||||
public init(
|
|
||||||
resources: [RDEPUBViewportResource],
|
|
||||||
visiblePageNumber: Int,
|
|
||||||
chapterIndex: Int? = nil,
|
|
||||||
isFixedLayout: Bool
|
|
||||||
) {
|
|
||||||
self.resources = resources
|
|
||||||
self.visiblePageNumber = visiblePageNumber
|
|
||||||
self.chapterIndex = chapterIndex
|
|
||||||
self.isFixedLayout = isFixedLayout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBReadingContext: Codable, Equatable {
|
|
||||||
|
|
||||||
public var location: RDEPUBLocation
|
|
||||||
|
|
||||||
public var viewport: RDEPUBViewport
|
|
||||||
|
|
||||||
public var pageNumber: Int
|
|
||||||
|
|
||||||
public var chapterIndex: Int?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
viewport: RDEPUBViewport,
|
|
||||||
pageNumber: Int,
|
|
||||||
chapterIndex: Int? = nil
|
|
||||||
) {
|
|
||||||
self.location = location
|
|
||||||
self.viewport = viewport
|
|
||||||
self.pageNumber = pageNumber
|
|
||||||
self.chapterIndex = chapterIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBNoteDetector {
|
|
||||||
|
|
||||||
private static let noteTokens = [
|
|
||||||
"noteref",
|
|
||||||
"footnote",
|
|
||||||
"endnote",
|
|
||||||
"rearnote",
|
|
||||||
"fnref",
|
|
||||||
"fn-",
|
|
||||||
"fn_",
|
|
||||||
"note",
|
|
||||||
"annotation"
|
|
||||||
]
|
|
||||||
|
|
||||||
public static func makeReferenceIfLikelyNote(
|
|
||||||
sourceHref: String,
|
|
||||||
sourceCFI: String?,
|
|
||||||
targetHref: String,
|
|
||||||
targetFragment: String?,
|
|
||||||
targetCFI: String?,
|
|
||||||
targetElementHTML: String?
|
|
||||||
) -> RDEPUBNoteReference? {
|
|
||||||
|
|
||||||
let haystack = [
|
|
||||||
targetHref,
|
|
||||||
targetFragment ?? "",
|
|
||||||
targetElementHTML ?? ""
|
|
||||||
].joined(separator: " ").lowercased()
|
|
||||||
|
|
||||||
guard noteTokens.contains(where: { haystack.contains($0) }) ||
|
|
||||||
hasEPUBType("footnote", in: targetElementHTML) ||
|
|
||||||
hasEPUBType("endnote", in: targetElementHTML) ||
|
|
||||||
hasEPUBType("rearnote", in: targetElementHTML) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBNoteReference(
|
|
||||||
sourceHref: sourceHref,
|
|
||||||
sourceCFI: sourceCFI,
|
|
||||||
targetHref: targetHref,
|
|
||||||
targetFragment: targetFragment,
|
|
||||||
targetCFI: targetCFI,
|
|
||||||
label: targetFragment,
|
|
||||||
kind: kind(from: haystack)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func kind(from text: String) -> RDEPUBNoteKind {
|
|
||||||
let lowered = text.lowercased()
|
|
||||||
if lowered.contains("endnote") { return .endnote }
|
|
||||||
if lowered.contains("rearnote") { return .rearnote }
|
|
||||||
if lowered.contains("footnote") || lowered.contains("fn") { return .footnote }
|
|
||||||
return .unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func hasEPUBType(_ type: String, in html: String?) -> Bool {
|
|
||||||
guard let html else { return false }
|
|
||||||
let pattern = #"epub:type\s*=\s*['"][^'"]*\b"# + NSRegularExpression.escapedPattern(for: type) + #"\b[^'"]*['"]"#
|
|
||||||
return html.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBNoteKind: String, Codable, Equatable {
|
|
||||||
|
|
||||||
case footnote
|
|
||||||
|
|
||||||
case endnote
|
|
||||||
|
|
||||||
case rearnote
|
|
||||||
|
|
||||||
case unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBNoteReference: Codable, Equatable {
|
|
||||||
|
|
||||||
public var sourceHref: String
|
|
||||||
|
|
||||||
public var sourceCFI: String?
|
|
||||||
|
|
||||||
public var targetHref: String
|
|
||||||
|
|
||||||
public var targetFragment: String?
|
|
||||||
|
|
||||||
public var targetCFI: String?
|
|
||||||
|
|
||||||
public var label: String?
|
|
||||||
|
|
||||||
public var kind: RDEPUBNoteKind
|
|
||||||
|
|
||||||
public init(
|
|
||||||
sourceHref: String,
|
|
||||||
sourceCFI: String? = nil,
|
|
||||||
targetHref: String,
|
|
||||||
targetFragment: String? = nil,
|
|
||||||
targetCFI: String? = nil,
|
|
||||||
label: String? = nil,
|
|
||||||
kind: RDEPUBNoteKind = .unknown
|
|
||||||
) {
|
|
||||||
self.sourceHref = sourceHref
|
|
||||||
self.sourceCFI = sourceCFI
|
|
||||||
self.targetHref = targetHref
|
|
||||||
self.targetFragment = targetFragment
|
|
||||||
self.targetCFI = targetCFI
|
|
||||||
self.label = label?.rd_nilIfEmpty
|
|
||||||
self.kind = kind
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBResolvedNote: Equatable {
|
|
||||||
|
|
||||||
public var reference: RDEPUBNoteReference
|
|
||||||
|
|
||||||
public var sourceLocation: RDEPUBLocation?
|
|
||||||
|
|
||||||
public var targetLocation: RDEPUBLocation
|
|
||||||
|
|
||||||
public var title: String?
|
|
||||||
|
|
||||||
public var html: String
|
|
||||||
|
|
||||||
public var plainText: String
|
|
||||||
|
|
||||||
public init(
|
|
||||||
reference: RDEPUBNoteReference,
|
|
||||||
sourceLocation: RDEPUBLocation? = nil,
|
|
||||||
targetLocation: RDEPUBLocation,
|
|
||||||
title: String? = nil,
|
|
||||||
html: String,
|
|
||||||
plainText: String
|
|
||||||
) {
|
|
||||||
self.reference = reference
|
|
||||||
self.sourceLocation = sourceLocation
|
|
||||||
self.targetLocation = targetLocation
|
|
||||||
self.title = title?.rd_nilIfEmpty
|
|
||||||
self.html = html
|
|
||||||
self.plainText = plainText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-168
@@ -1,168 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public final class RDEPUBNoteResolver {
|
|
||||||
|
|
||||||
private let resourceResolver: RDEPUBResourceResolver
|
|
||||||
|
|
||||||
public init(resourceResolver: RDEPUBResourceResolver) {
|
|
||||||
self.resourceResolver = resourceResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
public func resolveInternalLink(
|
|
||||||
sourceHref: String,
|
|
||||||
sourceCFI: String?,
|
|
||||||
targetLocation: RDEPUBLocation,
|
|
||||||
sourceLocation: RDEPUBLocation? = nil,
|
|
||||||
relativeToSpineIndex spineIndex: Int?
|
|
||||||
) -> RDEPUBResolvedNote? {
|
|
||||||
guard let normalizedTarget = resourceResolver.normalizedLocation(
|
|
||||||
targetLocation,
|
|
||||||
relativeToSpineIndex: spineIndex,
|
|
||||||
bookIdentifier: targetLocation.bookIdentifier
|
|
||||||
) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let fragment = normalizedTarget.fragment
|
|
||||||
guard let elementHTML = extractElementHTML(href: normalizedTarget.href, fragment: fragment) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let reference = RDEPUBNoteDetector.makeReferenceIfLikelyNote(
|
|
||||||
sourceHref: sourceHref,
|
|
||||||
sourceCFI: sourceCFI,
|
|
||||||
targetHref: normalizedTarget.href,
|
|
||||||
targetFragment: fragment,
|
|
||||||
targetCFI: normalizedTarget.cfi,
|
|
||||||
targetElementHTML: elementHTML
|
|
||||||
) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let sanitizedHTML = stripBacklinks(from: elementHTML)
|
|
||||||
let plainText = plainText(from: sanitizedHTML)
|
|
||||||
guard !plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBResolvedNote(
|
|
||||||
reference: reference,
|
|
||||||
sourceLocation: sourceLocation,
|
|
||||||
targetLocation: normalizedTarget,
|
|
||||||
title: title(for: reference),
|
|
||||||
html: sanitizedHTML,
|
|
||||||
plainText: plainText
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func extractElementHTML(href: String, fragment: String?) -> String? {
|
|
||||||
guard let fragment,
|
|
||||||
!fragment.isEmpty,
|
|
||||||
let fileURL = resourceResolver.fileURL(forRelativePath: href),
|
|
||||||
let html = chapterHTML(at: fileURL) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return elementHTML(withID: fragment, in: html)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取章节 HTML:优先经加密资源 provider 解密,明文文件保持原直读路径
|
|
||||||
private func chapterHTML(at fileURL: URL) -> String? {
|
|
||||||
if let provided = resourceResolver.providedResourceData(at: fileURL) {
|
|
||||||
return String(data: provided, encoding: .utf8)
|
|
||||||
?? String(data: provided, encoding: .utf16)
|
|
||||||
}
|
|
||||||
return try? String(contentsOf: fileURL, encoding: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func elementHTML(withID id: String, in html: String) -> String? {
|
|
||||||
let escapedID = NSRegularExpression.escapedPattern(for: id)
|
|
||||||
let pattern = #"<([A-Za-z][A-Za-z0-9:_-]*)(?=[^>]*(?:id|xml:id)\s*=\s*(['"])"# + escapedID + #"\2)[^>]*>"#
|
|
||||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
|
||||||
guard let match = regex.firstMatch(in: html, options: [], range: nsRange),
|
|
||||||
let startRange = Range(match.range, in: html),
|
|
||||||
let tagRange = Range(match.range(at: 1), in: html) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let tagName = String(html[tagRange])
|
|
||||||
guard !String(html[startRange]).hasSuffix("/>") else {
|
|
||||||
return String(html[startRange])
|
|
||||||
}
|
|
||||||
guard let endRange = matchingEndTagRange(
|
|
||||||
tagName: tagName,
|
|
||||||
in: html,
|
|
||||||
after: startRange.upperBound
|
|
||||||
) else {
|
|
||||||
return String(html[startRange])
|
|
||||||
}
|
|
||||||
return String(html[startRange.lowerBound..<endRange.upperBound])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stripBacklinks(from html: String) -> String {
|
|
||||||
let backlinkPattern = #"<a\b[^>]*(?:rev\s*=\s*['"]footnote['"]|epub:type\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"]|class\s*=\s*['"][^'"]*(?:backlink|return)[^'"]*['"])[^>]*>[\s\S]*?</a>"#
|
|
||||||
guard let regex = try? NSRegularExpression(pattern: backlinkPattern, options: [.caseInsensitive]) else {
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
let nsRange = NSRange(html.startIndex..<html.endIndex, in: html)
|
|
||||||
return regex.stringByReplacingMatches(in: html, options: [], range: nsRange, withTemplate: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func plainText(from html: String) -> String {
|
|
||||||
let withoutScripts = html
|
|
||||||
.replacingOccurrences(of: #"<script[\s\S]*?</script>"#, with: "", options: .regularExpression)
|
|
||||||
.replacingOccurrences(of: #"<style[\s\S]*?</style>"#, with: "", options: .regularExpression)
|
|
||||||
let withoutTags = withoutScripts.replacingOccurrences(of: #"<[^>]+>"#, with: " ", options: .regularExpression)
|
|
||||||
return withoutTags
|
|
||||||
.replacingOccurrences(of: " ", with: " ")
|
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
|
||||||
.components(separatedBy: .whitespacesAndNewlines)
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func title(for reference: RDEPUBNoteReference) -> String {
|
|
||||||
switch reference.kind {
|
|
||||||
case .footnote:
|
|
||||||
return "脚注"
|
|
||||||
case .endnote:
|
|
||||||
return "尾注"
|
|
||||||
case .rearnote:
|
|
||||||
return "后注"
|
|
||||||
case .unknown:
|
|
||||||
return "注释"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func matchingEndTagRange(
|
|
||||||
tagName: String,
|
|
||||||
in html: String,
|
|
||||||
after startIndex: String.Index
|
|
||||||
) -> Range<String.Index>? {
|
|
||||||
let escapedTagName = NSRegularExpression.escapedPattern(for: tagName)
|
|
||||||
let pattern = #"</?\s*"# + escapedTagName + #"\b[^>]*>"#
|
|
||||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let searchRange = NSRange(startIndex..<html.endIndex, in: html)
|
|
||||||
var depth = 1
|
|
||||||
for match in regex.matches(in: html, options: [], range: searchRange) {
|
|
||||||
guard let range = Range(match.range, in: html) else { continue }
|
|
||||||
let tag = String(html[range])
|
|
||||||
if tag.hasPrefix("</") {
|
|
||||||
depth -= 1
|
|
||||||
} else if !tag.hasSuffix("/>") {
|
|
||||||
depth += 1
|
|
||||||
}
|
|
||||||
if depth == 0 {
|
|
||||||
return range
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum RDEPUBAsset: String {
|
|
||||||
|
|
||||||
case rangyCoreScript = "rangy-core"
|
|
||||||
|
|
||||||
case rangySerializerScript = "rangy-serializer"
|
|
||||||
|
|
||||||
case cssInjectorScript = "cssInjector"
|
|
||||||
|
|
||||||
case weReadAPIScript = "WeReadApi"
|
|
||||||
|
|
||||||
case bridgeScript = "epub-bridge"
|
|
||||||
|
|
||||||
case fixedLayoutTemplate = "epub-fixed-layout"
|
|
||||||
|
|
||||||
case wxReadDefaultCSS = "wxread-default"
|
|
||||||
|
|
||||||
case wxReadReplaceCSS = "wxread-replace"
|
|
||||||
|
|
||||||
case wxReadDarkCSS = "wxread-dark"
|
|
||||||
|
|
||||||
case wxReadLatinReplaceCSS = "wxread-replace-latin"
|
|
||||||
|
|
||||||
var fileExtension: String {
|
|
||||||
switch self {
|
|
||||||
case .rangyCoreScript, .rangySerializerScript, .cssInjectorScript, .weReadAPIScript, .bridgeScript:
|
|
||||||
return "js"
|
|
||||||
case .fixedLayoutTemplate:
|
|
||||||
return "html"
|
|
||||||
case .wxReadDefaultCSS, .wxReadReplaceCSS, .wxReadDarkCSS, .wxReadLatinReplaceCSS:
|
|
||||||
return "css"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RDEPUBAssetRepository {
|
|
||||||
|
|
||||||
static func string(for asset: RDEPUBAsset, replacements: [String: String] = [:]) -> String {
|
|
||||||
guard let url = resourceBundle.url(forResource: asset.rawValue, withExtension: asset.fileExtension),
|
|
||||||
var content = try? String(contentsOf: url, encoding: .utf8) else {
|
|
||||||
assertionFailure("Missing EPUB asset: \(asset.rawValue).\(asset.fileExtension)")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
for (token, value) in replacements {
|
|
||||||
content = content.replacingOccurrences(of: "{{\(token)}}", with: value)
|
|
||||||
}
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var resourceBundle: Bundle {
|
|
||||||
if let bundle = resolvedBundle {
|
|
||||||
return bundle
|
|
||||||
}
|
|
||||||
return Bundle(for: RDEPUBAssetBundleToken.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static var resolvedBundle: Bundle? = {
|
|
||||||
let hostBundles = [Bundle(for: RDEPUBAssetBundleToken.self), Bundle.main] + Bundle.allFrameworks + Bundle.allBundles
|
|
||||||
for hostBundle in hostBundles {
|
|
||||||
for bundleName in ["RDEpubReaderViewAssets", "RDReaderViewAssets"] {
|
|
||||||
if let url = hostBundle.url(forResource: bundleName, withExtension: "bundle"),
|
|
||||||
let bundle = Bundle(url: url) {
|
|
||||||
return bundle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class RDEPUBAssetBundleToken {}
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum RDEPUBFixedLayoutTemplate {
|
|
||||||
|
|
||||||
static func html(for request: RDEPUBFixedRenderRequest, publication: RDEPUBPublication) -> String {
|
|
||||||
|
|
||||||
let panes = request.spread.resources.enumerated().compactMap { index, resource -> String? in
|
|
||||||
guard let url = publication.resourceResolver.resourceURL(forRelativePath: resource.href)?.absoluteString else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let pageType: String
|
|
||||||
if request.spread.resources.count == 1 {
|
|
||||||
pageType = "single"
|
|
||||||
} else if request.spread.resources.count == 2 {
|
|
||||||
pageType = index == 0 ? "left" : "right"
|
|
||||||
} else {
|
|
||||||
pageType = "center"
|
|
||||||
}
|
|
||||||
|
|
||||||
return """
|
|
||||||
<div class=\"ss-fixed-viewport\" data-page-type=\"\(pageType)\">
|
|
||||||
<iframe class=\"ss-fixed-page\" data-page-type=\"\(pageType)\" scrolling=\"no\" src=\"\(url)\"></iframe>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
}.joined()
|
|
||||||
|
|
||||||
let background = request.backgroundColorCSS ?? "#FFFFFF"
|
|
||||||
let viewportWidth = max(1, Int((request.viewportSize.width - request.contentInset.left - request.contentInset.right).rounded(.down)))
|
|
||||||
let viewportHeight = max(1, Int((request.viewportSize.height - request.contentInset.top - request.contentInset.bottom).rounded(.down)))
|
|
||||||
let insetTop = Int(request.contentInset.top.rounded(.down))
|
|
||||||
let insetRight = Int(request.contentInset.right.rounded(.down))
|
|
||||||
let insetBottom = Int(request.contentInset.bottom.rounded(.down))
|
|
||||||
let insetLeft = Int(request.contentInset.left.rounded(.down))
|
|
||||||
|
|
||||||
return RDEPUBAssetRepository.string(
|
|
||||||
for: .fixedLayoutTemplate,
|
|
||||||
replacements: [
|
|
||||||
"BACKGROUND": background,
|
|
||||||
"INSET_TOP": String(insetTop),
|
|
||||||
"INSET_RIGHT": String(insetRight),
|
|
||||||
"INSET_BOTTOM": String(insetBottom),
|
|
||||||
"INSET_LEFT": String(insetLeft),
|
|
||||||
"VIEWPORT_WIDTH": String(viewportWidth),
|
|
||||||
"VIEWPORT_HEIGHT": String(viewportHeight),
|
|
||||||
"PANES": panes,
|
|
||||||
"FIT_MODE": request.fit.rawValue,
|
|
||||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-188
@@ -1,188 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum RDEPUBJavaScriptBridgeMessage: String, CaseIterable {
|
|
||||||
|
|
||||||
case progressionChanged = "ssReaderProgressionChanged"
|
|
||||||
|
|
||||||
case selectionChanged = "ssReaderSelectionChanged"
|
|
||||||
|
|
||||||
case internalLink = "ssReaderInternalLink"
|
|
||||||
|
|
||||||
case externalLink = "ssReaderExternalLink"
|
|
||||||
|
|
||||||
case javaScriptError = "ssReaderJSError"
|
|
||||||
|
|
||||||
case fixedLayoutReady = "ssReaderFixedLayoutReady"
|
|
||||||
|
|
||||||
case imageDidTap = "ssReaderImageDidTap"
|
|
||||||
|
|
||||||
case footnoteDidTap = "ssReaderFootnoteDidTap"
|
|
||||||
}
|
|
||||||
|
|
||||||
enum RDEPUBJavaScriptBridge {
|
|
||||||
|
|
||||||
static var messageNames: [String] {
|
|
||||||
RDEPUBJavaScriptBridgeMessage.allCases.map(\.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
static var userScript: String {
|
|
||||||
[
|
|
||||||
RDEPUBAssetRepository.string(for: .rangyCoreScript),
|
|
||||||
RDEPUBAssetRepository.string(for: .rangySerializerScript),
|
|
||||||
RDEPUBAssetRepository.string(for: .cssInjectorScript),
|
|
||||||
RDEPUBAssetRepository.string(for: .weReadAPIScript),
|
|
||||||
RDEPUBAssetRepository.string(
|
|
||||||
for: .bridgeScript,
|
|
||||||
replacements: [
|
|
||||||
"PROGRESSION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue,
|
|
||||||
"SELECTION_CHANGED_MESSAGE": RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue,
|
|
||||||
"INTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.internalLink.rawValue,
|
|
||||||
"EXTERNAL_LINK_MESSAGE": RDEPUBJavaScriptBridgeMessage.externalLink.rawValue,
|
|
||||||
"JAVASCRIPT_ERROR_MESSAGE": RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue,
|
|
||||||
"FIXED_LAYOUT_READY_MESSAGE": RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue,
|
|
||||||
"IMAGE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue,
|
|
||||||
"FOOTNOTE_DID_TAP_MESSAGE": RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue
|
|
||||||
]
|
|
||||||
)
|
|
||||||
].joined(separator: "\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func applyPresentationScript(for request: RDEPUBReflowableRenderRequest) -> String {
|
|
||||||
let style = escapedJavaScriptTemplateLiteral(RDEPUBStyleSheetBuilder.renderCSS(for: request.presentation))
|
|
||||||
let pageStride = max(1, request.presentation.viewportSize.width)
|
|
||||||
let searchScript = applySearchScript(for: request.searchPresentation)
|
|
||||||
|
|
||||||
return """
|
|
||||||
(function() {
|
|
||||||
if (!window.WeReadApi) { return false; }
|
|
||||||
window.WeReadApi.applySharedTheme({
|
|
||||||
backgroundColor: \(javaScriptStringLiteral(request.presentation.themeBackgroundColor)),
|
|
||||||
textColor: \(javaScriptStringLiteral(request.presentation.themeTextColor)),
|
|
||||||
includeFrames: false
|
|
||||||
});
|
|
||||||
window.WeReadApi.applyPagination(`\(style)`);
|
|
||||||
window.WeReadApi.setPageMetrics(\(request.totalPagesInChapter), \(pageStride));
|
|
||||||
window.WeReadApi.clearHighlights();
|
|
||||||
window.WeReadApi.setHighlights(\(jsonString(from: highlightsPayload(request.highlights), fallback: "[]")));
|
|
||||||
if (\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")) !== null) {
|
|
||||||
window.WeReadApi.scrollToLocation(
|
|
||||||
\(jsonString(from: targetLocationPayload(request.targetLocation), fallback: "null")),
|
|
||||||
\(request.pageIndex),
|
|
||||||
\(javaScriptStringLiteral(request.targetHighlightRangeInfo))
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.WeReadApi.scrollToPage(\(request.pageIndex));
|
|
||||||
}
|
|
||||||
window.WeReadApi.reportProgression();
|
|
||||||
return true;
|
|
||||||
})();
|
|
||||||
\(searchScript)
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
static func applyFixedPresentationScript(for request: RDEPUBFixedRenderRequest) -> String {
|
|
||||||
"""
|
|
||||||
(function() {
|
|
||||||
if (!window.WeReadApi) { return false; }
|
|
||||||
window.WeReadApi.applySharedTheme({
|
|
||||||
backgroundColor: \(javaScriptStringLiteral(request.backgroundColorCSS)),
|
|
||||||
textColor: null,
|
|
||||||
includeFrames: true
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
static func applySearchScript(for presentation: RDEPUBSearchPresentation?) -> String {
|
|
||||||
let payload = jsonString(from: searchPayload(presentation), fallback: "null")
|
|
||||||
return """
|
|
||||||
(function() {
|
|
||||||
if (!window.WeReadApi) { return false; }
|
|
||||||
window.WeReadApi.setSearchPresentation(\(payload));
|
|
||||||
return true;
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resolveDecorationsScript() -> String {
|
|
||||||
"""
|
|
||||||
(function() {
|
|
||||||
if (!window.WeReadApi) { return { highlights: [], search: [] }; }
|
|
||||||
return window.WeReadApi.resolveDecorations();
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func escapedJavaScriptTemplateLiteral(_ string: String) -> String {
|
|
||||||
string
|
|
||||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
||||||
.replacingOccurrences(of: "`", with: "\\`")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func highlightsPayload(_ highlights: [RDEPUBHighlight]) -> [[String: String]] {
|
|
||||||
highlights.compactMap { highlight in
|
|
||||||
guard let rangeInfo = highlight.rangeInfo, !rangeInfo.isEmpty else { return nil }
|
|
||||||
return [
|
|
||||||
"id": highlight.id,
|
|
||||||
"rangeInfo": rangeInfo,
|
|
||||||
"color": highlight.color,
|
|
||||||
"style": highlight.style.rawValue
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func targetLocationPayload(_ location: RDEPUBLocation?) -> [String: Any]? {
|
|
||||||
guard let location else { return nil }
|
|
||||||
return [
|
|
||||||
"progression": location.progression,
|
|
||||||
"lastProgression": location.lastProgression ?? location.progression,
|
|
||||||
"fragment": location.fragment as Any
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func searchPayload(_ presentation: RDEPUBSearchPresentation?) -> [String: Any]? {
|
|
||||||
guard let presentation else { return nil }
|
|
||||||
return [
|
|
||||||
"keyword": presentation.keyword,
|
|
||||||
"resources": presentation.resources.map { resource in
|
|
||||||
[
|
|
||||||
"href": resource.href,
|
|
||||||
"matchCount": resource.matchCount,
|
|
||||||
"activeLocalMatchIndex": resource.activeLocalMatchIndex as Any
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func jsonString(from object: Any?, fallback: String) -> String {
|
|
||||||
guard let object else { return fallback }
|
|
||||||
guard JSONSerialization.isValidJSONObject(object),
|
|
||||||
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
|
||||||
let string = String(data: data, encoding: .utf8) else {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encodes a string value as a safe JavaScript string literal.
|
|
||||||
///
|
|
||||||
/// Uses JSON serialization to handle escaping of quotes, backslashes, control
|
|
||||||
/// characters, and Unicode, then strips the array wrapper to produce a standalone
|
|
||||||
/// JS string literal (including surrounding quotes).
|
|
||||||
///
|
|
||||||
/// - Parameter value: The string to encode, or `nil` which produces the JS keyword `null`.
|
|
||||||
/// - Returns: A JavaScript string literal safe for inline embedding in JS source.
|
|
||||||
private static func javaScriptStringLiteral(_ value: String?) -> String {
|
|
||||||
guard let value else { return "null" }
|
|
||||||
guard JSONSerialization.isValidJSONObject([value]),
|
|
||||||
let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
|
|
||||||
let arrayString = String(data: data, encoding: .utf8) else {
|
|
||||||
return "null"
|
|
||||||
}
|
|
||||||
// JSON encodes ["value"] as `["value"]`. Drop the leading `[` and trailing `]`
|
|
||||||
// to yield `"value"` — a valid JS string literal with all special characters escaped.
|
|
||||||
return String(arrayString.dropFirst().dropLast())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBLayout: String, Codable {
|
|
||||||
case reflowable
|
|
||||||
case fixed
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBReadingProfile: String, Codable {
|
|
||||||
case webInteractive
|
|
||||||
case webFixedLayout
|
|
||||||
case textReflowable
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBReadingProgression: String, Codable {
|
|
||||||
case ltr
|
|
||||||
case rtl
|
|
||||||
case auto
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBPageSpread: String, Codable {
|
|
||||||
case left
|
|
||||||
case right
|
|
||||||
case center
|
|
||||||
}
|
|
||||||
|
|
||||||
/// EPUB 渲染朝向(rendition:orientation / iBooks display-options)。
|
|
||||||
public enum RDEPUBOrientation: String, Codable {
|
|
||||||
case portrait
|
|
||||||
case landscape
|
|
||||||
case auto
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBMetadata: Codable, Equatable {
|
|
||||||
|
|
||||||
public var identifier: String?
|
|
||||||
|
|
||||||
public var title: String
|
|
||||||
|
|
||||||
public var author: String?
|
|
||||||
|
|
||||||
public var language: String?
|
|
||||||
|
|
||||||
public var version: String?
|
|
||||||
|
|
||||||
public var layout: RDEPUBLayout
|
|
||||||
|
|
||||||
public var spread: String?
|
|
||||||
|
|
||||||
public var readingProgression: RDEPUBReadingProgression
|
|
||||||
|
|
||||||
/// 渲染朝向;nil = 未声明(不锁定,跟随系统)。
|
|
||||||
/// 来源优先级:OPF rendition:orientation > META-INF/com.apple.ibooks.display-options.xml。
|
|
||||||
public var orientation: RDEPUBOrientation?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
identifier: String? = nil,
|
|
||||||
title: String = "",
|
|
||||||
author: String? = nil,
|
|
||||||
language: String? = nil,
|
|
||||||
version: String? = nil,
|
|
||||||
layout: RDEPUBLayout = .reflowable,
|
|
||||||
spread: String? = nil,
|
|
||||||
readingProgression: RDEPUBReadingProgression = .auto,
|
|
||||||
orientation: RDEPUBOrientation? = nil
|
|
||||||
) {
|
|
||||||
self.identifier = identifier
|
|
||||||
self.title = title
|
|
||||||
self.author = author
|
|
||||||
self.language = language
|
|
||||||
self.version = version
|
|
||||||
self.layout = layout
|
|
||||||
self.spread = spread
|
|
||||||
self.readingProgression = readingProgression
|
|
||||||
self.orientation = orientation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBManifestItem: Codable, Equatable {
|
|
||||||
|
|
||||||
public var id: String
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var mediaType: String
|
|
||||||
|
|
||||||
public var properties: [String]
|
|
||||||
|
|
||||||
public var fallback: String?
|
|
||||||
|
|
||||||
public var mediaOverlay: String?
|
|
||||||
|
|
||||||
public var title: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
id: String,
|
|
||||||
href: String,
|
|
||||||
mediaType: String,
|
|
||||||
properties: [String] = [],
|
|
||||||
fallback: String? = nil,
|
|
||||||
mediaOverlay: String? = nil,
|
|
||||||
title: String? = nil
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.href = href
|
|
||||||
self.mediaType = mediaType
|
|
||||||
self.properties = properties
|
|
||||||
self.fallback = fallback
|
|
||||||
self.mediaOverlay = mediaOverlay
|
|
||||||
self.title = title
|
|
||||||
}
|
|
||||||
|
|
||||||
public var isNavigationDocument: Bool {
|
|
||||||
properties.contains("nav")
|
|
||||||
}
|
|
||||||
|
|
||||||
public var isNCX: Bool {
|
|
||||||
mediaType == "application/x-dtbncx+xml"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBSpineItem: Codable, Equatable {
|
|
||||||
|
|
||||||
public var idref: String
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var mediaType: String
|
|
||||||
|
|
||||||
public var title: String
|
|
||||||
|
|
||||||
public var linear: Bool
|
|
||||||
|
|
||||||
public var properties: [String]
|
|
||||||
|
|
||||||
public var pageSpread: RDEPUBPageSpread?
|
|
||||||
|
|
||||||
public var layout: RDEPUBLayout?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
idref: String,
|
|
||||||
href: String,
|
|
||||||
mediaType: String,
|
|
||||||
title: String,
|
|
||||||
linear: Bool = true,
|
|
||||||
properties: [String] = [],
|
|
||||||
pageSpread: RDEPUBPageSpread? = nil,
|
|
||||||
layout: RDEPUBLayout? = nil
|
|
||||||
) {
|
|
||||||
self.idref = idref
|
|
||||||
self.href = href
|
|
||||||
self.mediaType = mediaType
|
|
||||||
self.title = title
|
|
||||||
self.linear = linear
|
|
||||||
self.properties = properties
|
|
||||||
self.pageSpread = pageSpread
|
|
||||||
self.layout = layout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct EPUBTableOfContentsItem: Codable, Equatable {
|
|
||||||
|
|
||||||
public var title: String
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var children: [EPUBTableOfContentsItem]
|
|
||||||
|
|
||||||
public init(title: String, href: String, children: [EPUBTableOfContentsItem] = []) {
|
|
||||||
self.title = title
|
|
||||||
self.href = href
|
|
||||||
self.children = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBParserError: LocalizedError {
|
|
||||||
|
|
||||||
case archiveOpenFailed(URL)
|
|
||||||
|
|
||||||
case missingContainerXML
|
|
||||||
|
|
||||||
case missingRootFile
|
|
||||||
|
|
||||||
case invalidRootFilePath(String)
|
|
||||||
|
|
||||||
case invalidXML(URL)
|
|
||||||
|
|
||||||
case missingManifestItem(idref: String)
|
|
||||||
|
|
||||||
case emptySpine
|
|
||||||
|
|
||||||
case invalidArchiveEntryPath(String)
|
|
||||||
|
|
||||||
public var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .archiveOpenFailed(let url):
|
|
||||||
return "无法打开 EPUB 压缩包: \(url.lastPathComponent)"
|
|
||||||
case .missingContainerXML:
|
|
||||||
return "EPUB 缺少 META-INF/container.xml"
|
|
||||||
case .missingRootFile:
|
|
||||||
return "container.xml 中未找到 rootfile"
|
|
||||||
case .invalidRootFilePath(let path):
|
|
||||||
return "OPF 路径无效: \(path)"
|
|
||||||
case .invalidXML(let url):
|
|
||||||
return "XML 解析失败: \(url.lastPathComponent)"
|
|
||||||
case .missingManifestItem(let idref):
|
|
||||||
return "spine itemref 找不到对应 manifest 项: \(idref)"
|
|
||||||
case .emptySpine:
|
|
||||||
return "OPF spine 为空"
|
|
||||||
case .invalidArchiveEntryPath(let path):
|
|
||||||
return "归档条目路径非法: \(path)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public struct RDEPUBNavigatorLayoutContext: Equatable {
|
|
||||||
|
|
||||||
public var containerSize: CGSize
|
|
||||||
|
|
||||||
public var pagesPerScreen: Int
|
|
||||||
|
|
||||||
public var safeAreaInsets: UIEdgeInsets
|
|
||||||
|
|
||||||
public var userInterfaceIdiom: UIUserInterfaceIdiom
|
|
||||||
|
|
||||||
public var reflowableContentInsets: UIEdgeInsets
|
|
||||||
|
|
||||||
public init(
|
|
||||||
containerSize: CGSize,
|
|
||||||
pagesPerScreen: Int = 1,
|
|
||||||
safeAreaInsets: UIEdgeInsets = .zero,
|
|
||||||
userInterfaceIdiom: UIUserInterfaceIdiom = .phone,
|
|
||||||
reflowableContentInsets: UIEdgeInsets = RDEPUBSafeArea.defaultReflowableContentInsets()
|
|
||||||
) {
|
|
||||||
self.containerSize = containerSize
|
|
||||||
self.pagesPerScreen = max(1, pagesPerScreen)
|
|
||||||
self.safeAreaInsets = safeAreaInsets
|
|
||||||
self.userInterfaceIdiom = userInterfaceIdiom
|
|
||||||
self.reflowableContentInsets = reflowableContentInsets
|
|
||||||
}
|
|
||||||
|
|
||||||
public var viewportSize: CGSize {
|
|
||||||
let width: CGFloat
|
|
||||||
if pagesPerScreen > 1 {
|
|
||||||
width = containerSize.width / CGFloat(pagesPerScreen)
|
|
||||||
} else {
|
|
||||||
width = containerSize.width
|
|
||||||
}
|
|
||||||
return CGSize(width: width, height: containerSize.height)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Content insets that account for safe area (Dynamic Island / notch) on iPhone.
|
|
||||||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for top/bottom
|
|
||||||
/// to ensure content is never hidden under the Dynamic Island or home indicator.
|
|
||||||
public var safeReflowableContentInsets: UIEdgeInsets {
|
|
||||||
let top = max(safeAreaInsets.top, reflowableContentInsets.top)
|
|
||||||
let bottom = max(safeAreaInsets.bottom, reflowableContentInsets.bottom)
|
|
||||||
let left = max(safeAreaInsets.left, reflowableContentInsets.left)
|
|
||||||
let right = max(safeAreaInsets.right, reflowableContentInsets.right)
|
|
||||||
return UIEdgeInsets(top: top, left: left, bottom: bottom, right: right)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixed layout content insets that respect safe areas on all devices including iPhone.
|
|
||||||
public var fixedContentInset: UIEdgeInsets {
|
|
||||||
var insets = safeAreaInsets
|
|
||||||
let horizontalInsets = max(insets.left, insets.right)
|
|
||||||
insets.left = horizontalInsets
|
|
||||||
insets.right = horizontalInsets
|
|
||||||
return insets
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
public final class RDEPUBPaginator: NSObject {
|
|
||||||
|
|
||||||
private var parser: RDEPUBParser?
|
|
||||||
|
|
||||||
private weak var hostingView: UIView?
|
|
||||||
|
|
||||||
private var presentation = RDEPUBPresentationStyle(
|
|
||||||
viewportSize: .zero,
|
|
||||||
contentInsets: .zero,
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeightMultiple: 1.5
|
|
||||||
)
|
|
||||||
|
|
||||||
private var completion: (([Int]) -> Void)?
|
|
||||||
|
|
||||||
private var singlePageCountCompletion: ((Int) -> Void)?
|
|
||||||
|
|
||||||
private var pageCounts: [Int] = []
|
|
||||||
|
|
||||||
private var measurementIndices: [Int] = []
|
|
||||||
|
|
||||||
private var currentMeasurementOffset = 0
|
|
||||||
|
|
||||||
private var pendingMeasurementValue = 1
|
|
||||||
|
|
||||||
private var measurementPass = 0
|
|
||||||
|
|
||||||
private var activeSessionID = UUID()
|
|
||||||
|
|
||||||
private let debugScope = "PaginatorWebView"
|
|
||||||
|
|
||||||
private lazy var webView: WKWebView = {
|
|
||||||
let configuration = WKWebViewConfiguration()
|
|
||||||
configuration.websiteDataStore = .nonPersistent()
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
|
||||||
webView.navigationDelegate = self
|
|
||||||
webView.isOpaque = false
|
|
||||||
webView.backgroundColor = .clear
|
|
||||||
webView.scrollView.isScrollEnabled = false
|
|
||||||
webView.scrollView.bounces = false
|
|
||||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
|
||||||
webView.scrollView.showsVerticalScrollIndicator = false
|
|
||||||
webView.isUserInteractionEnabled = false
|
|
||||||
if #available(iOS 16.4, *) {
|
|
||||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
|
||||||
}
|
|
||||||
RDEPUBWebViewDebug.log("PaginatorWebView", message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
|
||||||
return webView
|
|
||||||
}()
|
|
||||||
|
|
||||||
public override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
cleanupMeasurementState()
|
|
||||||
webView.navigationDelegate = nil
|
|
||||||
webView.stopLoading()
|
|
||||||
webView.removeFromSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func calculate(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
hostingView: UIView,
|
|
||||||
presentation: RDEPUBPresentationStyle,
|
|
||||||
completion: @escaping ([Int]) -> Void
|
|
||||||
) {
|
|
||||||
self.parser = parser
|
|
||||||
self.hostingView = hostingView
|
|
||||||
self.presentation = presentation
|
|
||||||
self.completion = completion
|
|
||||||
self.singlePageCountCompletion = nil
|
|
||||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
|
||||||
self.measurementIndices = Array(parser.spine.indices)
|
|
||||||
self.currentMeasurementOffset = 0
|
|
||||||
self.activeSessionID = UUID()
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "calculate session=\(activeSessionID) spineCount=\(parser.spine.count)")
|
|
||||||
|
|
||||||
guard parser.metadata.layout != .fixed else {
|
|
||||||
completion(self.pageCounts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
webView.navigationDelegate = self
|
|
||||||
if webView.superview == nil {
|
|
||||||
hostingView.addSubview(webView)
|
|
||||||
}
|
|
||||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
|
||||||
measureNextSpineItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func calculate(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
hostingView: UIView,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
padding: UIEdgeInsets,
|
|
||||||
fontSize: CGFloat,
|
|
||||||
lineHeightMultiple: CGFloat,
|
|
||||||
completion: @escaping ([Int]) -> Void
|
|
||||||
) {
|
|
||||||
calculate(
|
|
||||||
parser: parser,
|
|
||||||
hostingView: hostingView,
|
|
||||||
presentation: RDEPUBPresentationStyle(
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInsets: padding,
|
|
||||||
fontSize: fontSize,
|
|
||||||
lineHeightMultiple: lineHeightMultiple
|
|
||||||
),
|
|
||||||
completion: completion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func calculateSingleSpinePageCount(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
spineIndex: Int,
|
|
||||||
hostingView: UIView,
|
|
||||||
presentation: RDEPUBPresentationStyle,
|
|
||||||
completion: @escaping (Int) -> Void
|
|
||||||
) {
|
|
||||||
self.parser = parser
|
|
||||||
self.hostingView = hostingView
|
|
||||||
self.presentation = presentation
|
|
||||||
self.completion = nil
|
|
||||||
self.singlePageCountCompletion = completion
|
|
||||||
self.pageCounts = Array(repeating: 1, count: parser.spine.count)
|
|
||||||
self.measurementIndices = parser.spine.indices.contains(spineIndex) ? [spineIndex] : []
|
|
||||||
self.currentMeasurementOffset = 0
|
|
||||||
self.activeSessionID = UUID()
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "calculateSingle session=\(activeSessionID) spineIndex=\(spineIndex)")
|
|
||||||
|
|
||||||
guard parser.metadata.layout != .fixed, !measurementIndices.isEmpty else {
|
|
||||||
completion(1)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
webView.navigationDelegate = self
|
|
||||||
if webView.superview == nil {
|
|
||||||
hostingView.addSubview(webView)
|
|
||||||
}
|
|
||||||
webView.frame = CGRect(origin: .zero, size: presentation.viewportSize)
|
|
||||||
measureNextSpineItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func calculateSingleSpinePageCount(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
spineIndex: Int,
|
|
||||||
hostingView: UIView,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
padding: UIEdgeInsets,
|
|
||||||
fontSize: CGFloat,
|
|
||||||
lineHeightMultiple: CGFloat,
|
|
||||||
completion: @escaping (Int) -> Void
|
|
||||||
) {
|
|
||||||
calculateSingleSpinePageCount(
|
|
||||||
parser: parser,
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
hostingView: hostingView,
|
|
||||||
presentation: RDEPUBPresentationStyle(
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInsets: padding,
|
|
||||||
fontSize: fontSize,
|
|
||||||
lineHeightMultiple: lineHeightMultiple
|
|
||||||
),
|
|
||||||
completion: completion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func injectPaginationCSS(
|
|
||||||
into html: String,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
padding: UIEdgeInsets,
|
|
||||||
fontSize: CGFloat,
|
|
||||||
lineHeightMultiple: CGFloat,
|
|
||||||
themeBackgroundColor: String? = nil,
|
|
||||||
themeTextColor: String? = nil
|
|
||||||
) -> String {
|
|
||||||
RDEPUBStyleSheetBuilder.injectPaginationCSS(
|
|
||||||
into: html,
|
|
||||||
presentation: RDEPUBPresentationStyle(
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInsets: padding,
|
|
||||||
fontSize: fontSize,
|
|
||||||
lineHeightMultiple: lineHeightMultiple,
|
|
||||||
themeBackgroundColor: themeBackgroundColor,
|
|
||||||
themeTextColor: themeTextColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func measureNextSpineItem() {
|
|
||||||
guard let parser else {
|
|
||||||
finishIfNeeded()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard currentMeasurementOffset < measurementIndices.count else {
|
|
||||||
finishIfNeeded()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentSpineIndex = measurementIndices[currentMeasurementOffset]
|
|
||||||
|
|
||||||
let item = parser.spine[currentSpineIndex]
|
|
||||||
guard isRenderablePage(item: item),
|
|
||||||
let opfDirectoryURL = parser.opfDirectoryURL else {
|
|
||||||
pageCounts[currentSpineIndex] = 1
|
|
||||||
currentMeasurementOffset += 1
|
|
||||||
measureNextSpineItem()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileURL = opfDirectoryURL.appendingPathComponent(item.href)
|
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
||||||
pageCounts[currentSpineIndex] = 1
|
|
||||||
currentMeasurementOffset += 1
|
|
||||||
measureNextSpineItem()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingMeasurementValue = 1
|
|
||||||
measurementPass = 0
|
|
||||||
let readAccessURL = parser.extractionRootURL ?? opfDirectoryURL
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "measure-load", url: fileURL)
|
|
||||||
webView.loadFileURL(fileURL, allowingReadAccessTo: readAccessURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentSpineIndexForMeasurement() -> Int? {
|
|
||||||
measurementIndices[safe: currentMeasurementOffset]
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishIfNeeded() {
|
|
||||||
if let singlePageCountCompletion {
|
|
||||||
let measuredSpineIndex = measurementIndices.first ?? 0
|
|
||||||
singlePageCountCompletion(max(1, pageCounts[safe: measuredSpineIndex] ?? 1))
|
|
||||||
self.singlePageCountCompletion = nil
|
|
||||||
cleanupMeasurementState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let completion else { return }
|
|
||||||
completion(pageCounts)
|
|
||||||
self.completion = nil
|
|
||||||
cleanupMeasurementState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cleanupMeasurementState() {
|
|
||||||
parser = nil
|
|
||||||
hostingView = nil
|
|
||||||
completion = nil
|
|
||||||
singlePageCountCompletion = nil
|
|
||||||
measurementIndices = []
|
|
||||||
currentMeasurementOffset = 0
|
|
||||||
pendingMeasurementValue = 1
|
|
||||||
measurementPass = 0
|
|
||||||
activeSessionID = UUID()
|
|
||||||
webView.stopLoading()
|
|
||||||
webView.navigationDelegate = nil
|
|
||||||
webView.removeFromSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isRenderablePage(item: RDEPUBSpineItem) -> Bool {
|
|
||||||
let mediaType = item.mediaType.lowercased()
|
|
||||||
return mediaType.contains("html") || mediaType.contains("xhtml") || mediaType.contains("xml")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var measurementPassValues: [Int] = []
|
|
||||||
|
|
||||||
private func scheduleMeasurementPass() {
|
|
||||||
let sessionID = activeSessionID
|
|
||||||
let delays: [TimeInterval] = [0.0, 0.08, 0.18]
|
|
||||||
guard measurementPass < delays.count else {
|
|
||||||
guard sessionID == activeSessionID,
|
|
||||||
let currentSpineIndex = currentSpineIndexForMeasurement() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let finalValue = max(1, pendingMeasurementValue)
|
|
||||||
|
|
||||||
if measurementPassValues.count >= 2 {
|
|
||||||
let minVal = measurementPassValues.min() ?? 1
|
|
||||||
let maxVal = measurementPassValues.max() ?? 1
|
|
||||||
if minVal > 0 && Double(maxVal - minVal) / Double(minVal) > 0.2 {
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "measurement instability: passes=\(measurementPassValues) spine=\(currentSpineIndex)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pageCounts[currentSpineIndex] = finalValue
|
|
||||||
currentMeasurementOffset += 1
|
|
||||||
measurementPassValues = []
|
|
||||||
measureNextSpineItem()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let delay = delays[measurementPass]
|
|
||||||
measurementPass += 1
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
||||||
guard let self, self.activeSessionID == sessionID else { return }
|
|
||||||
self.measureCurrentDocument(sessionID: sessionID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func measureCurrentDocument(sessionID: UUID) {
|
|
||||||
let script = RDEPUBStyleSheetBuilder.measurementScript(for: presentation)
|
|
||||||
RDEPUBWebViewDebug.logJavaScript(debugScope, webView: webView, action: "measure", details: "session=\(sessionID) pass=\(measurementPass)")
|
|
||||||
|
|
||||||
webView.evaluateJavaScript(script) { [weak self] value, _ in
|
|
||||||
guard let self else { return }
|
|
||||||
guard self.activeSessionID == sessionID,
|
|
||||||
self.currentSpineIndexForMeasurement() != nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let number = value as? NSNumber {
|
|
||||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, number.intValue)
|
|
||||||
self.measurementPassValues.append(number.intValue)
|
|
||||||
} else if let intValue = value as? Int {
|
|
||||||
self.pendingMeasurementValue = max(self.pendingMeasurementValue, intValue)
|
|
||||||
self.measurementPassValues.append(intValue)
|
|
||||||
}
|
|
||||||
RDEPUBWebViewDebug.log(self.debugScope, message: "measurement value=\(self.pendingMeasurementValue) session=\(sessionID)")
|
|
||||||
self.scheduleMeasurementPass()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RDEPUBPaginator: WKNavigationDelegate {
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
||||||
guard let url = navigationAction.request.url else {
|
|
||||||
decisionHandler(.cancel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let scheme = url.scheme?.lowercased() ?? ""
|
|
||||||
if scheme == "file" || scheme == RDEPUBResourceURLSchemeHandler.scheme {
|
|
||||||
decisionHandler(.allow)
|
|
||||||
} else {
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "blocked navigation to non-local scheme: \(scheme)")
|
|
||||||
decisionHandler(.cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
|
||||||
guard currentSpineIndexForMeasurement() != nil else { return }
|
|
||||||
scheduleMeasurementPass()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
|
||||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
|
||||||
pageCounts[currentSpineIndex] = 1
|
|
||||||
currentMeasurementOffset += 1
|
|
||||||
measureNextSpineItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
|
||||||
let currentSpineIndex = measurementIndices[safe: currentMeasurementOffset] ?? 0
|
|
||||||
pageCounts[currentSpineIndex] = 1
|
|
||||||
currentMeasurementOffset += 1
|
|
||||||
measureNextSpineItem()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension Array {
|
|
||||||
subscript(safe index: Int) -> Element? {
|
|
||||||
indices.contains(index) ? self[index] : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-198
@@ -1,198 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
import ZIPFoundation
|
|
||||||
|
|
||||||
extension RDEPUBParser {
|
|
||||||
|
|
||||||
func parseContainerRootFile(at containerURL: URL) throws -> String {
|
|
||||||
guard let parser = XMLParser(contentsOf: containerURL) else {
|
|
||||||
throw RDEPUBParserError.invalidXML(containerURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
let delegate = ContainerXMLParserDelegate()
|
|
||||||
parser.shouldProcessNamespaces = false
|
|
||||||
parser.delegate = delegate
|
|
||||||
|
|
||||||
guard parser.parse() else {
|
|
||||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(containerURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let rootFilePath = delegate.rootFilePath, !rootFilePath.isEmpty else {
|
|
||||||
throw RDEPUBParserError.missingRootFile
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootFilePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractArchiveIfNeeded(epubURL: URL) throws -> URL {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let extractionURL = temporaryExtractionDirectory(for: epubURL)
|
|
||||||
|
|
||||||
if fileManager.fileExists(atPath: extractionURL.path) {
|
|
||||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
|
||||||
if fileManager.fileExists(atPath: containerURL.path) {
|
|
||||||
return extractionURL
|
|
||||||
}
|
|
||||||
try? fileManager.removeItem(at: extractionURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let archive = Archive(url: epubURL, accessMode: .read) else {
|
|
||||||
throw RDEPUBParserError.archiveOpenFailed(epubURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
try fileManager.createDirectory(at: extractionURL, withIntermediateDirectories: true)
|
|
||||||
|
|
||||||
do {
|
|
||||||
for entry in archive {
|
|
||||||
if shouldSkipEntry(entry.path) { continue }
|
|
||||||
|
|
||||||
guard let destinationURL = validatedExtractionDestination(for: entry.path, extractionRoot: extractionURL) else {
|
|
||||||
if isCriticalEntry(entry.path) {
|
|
||||||
throw RDEPUBParserError.invalidArchiveEntryPath(entry.path)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch entry.type {
|
|
||||||
case .directory:
|
|
||||||
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
|
||||||
case .file:
|
|
||||||
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
||||||
_ = try archive.extract(entry, to: destinationURL)
|
|
||||||
case .symlink:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
try? fileManager.removeItem(at: extractionURL)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
|
|
||||||
return extractionURL
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isCriticalEntry(_ entryPath: String) -> Bool {
|
|
||||||
let lowercased = entryPath.lowercased()
|
|
||||||
return lowercased == "mimetype"
|
|
||||||
|| lowercased.hasPrefix("meta-inf/")
|
|
||||||
|| lowercased.hasSuffix(".opf")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldSkipEntry(_ entryPath: String) -> Bool {
|
|
||||||
let lowercased = entryPath.lowercased()
|
|
||||||
if lowercased.hasPrefix("__macosx/") { return true }
|
|
||||||
if lowercased.hasPrefix(".ds_store") { return true }
|
|
||||||
if lowercased.contains("/.ds_store") { return true }
|
|
||||||
if lowercased.hasSuffix("/thumbs.db") { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validatedExtractionDestination(for entryPath: String, extractionRoot: URL) -> URL? {
|
|
||||||
|
|
||||||
if entryPath.hasPrefix("/") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let components = entryPath.split(separator: "/", omittingEmptySubsequences: true)
|
|
||||||
if components.contains(where: { $0 == ".." }) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let destinationURL = extractionRoot.appendingPathComponent(entryPath)
|
|
||||||
let standardizedDest = destinationURL.standardizedFileURL.path
|
|
||||||
let standardizedRoot = extractionRoot.standardizedFileURL.path
|
|
||||||
|
|
||||||
guard standardizedDest.hasPrefix(standardizedRoot) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return destinationURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func temporaryExtractionDirectory(for epubURL: URL) -> URL {
|
|
||||||
let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
||||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
||||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
||||||
let fileAttributes = try? FileManager.default.attributesOfItem(atPath: epubURL.path)
|
|
||||||
let fileSize = (fileAttributes?[.size] as? NSNumber)?.stringValue ?? "0"
|
|
||||||
let modifiedAt = (fileAttributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
|
||||||
let slug = epubURL.deletingPathExtension().lastPathComponent
|
|
||||||
.replacingOccurrences(of: " ", with: "-")
|
|
||||||
let signature = String(format: "%.0f", modifiedAt)
|
|
||||||
return baseURL.appendingPathComponent("\(slug)-\(fileSize)-\(signature)", isDirectory: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
extractionRootURL = nil
|
|
||||||
opfURL = nil
|
|
||||||
resetPublicationState()
|
|
||||||
}
|
|
||||||
|
|
||||||
func resetPublicationState() {
|
|
||||||
metadata = RDEPUBMetadata()
|
|
||||||
manifest = [:]
|
|
||||||
spine = []
|
|
||||||
tableOfContents = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class ContainerXMLParserDelegate: NSObject, XMLParserDelegate {
|
|
||||||
|
|
||||||
private(set) var rootFilePath: String?
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didStartElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?,
|
|
||||||
attributes attributeDict: [String: String] = [:]
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
guard name == "rootfile", rootFilePath == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rootFilePath = attributeDict["full-path"]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 com.apple.ibooks.display-options.xml,提取 orientation-lock 选项值。
|
|
||||||
final class IBooksDisplayOptionsParserDelegate: NSObject, XMLParserDelegate {
|
|
||||||
|
|
||||||
private(set) var orientationLock: String?
|
|
||||||
|
|
||||||
private var isCapturingOrientationOption = false
|
|
||||||
private var buffer = ""
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didStartElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?,
|
|
||||||
attributes attributeDict: [String: String] = [:]
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
guard name == "option" else { return }
|
|
||||||
let optionName = (attributeDict["name"] ?? "").lowercased()
|
|
||||||
if optionName == "orientation-lock" {
|
|
||||||
isCapturingOrientationOption = true
|
|
||||||
buffer = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
||||||
guard isCapturingOrientationOption else { return }
|
|
||||||
buffer += string
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didEndElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
guard name == "option", isCapturingOrientationOption else { return }
|
|
||||||
isCapturingOrientationOption = false
|
|
||||||
let value = buffer.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if !value.isEmpty, orientationLock == nil {
|
|
||||||
orientationLock = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-407
@@ -1,407 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension RDEPUBParser {
|
|
||||||
|
|
||||||
func buildSpine(from packageDocument: OPFPackageDocument, opfURL: URL) throws -> [RDEPUBSpineItem] {
|
|
||||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
|
||||||
let publicationLayout = packageDocument.metadata.layout
|
|
||||||
|
|
||||||
let spineItems = try packageDocument.spineReferences.map { reference in
|
|
||||||
guard let manifestItem = packageDocument.manifestByID[reference.idref] else {
|
|
||||||
throw RDEPUBParserError.missingManifestItem(idref: reference.idref)
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedHref = normalize(href: manifestItem.href, relativeTo: opfDirectoryURL)
|
|
||||||
let title = manifestItem.title?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let fallbackTitle = normalizedHref
|
|
||||||
.split(separator: "/")
|
|
||||||
.last
|
|
||||||
.map(String.init)
|
|
||||||
.map { $0.replacingOccurrences(of: ".xhtml", with: "") }
|
|
||||||
.map { $0.replacingOccurrences(of: ".html", with: "") }
|
|
||||||
.flatMap { $0.isEmpty ? nil : $0 }
|
|
||||||
?? reference.idref
|
|
||||||
|
|
||||||
let effectiveLayout: RDEPUBLayout?
|
|
||||||
if reference.properties.contains("rendition:layout-pre-paginated") || manifestItem.properties.contains("rendition:layout-pre-paginated") {
|
|
||||||
effectiveLayout = .fixed
|
|
||||||
} else if reference.properties.contains("rendition:layout-reflowable") || manifestItem.properties.contains("rendition:layout-reflowable") {
|
|
||||||
effectiveLayout = .reflowable
|
|
||||||
} else {
|
|
||||||
effectiveLayout = publicationLayout == .fixed ? .fixed : nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBSpineItem(
|
|
||||||
idref: reference.idref,
|
|
||||||
href: normalizedHref,
|
|
||||||
mediaType: manifestItem.mediaType,
|
|
||||||
title: title?.isEmpty == false ? title! : fallbackTitle,
|
|
||||||
linear: reference.linear,
|
|
||||||
properties: reference.properties,
|
|
||||||
pageSpread: reference.pageSpread,
|
|
||||||
layout: effectiveLayout
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !spineItems.isEmpty else {
|
|
||||||
throw RDEPUBParserError.emptySpine
|
|
||||||
}
|
|
||||||
|
|
||||||
return spineItems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OPFPackageDocument {
|
|
||||||
var metadata: RDEPUBMetadata
|
|
||||||
var manifest: [RDEPUBManifestItem]
|
|
||||||
var manifestByID: [String: RDEPUBManifestItem]
|
|
||||||
var spineReferences: [OPFSpineReference]
|
|
||||||
|
|
||||||
var ncxItem: RDEPUBManifestItem?
|
|
||||||
|
|
||||||
var navigationItem: RDEPUBManifestItem?
|
|
||||||
}
|
|
||||||
|
|
||||||
struct OPFSpineReference {
|
|
||||||
|
|
||||||
var idref: String
|
|
||||||
|
|
||||||
var linear: Bool
|
|
||||||
|
|
||||||
var properties: [String]
|
|
||||||
|
|
||||||
var pageSpread: RDEPUBPageSpread?
|
|
||||||
}
|
|
||||||
|
|
||||||
final class OPFPackageParserDelegate: NSObject, XMLParserDelegate {
|
|
||||||
|
|
||||||
private var metadata = RDEPUBMetadata()
|
|
||||||
|
|
||||||
private var manifestItems: [RDEPUBManifestItem] = []
|
|
||||||
|
|
||||||
private var spineReferences: [OPFSpineReference] = []
|
|
||||||
|
|
||||||
private let xmlContext = XMLParserContext()
|
|
||||||
|
|
||||||
private var uniqueIdentifierID: String?
|
|
||||||
|
|
||||||
private var currentIdentifierElementID: String?
|
|
||||||
|
|
||||||
private var currentMetaProperty: String?
|
|
||||||
|
|
||||||
private var identifierByID: [String: String] = [:]
|
|
||||||
|
|
||||||
private var manifestTitleByID: [String: String] = [:]
|
|
||||||
|
|
||||||
private var currentMetaRefinesID: String?
|
|
||||||
|
|
||||||
private var ncxID: String?
|
|
||||||
|
|
||||||
func packageDocument() -> OPFPackageDocument {
|
|
||||||
if metadata.identifier == nil, let fallbackIdentifier = identifierByID.values.first {
|
|
||||||
metadata.identifier = fallbackIdentifier
|
|
||||||
}
|
|
||||||
metadata.title = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
metadata.author = metadata.author?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
metadata.language = metadata.language?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
let manifest = manifestItems.map { item in
|
|
||||||
var updatedItem = item
|
|
||||||
updatedItem.title = manifestTitleByID[item.id]
|
|
||||||
return updatedItem
|
|
||||||
}
|
|
||||||
// 部分历史 EPUB 的 OPF manifest 含重复 id。`uniqueKeysWithValues` 会在
|
|
||||||
// 此处触发运行时断言,导致整本书无法打开;spine 按 OPF 顺序解析,故保留
|
|
||||||
// 第一个声明即可维持其原始引用语义。
|
|
||||||
let manifestByID = manifest.reduce(into: [String: RDEPUBManifestItem]()) { result, item in
|
|
||||||
guard !item.id.isEmpty, result[item.id] == nil else { return }
|
|
||||||
result[item.id] = item
|
|
||||||
}
|
|
||||||
|
|
||||||
return OPFPackageDocument(
|
|
||||||
metadata: metadata,
|
|
||||||
manifest: manifest,
|
|
||||||
manifestByID: manifestByID,
|
|
||||||
spineReferences: spineReferences,
|
|
||||||
ncxItem: manifest.first(where: { $0.id == ncxID || $0.isNCX }),
|
|
||||||
navigationItem: manifest.first(where: { $0.isNavigationDocument })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didStartElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?,
|
|
||||||
attributes attributeDict: [String: String] = [:]
|
|
||||||
) {
|
|
||||||
let name = xmlContext.startElement(named: qName ?? elementName)
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "package":
|
|
||||||
metadata.version = attributeDict["version"]
|
|
||||||
uniqueIdentifierID = attributeDict["unique-identifier"]
|
|
||||||
case "identifier":
|
|
||||||
if currentSection == .metadata {
|
|
||||||
currentIdentifierElementID = attributeDict["id"]
|
|
||||||
}
|
|
||||||
case "item":
|
|
||||||
guard currentSection == .manifest,
|
|
||||||
let id = attributeDict["id"],
|
|
||||||
let href = attributeDict["href"],
|
|
||||||
let mediaType = attributeDict["media-type"] else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
manifestItems.append(
|
|
||||||
RDEPUBManifestItem(
|
|
||||||
id: id,
|
|
||||||
href: href,
|
|
||||||
mediaType: mediaType,
|
|
||||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
|
||||||
fallback: attributeDict["fallback"],
|
|
||||||
mediaOverlay: attributeDict["media-overlay"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case "itemref":
|
|
||||||
guard currentSection == .spine, let idref = attributeDict["idref"] else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
spineReferences.append(
|
|
||||||
OPFSpineReference(
|
|
||||||
idref: idref,
|
|
||||||
linear: (attributeDict["linear"]?.lowercased() ?? "yes") != "no",
|
|
||||||
properties: XMLName.tokenize(attributeDict["properties"]),
|
|
||||||
pageSpread: pageSpread(from: attributeDict)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
case "meta":
|
|
||||||
currentMetaProperty = attributeDict["property"] ?? attributeDict["name"]
|
|
||||||
currentMetaRefinesID = attributeDict["refines"].flatMap(XMLName.refinedID(from:))
|
|
||||||
if let property = currentMetaProperty?.lowercased(),
|
|
||||||
property == "rendition:layout",
|
|
||||||
let value = attributeDict["content"] {
|
|
||||||
metadata.layout = layout(from: value)
|
|
||||||
}
|
|
||||||
if let property = currentMetaProperty?.lowercased(),
|
|
||||||
property == "rendition:spread" {
|
|
||||||
metadata.spread = attributeDict["content"]
|
|
||||||
}
|
|
||||||
if let property = currentMetaProperty?.lowercased(),
|
|
||||||
property == "rendition:orientation",
|
|
||||||
let value = attributeDict["content"] {
|
|
||||||
metadata.orientation = orientation(from: value)
|
|
||||||
}
|
|
||||||
if let property = currentMetaProperty?.lowercased(),
|
|
||||||
property == "title",
|
|
||||||
let refinesID = currentMetaRefinesID,
|
|
||||||
let value = attributeDict["content"],
|
|
||||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
manifestTitleByID[refinesID] = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "spine" {
|
|
||||||
if let direction = attributeDict["page-progression-direction"]?.lowercased() {
|
|
||||||
metadata.readingProgression = direction == "ltr" ? .ltr : (direction == "rtl" ? .rtl : .auto)
|
|
||||||
}
|
|
||||||
ncxID = attributeDict["toc"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
||||||
xmlContext.appendCharacters(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didEndElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
let text = xmlContext.trimmedCharacters
|
|
||||||
|
|
||||||
switch (currentSection, name) {
|
|
||||||
case (.metadata, "identifier"):
|
|
||||||
if let identifierID = currentIdentifierElementID, !text.isEmpty {
|
|
||||||
identifierByID[identifierID] = text
|
|
||||||
if identifierID == uniqueIdentifierID {
|
|
||||||
metadata.identifier = text
|
|
||||||
}
|
|
||||||
} else if metadata.identifier == nil, !text.isEmpty {
|
|
||||||
metadata.identifier = text
|
|
||||||
}
|
|
||||||
currentIdentifierElementID = nil
|
|
||||||
case (.metadata, "title"):
|
|
||||||
if metadata.title.isEmpty, !text.isEmpty {
|
|
||||||
metadata.title = text
|
|
||||||
}
|
|
||||||
case (.metadata, "creator"):
|
|
||||||
if metadata.author == nil, !text.isEmpty {
|
|
||||||
metadata.author = text
|
|
||||||
}
|
|
||||||
case (.metadata, "language"):
|
|
||||||
if metadata.language == nil, !text.isEmpty {
|
|
||||||
metadata.language = text
|
|
||||||
}
|
|
||||||
case (.metadata, "meta"):
|
|
||||||
applyMetaValue(text)
|
|
||||||
currentMetaProperty = nil
|
|
||||||
currentMetaRefinesID = nil
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
xmlContext.endElement()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var currentSection: XMLSection {
|
|
||||||
if xmlContext.containsElement(named: "manifest") {
|
|
||||||
return .manifest
|
|
||||||
}
|
|
||||||
if xmlContext.containsElement(named: "spine") {
|
|
||||||
return .spine
|
|
||||||
}
|
|
||||||
if xmlContext.containsElement(named: "metadata") {
|
|
||||||
return .metadata
|
|
||||||
}
|
|
||||||
return .other
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyMetaValue(_ value: String) {
|
|
||||||
let normalizedValue = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !normalizedValue.isEmpty, let property = currentMetaProperty?.lowercased() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch property {
|
|
||||||
case "rendition:layout":
|
|
||||||
metadata.layout = layout(from: normalizedValue)
|
|
||||||
case "rendition:spread":
|
|
||||||
metadata.spread = normalizedValue
|
|
||||||
case "rendition:orientation":
|
|
||||||
metadata.orientation = orientation(from: normalizedValue)
|
|
||||||
case "dcterms:identifier", "identifier":
|
|
||||||
if metadata.identifier == nil {
|
|
||||||
metadata.identifier = normalizedValue
|
|
||||||
}
|
|
||||||
case "title":
|
|
||||||
if let refinesID = currentMetaRefinesID {
|
|
||||||
manifestTitleByID[refinesID] = normalizedValue
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pageSpread(from attributes: [String: String]) -> RDEPUBPageSpread? {
|
|
||||||
if let properties = attributes["properties"]?.lowercased() {
|
|
||||||
if properties.contains("page-spread-left") {
|
|
||||||
return .left
|
|
||||||
}
|
|
||||||
if properties.contains("page-spread-right") {
|
|
||||||
return .right
|
|
||||||
}
|
|
||||||
if properties.contains("page-spread-center") {
|
|
||||||
return .center
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let legacySpread = attributes["page-spread"]?.lowercased() {
|
|
||||||
switch legacySpread {
|
|
||||||
case "left":
|
|
||||||
return .left
|
|
||||||
case "right":
|
|
||||||
return .right
|
|
||||||
case "center":
|
|
||||||
return .center
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func layout(from rawValue: String) -> RDEPUBLayout {
|
|
||||||
rawValue.lowercased().contains("pre-paginated") ? .fixed : .reflowable
|
|
||||||
}
|
|
||||||
|
|
||||||
private func orientation(from rawValue: String) -> RDEPUBOrientation? {
|
|
||||||
switch rawValue.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) {
|
|
||||||
case "portrait":
|
|
||||||
return .portrait
|
|
||||||
case "landscape":
|
|
||||||
return .landscape
|
|
||||||
case "auto":
|
|
||||||
return .auto
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum XMLSection {
|
|
||||||
case metadata
|
|
||||||
case manifest
|
|
||||||
case spine
|
|
||||||
case other
|
|
||||||
}
|
|
||||||
|
|
||||||
enum XMLName {
|
|
||||||
|
|
||||||
static func localName(from rawName: String) -> String {
|
|
||||||
rawName.split(separator: ":").last.map(String.init) ?? rawName
|
|
||||||
}
|
|
||||||
|
|
||||||
static func tokenize(_ rawValue: String?) -> [String] {
|
|
||||||
guard let rawValue else { return [] }
|
|
||||||
return rawValue
|
|
||||||
.split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" })
|
|
||||||
.map(String.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func refinedID(from rawValue: String) -> String? {
|
|
||||||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard trimmed.hasPrefix("#") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return String(trimmed.dropFirst())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final class XMLParserContext {
|
|
||||||
|
|
||||||
private var elementStack: [String] = []
|
|
||||||
|
|
||||||
private var currentCharacters = ""
|
|
||||||
|
|
||||||
var trimmedCharacters: String {
|
|
||||||
currentCharacters.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startElement(named rawName: String) -> String {
|
|
||||||
let name = XMLName.localName(from: rawName)
|
|
||||||
elementStack.append(name)
|
|
||||||
currentCharacters = ""
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendCharacters(_ string: String) {
|
|
||||||
currentCharacters += string
|
|
||||||
}
|
|
||||||
|
|
||||||
func endElement() {
|
|
||||||
if !elementStack.isEmpty {
|
|
||||||
_ = elementStack.removeLast()
|
|
||||||
}
|
|
||||||
currentCharacters = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsElement(named name: String) -> Bool {
|
|
||||||
elementStack.contains(name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-46
@@ -1,46 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension RDEPUBParser {
|
|
||||||
|
|
||||||
public func readingProfile() -> RDEPUBReadingProfile {
|
|
||||||
if metadata.layout == .fixed {
|
|
||||||
return .webFixedLayout
|
|
||||||
}
|
|
||||||
return hasInteractiveContent() ? .webInteractive : .textReflowable
|
|
||||||
}
|
|
||||||
|
|
||||||
public func hasInteractiveContent() -> Bool {
|
|
||||||
let interactiveMediaTypes = [
|
|
||||||
"application/javascript",
|
|
||||||
"text/javascript",
|
|
||||||
"application/ecmascript"
|
|
||||||
]
|
|
||||||
if manifest.values.contains(where: { item in
|
|
||||||
interactiveMediaTypes.contains(item.mediaType.lowercased()) || item.properties.contains("scripted")
|
|
||||||
}) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in spine where item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) {
|
|
||||||
guard let html = htmlString(forRelativePath: item.href) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if containsInteractiveMarkup(html) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func containsInteractiveMarkup(_ html: String) -> Bool {
|
|
||||||
let interactivePattern = #"<(script|iframe|video|audio|canvas|object|embed)\b|\bon(load|click|touchstart|touchend|mouseover|submit|change|input)=|hype_generated_script|swiper|webview"#
|
|
||||||
if html.range(of: interactivePattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let dynamicSVGPattern = #"<svg\b[^>]*>[\s\S]*?(<(script|foreignObject|animate|animateMotion|animateTransform|set)\b|\bon(load|click|touchstart|touchend|mouseover)=)"#
|
|
||||||
return html.range(of: dynamicSVGPattern, options: [.regularExpression, .caseInsensitive]) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
extension RDEPUBParser {
|
|
||||||
|
|
||||||
public func manifestItem(for id: String) -> RDEPUBManifestItem? {
|
|
||||||
manifest[id]
|
|
||||||
}
|
|
||||||
|
|
||||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
|
||||||
let target = href.components(separatedBy: "#").first ?? href
|
|
||||||
return manifest.values.first { item in
|
|
||||||
normalize(href: item.href, relativeTo: opfDirectoryURL ?? URL(fileURLWithPath: "/")) == target
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func href(forSpineIndex index: Int) -> String? {
|
|
||||||
guard spine.indices.contains(index) else { return nil }
|
|
||||||
return spine[index].href
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
|
||||||
guard let opfDirectoryURL else { return nil }
|
|
||||||
let path = relativePath.components(separatedBy: "#").first ?? relativePath
|
|
||||||
guard !path.isEmpty else { return nil }
|
|
||||||
let resolvedURL = URL(fileURLWithPath: path, relativeTo: opfDirectoryURL).standardizedFileURL
|
|
||||||
guard let rootURL = extractionRootURL?.standardizedFileURL else {
|
|
||||||
return resolvedURL
|
|
||||||
}
|
|
||||||
guard resolvedURL.path.hasPrefix(rootURL.path) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return resolvedURL
|
|
||||||
}
|
|
||||||
|
|
||||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
|
||||||
let normalizedPath = relativePath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard !normalizedPath.isEmpty else { return nil }
|
|
||||||
guard let encodedPath = normalizedPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var components = URLComponents()
|
|
||||||
components.scheme = RDEPUBResourceURLSchemeHandler.scheme
|
|
||||||
components.host = RDEPUBResourceURLSchemeHandler.host
|
|
||||||
components.percentEncodedPath = "/" + encodedPath
|
|
||||||
return components.url
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
|
||||||
guard resourceURL.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
|
||||||
resourceURL.host == RDEPUBResourceURLSchemeHandler.host else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let relativePath = resourceURL.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
|
||||||
return fileURL(forRelativePath: relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func coverImage() -> UIImage? {
|
|
||||||
let coverCandidates = manifest.values.filter { item in
|
|
||||||
item.properties.contains("cover-image") || item.id.lowercased().contains("cover")
|
|
||||||
}
|
|
||||||
for item in coverCandidates {
|
|
||||||
if let fileURL = fileURL(forRelativePath: item.href),
|
|
||||||
let data = resourceData(at: fileURL),
|
|
||||||
let image = UIImage(data: data) {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func htmlString(forSpineIndex index: Int) -> String? {
|
|
||||||
guard spine.indices.contains(index) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return htmlString(forRelativePath: spine[index].href)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func htmlString(forRelativePath relativePath: String) -> String? {
|
|
||||||
guard let fileURL = fileURL(forRelativePath: relativePath) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if let provided = providedResourceData(at: fileURL) {
|
|
||||||
return String(data: provided, encoding: .utf8)
|
|
||||||
?? String(data: provided, encoding: .utf16)
|
|
||||||
}
|
|
||||||
return try? String(contentsOf: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalize(href: String, relativeTo directoryURL: URL) -> String {
|
|
||||||
guard let resolvedURL = URL(string: href, relativeTo: directoryURL)?.standardizedFileURL else {
|
|
||||||
return href
|
|
||||||
}
|
|
||||||
let prefix = directoryURL.standardizedFileURL.path + "/"
|
|
||||||
if resolvedURL.path.hasPrefix(prefix) {
|
|
||||||
return String(resolvedURL.path.dropFirst(prefix.count))
|
|
||||||
}
|
|
||||||
return href
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension RDEPUBParser {
|
|
||||||
|
|
||||||
func parseTOC(from packageDocument: OPFPackageDocument, opfURL: URL) -> [EPUBTableOfContentsItem] {
|
|
||||||
let opfDirectoryURL = opfURL.deletingLastPathComponent()
|
|
||||||
|
|
||||||
if let ncxItem = packageDocument.ncxItem ?? packageDocument.manifest.first(where: { $0.isNCX }) {
|
|
||||||
let ncxURL = opfDirectoryURL.appendingPathComponent(ncxItem.href)
|
|
||||||
let items = parseNCXDocumentItems(at: ncxURL)
|
|
||||||
if !items.isEmpty {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let navItem = packageDocument.navigationItem {
|
|
||||||
let navURL = opfDirectoryURL.appendingPathComponent(navItem.href)
|
|
||||||
let items = parseNavDocumentItems(at: navURL, baseURL: navURL.deletingLastPathComponent())
|
|
||||||
if !items.isEmpty {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spine.map { EPUBTableOfContentsItem(title: $0.title, href: $0.href) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNCXDocumentItems(at ncxURL: URL) -> [EPUBTableOfContentsItem] {
|
|
||||||
guard FileManager.default.fileExists(atPath: ncxURL.path),
|
|
||||||
let parser = XMLParser(contentsOf: ncxURL) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let delegate = NCXParserDelegate(baseURL: ncxURL.deletingLastPathComponent()) { [weak self] href, baseURL in
|
|
||||||
self?.normalizeTOCHref(href, relativeTo: baseURL)
|
|
||||||
}
|
|
||||||
parser.shouldProcessNamespaces = false
|
|
||||||
parser.delegate = delegate
|
|
||||||
guard parser.parse() else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return delegate.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNavDocumentItems(at navURL: URL, baseURL: URL) -> [EPUBTableOfContentsItem] {
|
|
||||||
guard FileManager.default.fileExists(atPath: navURL.path),
|
|
||||||
let parser = XMLParser(contentsOf: navURL) else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
let delegate = NavDocumentParserDelegate(baseURL: baseURL) { [weak self] href, currentBaseURL in
|
|
||||||
self?.normalizeTOCHref(href, relativeTo: currentBaseURL)
|
|
||||||
}
|
|
||||||
parser.shouldProcessNamespaces = false
|
|
||||||
parser.delegate = delegate
|
|
||||||
guard parser.parse() else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
return delegate.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeTOCHref(_ href: String, relativeTo baseURL: URL) -> String? {
|
|
||||||
let components = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
|
||||||
let pathPart = components.first.map(String.init) ?? href
|
|
||||||
let fragment = components.count > 1 ? String(components[1]) : nil
|
|
||||||
|
|
||||||
let normalizedPath: String
|
|
||||||
if pathPart.isEmpty {
|
|
||||||
normalizedPath = ""
|
|
||||||
} else {
|
|
||||||
normalizedPath = normalize(href: pathPart, relativeTo: baseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let fragment, !fragment.isEmpty {
|
|
||||||
if normalizedPath.isEmpty {
|
|
||||||
return "#\(fragment)"
|
|
||||||
}
|
|
||||||
return "\(normalizedPath)#\(fragment)"
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedPath.isEmpty ? nil : normalizedPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class TOCNode {
|
|
||||||
var title = ""
|
|
||||||
var href: String?
|
|
||||||
var children: [TOCNode] = []
|
|
||||||
|
|
||||||
func asItem() -> EPUBTableOfContentsItem? {
|
|
||||||
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let href, !href.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return EPUBTableOfContentsItem(
|
|
||||||
title: trimmedTitle.isEmpty ? href : trimmedTitle,
|
|
||||||
href: href,
|
|
||||||
children: children.compactMap { $0.asItem() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class TOCTreeBuilder {
|
|
||||||
|
|
||||||
private var nodeStack: [TOCNode] = []
|
|
||||||
|
|
||||||
private var rootNodes: [TOCNode] = []
|
|
||||||
|
|
||||||
var items: [EPUBTableOfContentsItem] {
|
|
||||||
rootNodes.compactMap { $0.asItem() }
|
|
||||||
}
|
|
||||||
|
|
||||||
func beginNode() {
|
|
||||||
nodeStack.append(TOCNode())
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateCurrentHref(_ href: String?) {
|
|
||||||
nodeStack.last?.href = href
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendCurrentTitle(_ text: String) {
|
|
||||||
nodeStack.last?.title += text
|
|
||||||
}
|
|
||||||
|
|
||||||
func endNode() {
|
|
||||||
guard let node = nodeStack.popLast() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let parent = nodeStack.last {
|
|
||||||
parent.children.append(node)
|
|
||||||
} else {
|
|
||||||
rootNodes.append(node)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
nodeStack.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class NCXParserDelegate: NSObject, XMLParserDelegate {
|
|
||||||
private let baseURL: URL
|
|
||||||
private let hrefNormalizer: (String, URL) -> String?
|
|
||||||
private let treeBuilder = TOCTreeBuilder()
|
|
||||||
private var readingLabelText = false
|
|
||||||
|
|
||||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
|
||||||
self.baseURL = baseURL
|
|
||||||
self.hrefNormalizer = hrefNormalizer
|
|
||||||
}
|
|
||||||
|
|
||||||
var items: [EPUBTableOfContentsItem] {
|
|
||||||
treeBuilder.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didStartElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?,
|
|
||||||
attributes attributeDict: [String: String] = [:]
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "navPoint":
|
|
||||||
treeBuilder.beginNode()
|
|
||||||
case "content":
|
|
||||||
guard let src = attributeDict["src"] else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
treeBuilder.updateCurrentHref(hrefNormalizer(src, baseURL))
|
|
||||||
case "text":
|
|
||||||
readingLabelText = true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
||||||
if readingLabelText {
|
|
||||||
treeBuilder.appendCurrentTitle(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didEndElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "text":
|
|
||||||
readingLabelText = false
|
|
||||||
case "navPoint":
|
|
||||||
treeBuilder.endNode()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class NavDocumentParserDelegate: NSObject, XMLParserDelegate {
|
|
||||||
private let baseURL: URL
|
|
||||||
private let hrefNormalizer: (String, URL) -> String?
|
|
||||||
private var insideTOCNav = false
|
|
||||||
private var listDepth = 0
|
|
||||||
private let treeBuilder = TOCTreeBuilder()
|
|
||||||
private var collectingAnchorText = false
|
|
||||||
|
|
||||||
init(baseURL: URL, hrefNormalizer: @escaping (String, URL) -> String?) {
|
|
||||||
self.baseURL = baseURL
|
|
||||||
self.hrefNormalizer = hrefNormalizer
|
|
||||||
}
|
|
||||||
|
|
||||||
var items: [EPUBTableOfContentsItem] {
|
|
||||||
treeBuilder.items
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didStartElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?,
|
|
||||||
attributes attributeDict: [String: String] = [:]
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "nav":
|
|
||||||
insideTOCNav = isTOCNav(attributes: attributeDict)
|
|
||||||
case "ol":
|
|
||||||
if insideTOCNav {
|
|
||||||
listDepth += 1
|
|
||||||
}
|
|
||||||
case "li":
|
|
||||||
if insideTOCNav, listDepth > 0 {
|
|
||||||
treeBuilder.beginNode()
|
|
||||||
}
|
|
||||||
case "a":
|
|
||||||
guard insideTOCNav else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
collectingAnchorText = true
|
|
||||||
if let href = attributeDict["href"] {
|
|
||||||
treeBuilder.updateCurrentHref(hrefNormalizer(href, baseURL))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
|
||||||
if collectingAnchorText {
|
|
||||||
treeBuilder.appendCurrentTitle(string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parser(
|
|
||||||
_ parser: XMLParser,
|
|
||||||
didEndElement elementName: String,
|
|
||||||
namespaceURI: String?,
|
|
||||||
qualifiedName qName: String?
|
|
||||||
) {
|
|
||||||
let name = XMLName.localName(from: qName ?? elementName)
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "a":
|
|
||||||
collectingAnchorText = false
|
|
||||||
case "li":
|
|
||||||
guard insideTOCNav else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
treeBuilder.endNode()
|
|
||||||
case "ol":
|
|
||||||
if insideTOCNav {
|
|
||||||
listDepth = max(0, listDepth - 1)
|
|
||||||
}
|
|
||||||
case "nav":
|
|
||||||
insideTOCNav = false
|
|
||||||
listDepth = 0
|
|
||||||
treeBuilder.reset()
|
|
||||||
collectingAnchorText = false
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTOCNav(attributes: [String: String]) -> Bool {
|
|
||||||
if let epubType = attributes["epub:type"]?.lowercased(), epubType.contains("toc") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let type = attributes["type"]?.lowercased(), type.contains("toc") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if let role = attributes["role"]?.lowercased(), role.contains("doc-toc") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public final class RDEPUBParser {
|
|
||||||
|
|
||||||
public internal(set) var metadata = RDEPUBMetadata()
|
|
||||||
|
|
||||||
public internal(set) var manifest: [String: RDEPUBManifestItem] = [:]
|
|
||||||
|
|
||||||
public internal(set) var spine: [RDEPUBSpineItem] = []
|
|
||||||
|
|
||||||
public internal(set) var tableOfContents: [EPUBTableOfContentsItem] = []
|
|
||||||
|
|
||||||
public internal(set) var extractionRootURL: URL?
|
|
||||||
|
|
||||||
public internal(set) var opfURL: URL?
|
|
||||||
|
|
||||||
public var opfDirectoryURL: URL? {
|
|
||||||
opfURL?.deletingLastPathComponent()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 加密资源数据提供者;nil 时所有资源按明文直读。
|
|
||||||
/// 必须在 `parse(epubURL:)` 之前设置(推荐通过 `RDEPUBReaderDependencies.live(resourceDataProvider:)` 注入)。
|
|
||||||
public var resourceDataProvider: RDEPUBResourceDataProvider? {
|
|
||||||
didSet {
|
|
||||||
guard resourceDataProvider != nil else { return }
|
|
||||||
RDEPUBResourceAccessRegistry.register(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
|
||||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
|
||||||
resourceDataProvider?.resourceData(at: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
|
||||||
public func resourceData(at fileURL: URL) -> Data? {
|
|
||||||
if let provided = providedResourceData(at: fileURL) {
|
|
||||||
return provided
|
|
||||||
}
|
|
||||||
return try? Data(contentsOf: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
public init() {}
|
|
||||||
|
|
||||||
// MARK: - Cache Management
|
|
||||||
|
|
||||||
/// The base directory used for EPUB extraction caches.
|
|
||||||
public static var extractionCacheBaseDirectory: URL {
|
|
||||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
||||||
.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
||||||
?? FileManager.default.temporaryDirectory.appendingPathComponent("ssreaderview-epub", isDirectory: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes all EPUB extraction caches from disk.
|
|
||||||
/// Call this when the host app wants to free disk space, e.g. on didReceiveMemoryWarning
|
|
||||||
/// or during cleanup when the reader is no longer needed.
|
|
||||||
public static func cleanExtractionCache() throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let baseURL = extractionCacheBaseDirectory
|
|
||||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
|
||||||
try fileManager.removeItem(at: baseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evicts extraction caches until total disk usage is below the given threshold,
|
|
||||||
/// removing least-recently-accessed directories first.
|
|
||||||
/// - Parameter maxBytes: Maximum total bytes for all extraction caches. Defaults to 500 MB.
|
|
||||||
public static func evictExtractionCache(maxBytes: UInt64 = 500 * 1024 * 1024) throws {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let baseURL = extractionCacheBaseDirectory
|
|
||||||
guard fileManager.fileExists(atPath: baseURL.path) else { return }
|
|
||||||
|
|
||||||
let contents = try fileManager.contentsOfDirectory(
|
|
||||||
at: baseURL,
|
|
||||||
includingPropertiesForKeys: [.contentAccessDateKey, .isDirectoryKey],
|
|
||||||
options: .skipsHiddenFiles
|
|
||||||
)
|
|
||||||
|
|
||||||
var entries: [(url: URL, accessDate: Date, size: UInt64)] = []
|
|
||||||
var totalSize: UInt64 = 0
|
|
||||||
|
|
||||||
for directoryURL in contents {
|
|
||||||
guard let resourceValues = try? directoryURL.resourceValues(forKeys: [.isDirectoryKey, .contentAccessDateKey]),
|
|
||||||
resourceValues.isDirectory == true else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let directorySize = directoryURL.directorySize
|
|
||||||
let accessDate = resourceValues.contentAccessDate ?? Date.distantPast
|
|
||||||
entries.append((url: directoryURL, accessDate: accessDate, size: directorySize))
|
|
||||||
totalSize += directorySize
|
|
||||||
}
|
|
||||||
|
|
||||||
guard totalSize > maxBytes else { return }
|
|
||||||
|
|
||||||
entries.sort { $0.accessDate < $1.accessDate }
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
guard totalSize > maxBytes else { break }
|
|
||||||
try? fileManager.removeItem(at: entry.url)
|
|
||||||
totalSize -= entry.size
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func makePublication() -> RDEPUBPublication {
|
|
||||||
RDEPUBPublication(parser: self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parse(epubURL: URL) throws {
|
|
||||||
reset()
|
|
||||||
|
|
||||||
let extractionURL = try extractArchiveIfNeeded(epubURL: epubURL)
|
|
||||||
extractionRootURL = extractionURL
|
|
||||||
|
|
||||||
let containerURL = extractionURL.appendingPathComponent("META-INF/container.xml")
|
|
||||||
guard FileManager.default.fileExists(atPath: containerURL.path) else {
|
|
||||||
throw RDEPUBParserError.missingContainerXML
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootFilePath = try parseContainerRootFile(at: containerURL)
|
|
||||||
let packageURL = extractionURL.appendingPathComponent(rootFilePath)
|
|
||||||
guard FileManager.default.fileExists(atPath: packageURL.path) else {
|
|
||||||
throw RDEPUBParserError.invalidRootFilePath(rootFilePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
try parseOPF(at: packageURL)
|
|
||||||
|
|
||||||
// 兜底:OPF 未声明朝向时,回退读取 iBooks display-options
|
|
||||||
if metadata.orientation == nil {
|
|
||||||
let displayOptionsURL = extractionURL
|
|
||||||
.appendingPathComponent("META-INF/com.apple.ibooks.display-options.xml")
|
|
||||||
if let orientation = parseIBooksOrientation(at: displayOptionsURL) {
|
|
||||||
metadata.orientation = orientation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 `com.apple.ibooks.display-options.xml` 中的 orientation-lock。
|
|
||||||
/// 结构:platform[@name] / option[@name="orientation-lock"] 文本值。
|
|
||||||
private func parseIBooksOrientation(at url: URL) -> RDEPUBOrientation? {
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path),
|
|
||||||
let parser = XMLParser(contentsOf: url) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let delegate = IBooksDisplayOptionsParserDelegate()
|
|
||||||
parser.delegate = delegate
|
|
||||||
guard parser.parse() else { return nil }
|
|
||||||
switch delegate.orientationLock?.lowercased() {
|
|
||||||
case "portrait-orientation", "portrait":
|
|
||||||
return .portrait
|
|
||||||
case "landscape-orientation", "landscape":
|
|
||||||
return .landscape
|
|
||||||
case "none":
|
|
||||||
return .auto
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parseOPF(at opfURL: URL) throws {
|
|
||||||
resetPublicationState()
|
|
||||||
|
|
||||||
guard let parser = XMLParser(contentsOf: opfURL) else {
|
|
||||||
throw RDEPUBParserError.invalidXML(opfURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
let delegate = OPFPackageParserDelegate()
|
|
||||||
parser.shouldProcessNamespaces = false
|
|
||||||
parser.delegate = delegate
|
|
||||||
|
|
||||||
guard parser.parse() else {
|
|
||||||
throw parser.parserError ?? RDEPUBParserError.invalidXML(opfURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
let packageDocument = delegate.packageDocument()
|
|
||||||
self.opfURL = opfURL
|
|
||||||
self.metadata = packageDocument.metadata
|
|
||||||
// 与 packageDocument 使用同一份已去重索引,避免重复 manifest id 再次触发断言。
|
|
||||||
self.manifest = packageDocument.manifestByID
|
|
||||||
self.spine = try buildSpine(from: packageDocument, opfURL: opfURL)
|
|
||||||
self.tableOfContents = parseTOC(from: packageDocument, opfURL: opfURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parseTOC() -> [EPUBTableOfContentsItem] {
|
|
||||||
tableOfContents
|
|
||||||
}
|
|
||||||
|
|
||||||
public func parseNavDocument(_ navURL: URL, baseURL: URL? = nil) -> [EPUBTableOfContentsItem] {
|
|
||||||
parseNavDocumentItems(at: navURL, baseURL: baseURL ?? navURL.deletingLastPathComponent())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - URL Directory Size Extension
|
|
||||||
|
|
||||||
private extension URL {
|
|
||||||
var directorySize: UInt64 {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
guard let enumerator = fileManager.enumerator(at: self, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles], errorHandler: nil) else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var totalSize: UInt64 = 0
|
|
||||||
for case let fileURL as URL in enumerator {
|
|
||||||
guard let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
||||||
let fileSize = resourceValues.fileSize else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
totalSize += UInt64(fileSize)
|
|
||||||
}
|
|
||||||
return totalSize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public struct RDEPUBPreferences: Equatable {
|
|
||||||
|
|
||||||
public var fontSize: CGFloat
|
|
||||||
|
|
||||||
public var lineHeightMultiple: CGFloat
|
|
||||||
|
|
||||||
public var reflowableContentInsets: UIEdgeInsets
|
|
||||||
|
|
||||||
public var fixedContentInset: UIEdgeInsets
|
|
||||||
|
|
||||||
public var themeBackgroundColor: String?
|
|
||||||
|
|
||||||
public var themeTextColor: String?
|
|
||||||
|
|
||||||
public var fixedBackgroundColor: String?
|
|
||||||
|
|
||||||
public var fixedLayoutFit: RDEPUBFixedLayoutFit
|
|
||||||
|
|
||||||
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
|
|
||||||
|
|
||||||
public var numberOfColumns: Int
|
|
||||||
|
|
||||||
public var columnGap: CGFloat
|
|
||||||
|
|
||||||
public init(
|
|
||||||
fontSize: CGFloat,
|
|
||||||
lineHeightMultiple: CGFloat,
|
|
||||||
reflowableContentInsets: UIEdgeInsets,
|
|
||||||
fixedContentInset: UIEdgeInsets,
|
|
||||||
numberOfColumns: Int = 1,
|
|
||||||
columnGap: CGFloat = 20,
|
|
||||||
themeBackgroundColor: String? = nil,
|
|
||||||
themeTextColor: String? = nil,
|
|
||||||
fixedBackgroundColor: String? = nil,
|
|
||||||
fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
|
|
||||||
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic
|
|
||||||
) {
|
|
||||||
self.fontSize = fontSize
|
|
||||||
self.lineHeightMultiple = lineHeightMultiple
|
|
||||||
self.reflowableContentInsets = reflowableContentInsets
|
|
||||||
self.fixedContentInset = fixedContentInset
|
|
||||||
self.numberOfColumns = max(1, numberOfColumns)
|
|
||||||
self.columnGap = max(0, columnGap)
|
|
||||||
self.themeBackgroundColor = themeBackgroundColor
|
|
||||||
self.themeTextColor = themeTextColor
|
|
||||||
self.fixedBackgroundColor = fixedBackgroundColor
|
|
||||||
self.fixedLayoutFit = fixedLayoutFit
|
|
||||||
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
|
|
||||||
}
|
|
||||||
|
|
||||||
public func presentationStyle(viewportSize: CGSize) -> RDEPUBPresentationStyle {
|
|
||||||
RDEPUBPresentationStyle(
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInsets: reflowableContentInsets,
|
|
||||||
fontSize: fontSize,
|
|
||||||
lineHeightMultiple: lineHeightMultiple,
|
|
||||||
numberOfColumns: numberOfColumns,
|
|
||||||
columnGap: columnGap,
|
|
||||||
themeBackgroundColor: themeBackgroundColor,
|
|
||||||
themeTextColor: themeTextColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func renderRequest(
|
|
||||||
for page: EPUBPage,
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
targetLocation: RDEPUBLocation? = nil,
|
|
||||||
targetHighlightRangeInfo: String? = nil,
|
|
||||||
highlights: [RDEPUBHighlight] = [],
|
|
||||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
|
||||||
) -> RDEPUBRenderRequest? {
|
|
||||||
if publication.layout == .fixed, let fixedSpread = page.fixedSpread {
|
|
||||||
return .fixed(
|
|
||||||
RDEPUBFixedRenderRequest(
|
|
||||||
spread: fixedSpread,
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInset: fixedContentInset,
|
|
||||||
backgroundColorCSS: fixedBackgroundColor,
|
|
||||||
fit: fixedLayoutFit,
|
|
||||||
searchPresentation: searchPresentation
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard publication.spine.indices.contains(page.spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return .reflowable(
|
|
||||||
RDEPUBReflowableRenderRequest(
|
|
||||||
spineIndex: page.spineIndex,
|
|
||||||
href: publication.spine[page.spineIndex].href,
|
|
||||||
pageIndex: page.pageIndexInChapter,
|
|
||||||
totalPagesInChapter: page.totalPagesInChapter,
|
|
||||||
presentation: presentationStyle(viewportSize: viewportSize),
|
|
||||||
targetLocation: targetLocation,
|
|
||||||
targetHighlightRangeInfo: targetHighlightRangeInfo,
|
|
||||||
highlights: highlights,
|
|
||||||
searchPresentation: searchPresentation
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public final class RDEPUBPublication {
|
|
||||||
|
|
||||||
public let parser: RDEPUBParser
|
|
||||||
|
|
||||||
public let resourceResolver: RDEPUBResourceResolver
|
|
||||||
|
|
||||||
public init(parser: RDEPUBParser) {
|
|
||||||
self.parser = parser
|
|
||||||
self.resourceResolver = RDEPUBResourceResolver(parser: parser)
|
|
||||||
}
|
|
||||||
|
|
||||||
public var metadata: RDEPUBMetadata {
|
|
||||||
parser.metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
public var manifest: [String: RDEPUBManifestItem] {
|
|
||||||
parser.manifest
|
|
||||||
}
|
|
||||||
|
|
||||||
public var spine: [RDEPUBSpineItem] {
|
|
||||||
parser.spine
|
|
||||||
}
|
|
||||||
|
|
||||||
public var tableOfContents: [EPUBTableOfContentsItem] {
|
|
||||||
parser.tableOfContents
|
|
||||||
}
|
|
||||||
|
|
||||||
public var layout: RDEPUBLayout {
|
|
||||||
metadata.layout
|
|
||||||
}
|
|
||||||
|
|
||||||
public var readingProfile: RDEPUBReadingProfile {
|
|
||||||
parser.readingProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
public var readingProgression: RDEPUBReadingProgression {
|
|
||||||
metadata.readingProgression
|
|
||||||
}
|
|
||||||
|
|
||||||
public var bookIdentifier: String? {
|
|
||||||
metadata.identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fixedLayoutSpreadEnabled(for preferences: RDEPUBPreferences, viewportSize: CGSize) -> Bool {
|
|
||||||
guard layout == .fixed else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard metadata.spread?.lowercased() != "none" else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
switch preferences.fixedLayoutSpreadMode {
|
|
||||||
case .never:
|
|
||||||
return false
|
|
||||||
case .always:
|
|
||||||
return spine.count > 1
|
|
||||||
case .automatic:
|
|
||||||
return viewportSize.width > viewportSize.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func makeFixedSpreads(preferences: RDEPUBPreferences, viewportSize: CGSize) -> [EPUBFixedSpread] {
|
|
||||||
EPUBFixedSpread.makeSpreads(
|
|
||||||
spine: spine,
|
|
||||||
spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-322
@@ -1,322 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public final class RDEPUBReadingSession {
|
|
||||||
|
|
||||||
public typealias PaginationSnapshot = (pages: [EPUBPage], chapters: [EPUBChapterInfo])
|
|
||||||
|
|
||||||
public let publication: RDEPUBPublication
|
|
||||||
|
|
||||||
public private(set) var activePages: [EPUBPage] = []
|
|
||||||
|
|
||||||
public private(set) var activeChapters: [EPUBChapterInfo] = []
|
|
||||||
|
|
||||||
public private(set) var pendingNavigationLocation: RDEPUBLocation?
|
|
||||||
|
|
||||||
public private(set) var pendingNavigationPageNum: Int?
|
|
||||||
|
|
||||||
public private(set) var pendingNavigationHighlightRangeInfo: String?
|
|
||||||
|
|
||||||
public private(set) var currentViewport: RDEPUBViewport?
|
|
||||||
|
|
||||||
public private(set) var currentReadingContext: RDEPUBReadingContext?
|
|
||||||
|
|
||||||
public init(publication: RDEPUBPublication) {
|
|
||||||
self.publication = publication
|
|
||||||
}
|
|
||||||
|
|
||||||
public var resourceResolver: RDEPUBResourceResolver {
|
|
||||||
publication.resourceResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
public func resetRuntimeState() {
|
|
||||||
activePages = []
|
|
||||||
activeChapters = []
|
|
||||||
clearPendingNavigation()
|
|
||||||
currentViewport = nil
|
|
||||||
currentReadingContext = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setActiveSnapshot(_ snapshot: PaginationSnapshot) {
|
|
||||||
activePages = snapshot.pages
|
|
||||||
activeChapters = snapshot.chapters
|
|
||||||
}
|
|
||||||
|
|
||||||
public func clearPendingNavigation() {
|
|
||||||
pendingNavigationLocation = nil
|
|
||||||
pendingNavigationPageNum = nil
|
|
||||||
pendingNavigationHighlightRangeInfo = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pendingLocation(forPageNumber pageNumber: Int, spineIndex: Int?) -> RDEPUBLocation? {
|
|
||||||
guard pendingNavigationPageNum == pageNumber,
|
|
||||||
let pendingNavigationLocation else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let spineIndex else {
|
|
||||||
return pendingNavigationLocation
|
|
||||||
}
|
|
||||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
|
||||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pendingNavigationLocation
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pendingHighlightRangeInfo(forPageNumber pageNumber: Int, spineIndex: Int?) -> String? {
|
|
||||||
guard pendingNavigationPageNum == pageNumber,
|
|
||||||
let pendingNavigationHighlightRangeInfo,
|
|
||||||
!pendingNavigationHighlightRangeInfo.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let pendingNavigationLocation else {
|
|
||||||
return pendingNavigationHighlightRangeInfo
|
|
||||||
}
|
|
||||||
guard let spineIndex else {
|
|
||||||
return pendingNavigationHighlightRangeInfo
|
|
||||||
}
|
|
||||||
let pageHref = resourceResolver.href(forSpineIndex: spineIndex)
|
|
||||||
guard resourceResolver.normalizedHref(pageHref ?? "") == resourceResolver.normalizedHref(pendingNavigationLocation.href) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return pendingNavigationHighlightRangeInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pageContains(spineIndex: Int, in page: EPUBPage) -> Bool {
|
|
||||||
if let fixedSpread = page.fixedSpread {
|
|
||||||
return fixedSpread.resources.contains(where: { $0.spineIndex == spineIndex })
|
|
||||||
}
|
|
||||||
return page.spineIndex == spineIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fallbackLocation(for page: EPUBPage, bookIdentifier: String?) -> RDEPUBLocation? {
|
|
||||||
if let fixedSpread = page.fixedSpread {
|
|
||||||
return RDEPUBLocation(
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
href: fixedSpread.primaryResource.href,
|
|
||||||
progression: 0,
|
|
||||||
lastProgression: 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let href = resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let progression: Double
|
|
||||||
if page.totalPagesInChapter <= 1 {
|
|
||||||
progression = 0
|
|
||||||
} else {
|
|
||||||
progression = Double(page.pageIndexInChapter) / Double(page.totalPagesInChapter - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBLocation(
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
href: href,
|
|
||||||
progression: progression,
|
|
||||||
lastProgression: progression
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func currentVisibleLocation(currentPageNumber: Int, bookIdentifier: String?) -> RDEPUBLocation? {
|
|
||||||
guard currentPageNumber > 0,
|
|
||||||
activePages.indices.contains(currentPageNumber - 1) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fallbackLocation(for: activePages[currentPageNumber - 1], bookIdentifier: bookIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func initialSpineIndex(for location: RDEPUBLocation?) -> Int {
|
|
||||||
guard let location,
|
|
||||||
let normalizedHref = resourceResolver.normalizedHref(location.href),
|
|
||||||
let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == normalizedHref }) else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return spineIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
public func pageIndex(for location: RDEPUBLocation, bookIdentifier: String?) -> Int? {
|
|
||||||
guard location.bookIdentifier == nil || location.bookIdentifier == bookIdentifier else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let targetHref = resourceResolver.normalizedHref(location.href) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if publication.layout == .fixed {
|
|
||||||
return activePages.firstIndex(where: { page in
|
|
||||||
if let fixedSpread = page.fixedSpread {
|
|
||||||
return fixedSpread.contains(normalizedHref: targetHref, normalizer: { self.resourceResolver.normalizedHref($0) })
|
|
||||||
}
|
|
||||||
guard let href = self.resourceResolver.href(forSpineIndex: page.spineIndex) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return self.resourceResolver.normalizedHref(href) == targetHref
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let spineIndex = publication.spine.firstIndex(where: { resourceResolver.normalizedHref($0.href) == targetHref }) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidates = activePages.enumerated().filter { $0.element.spineIndex == spineIndex }
|
|
||||||
guard !candidates.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if candidates.count == 1 {
|
|
||||||
return candidates[0].offset
|
|
||||||
}
|
|
||||||
|
|
||||||
let navigationProgression = location.navigationProgression
|
|
||||||
let localIndex = min(
|
|
||||||
candidates.count - 1,
|
|
||||||
max(0, Int(round(navigationProgression * Double(candidates.count - 1))))
|
|
||||||
)
|
|
||||||
return candidates[localIndex].offset
|
|
||||||
}
|
|
||||||
|
|
||||||
public func queueNavigation(
|
|
||||||
to location: RDEPUBLocation,
|
|
||||||
relativeToSpineIndex spineIndex: Int? = nil,
|
|
||||||
bookIdentifier: String?,
|
|
||||||
targetHighlightRangeInfo: String? = nil
|
|
||||||
) -> Int? {
|
|
||||||
guard let normalizedLocation = resourceResolver.normalizedLocation(
|
|
||||||
location,
|
|
||||||
relativeToSpineIndex: spineIndex,
|
|
||||||
bookIdentifier: bookIdentifier
|
|
||||||
), let pageIndex = pageIndex(for: normalizedLocation, bookIdentifier: bookIdentifier) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasTargetHighlightRangeInfo = targetHighlightRangeInfo?.isEmpty == false
|
|
||||||
let shouldKeepPendingNavigation =
|
|
||||||
normalizedLocation.fragment != nil ||
|
|
||||||
normalizedLocation.rangeAnchor != nil ||
|
|
||||||
hasTargetHighlightRangeInfo
|
|
||||||
pendingNavigationLocation = shouldKeepPendingNavigation ? normalizedLocation : nil
|
|
||||||
pendingNavigationPageNum = shouldKeepPendingNavigation ? pageIndex + 1 : nil
|
|
||||||
pendingNavigationHighlightRangeInfo = hasTargetHighlightRangeInfo ? targetHighlightRangeInfo : nil
|
|
||||||
return pageIndex + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
public func updateReadingContext(
|
|
||||||
pageNumber: Int,
|
|
||||||
location: RDEPUBLocation,
|
|
||||||
spineIndex: Int,
|
|
||||||
chapterIndex: Int?,
|
|
||||||
bookIdentifier: String?
|
|
||||||
) {
|
|
||||||
let normalizedLocation = resourceResolver.normalizedLocation(
|
|
||||||
location,
|
|
||||||
relativeToSpineIndex: spineIndex,
|
|
||||||
bookIdentifier: bookIdentifier
|
|
||||||
) ?? RDEPUBLocation(
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
href: location.href,
|
|
||||||
progression: location.progression,
|
|
||||||
lastProgression: location.lastProgression,
|
|
||||||
fragment: location.fragment,
|
|
||||||
rangeAnchor: location.rangeAnchor,
|
|
||||||
cfi: location.cfi,
|
|
||||||
lastCFI: location.lastCFI,
|
|
||||||
rangeCFI: location.rangeCFI
|
|
||||||
)
|
|
||||||
|
|
||||||
let resource = RDEPUBViewportResource(
|
|
||||||
href: normalizedLocation.href,
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
progression: normalizedLocation.progression,
|
|
||||||
lastProgression: normalizedLocation.lastProgression,
|
|
||||||
fragment: normalizedLocation.fragment
|
|
||||||
)
|
|
||||||
let viewport = RDEPUBViewport(
|
|
||||||
resources: [resource],
|
|
||||||
visiblePageNumber: pageNumber,
|
|
||||||
chapterIndex: chapterIndex,
|
|
||||||
isFixedLayout: publication.layout == .fixed
|
|
||||||
)
|
|
||||||
currentViewport = viewport
|
|
||||||
currentReadingContext = RDEPUBReadingContext(
|
|
||||||
location: normalizedLocation,
|
|
||||||
viewport: viewport,
|
|
||||||
pageNumber: pageNumber,
|
|
||||||
chapterIndex: chapterIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
if pendingNavigationPageNum == pageNumber {
|
|
||||||
clearPendingNavigation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func currentReadingLocation(bookIdentifier: String?) -> RDEPUBLocation? {
|
|
||||||
currentReadingContext?.location ?? currentVisibleLocation(currentPageNumber: currentViewport?.visiblePageNumber ?? 0, bookIdentifier: bookIdentifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func makePaginationSnapshot(
|
|
||||||
pageCounts: [Int],
|
|
||||||
preferences: RDEPUBPreferences,
|
|
||||||
layoutContext: RDEPUBNavigatorLayoutContext
|
|
||||||
) -> PaginationSnapshot {
|
|
||||||
if publication.layout == .fixed {
|
|
||||||
let spreads = publication.makeFixedSpreads(
|
|
||||||
preferences: preferences,
|
|
||||||
viewportSize: layoutContext.viewportSize
|
|
||||||
)
|
|
||||||
|
|
||||||
let chapters = spreads.enumerated().map { spreadIndex, spread in
|
|
||||||
EPUBChapterInfo(
|
|
||||||
spineIndex: spread.primaryResource.spineIndex,
|
|
||||||
title: spread.primaryResource.title,
|
|
||||||
pageCount: 1
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let pages = spreads.enumerated().map { spreadIndex, spread in
|
|
||||||
EPUBPage(
|
|
||||||
spineIndex: spread.primaryResource.spineIndex,
|
|
||||||
chapterIndex: spreadIndex,
|
|
||||||
pageIndexInChapter: 0,
|
|
||||||
totalPagesInChapter: 1,
|
|
||||||
chapterTitle: spread.primaryResource.title,
|
|
||||||
fixedSpread: spread
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (pages, chapters)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pages: [EPUBPage] = []
|
|
||||||
var chapters: [EPUBChapterInfo] = []
|
|
||||||
|
|
||||||
for (spineIndex, pageCount) in pageCounts.enumerated() {
|
|
||||||
guard pageCount > 0, spineIndex < publication.spine.count else { continue }
|
|
||||||
|
|
||||||
let chapterIndex = chapters.count
|
|
||||||
let title = publication.spine[spineIndex].title
|
|
||||||
|
|
||||||
chapters.append(
|
|
||||||
EPUBChapterInfo(
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
title: title,
|
|
||||||
pageCount: pageCount
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for pageIndex in 0..<pageCount {
|
|
||||||
pages.append(
|
|
||||||
EPUBPage(
|
|
||||||
spineIndex: spineIndex,
|
|
||||||
chapterIndex: chapterIndex,
|
|
||||||
pageIndexInChapter: pageIndex,
|
|
||||||
totalPagesInChapter: pageCount,
|
|
||||||
chapterTitle: title,
|
|
||||||
fixedSpread: nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (pages, chapters)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public enum RDEPUBFixedLayoutFit: String, Codable {
|
|
||||||
case auto
|
|
||||||
case page
|
|
||||||
case width
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBFixedLayoutSpreadMode: String, Codable {
|
|
||||||
case automatic
|
|
||||||
case always
|
|
||||||
case never
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBPresentationStyle: Equatable {
|
|
||||||
|
|
||||||
public var viewportSize: CGSize
|
|
||||||
|
|
||||||
public var contentInsets: UIEdgeInsets
|
|
||||||
|
|
||||||
public var fontSize: CGFloat
|
|
||||||
|
|
||||||
public var lineHeightMultiple: CGFloat
|
|
||||||
|
|
||||||
public var numberOfColumns: Int
|
|
||||||
|
|
||||||
public var columnGap: CGFloat
|
|
||||||
|
|
||||||
public var themeBackgroundColor: String?
|
|
||||||
|
|
||||||
public var themeTextColor: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
viewportSize: CGSize,
|
|
||||||
contentInsets: UIEdgeInsets,
|
|
||||||
fontSize: CGFloat,
|
|
||||||
lineHeightMultiple: CGFloat,
|
|
||||||
numberOfColumns: Int = 1,
|
|
||||||
columnGap: CGFloat = 20,
|
|
||||||
themeBackgroundColor: String? = nil,
|
|
||||||
themeTextColor: String? = nil
|
|
||||||
) {
|
|
||||||
self.viewportSize = viewportSize
|
|
||||||
self.contentInsets = contentInsets
|
|
||||||
self.fontSize = fontSize
|
|
||||||
self.lineHeightMultiple = lineHeightMultiple
|
|
||||||
self.numberOfColumns = max(1, numberOfColumns)
|
|
||||||
self.columnGap = max(0, columnGap)
|
|
||||||
self.themeBackgroundColor = themeBackgroundColor
|
|
||||||
self.themeTextColor = themeTextColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBReflowableRenderRequest: Equatable {
|
|
||||||
|
|
||||||
public var spineIndex: Int
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var pageIndex: Int
|
|
||||||
|
|
||||||
public var totalPagesInChapter: Int
|
|
||||||
|
|
||||||
public var presentation: RDEPUBPresentationStyle
|
|
||||||
|
|
||||||
public var targetLocation: RDEPUBLocation?
|
|
||||||
|
|
||||||
public var targetHighlightRangeInfo: String?
|
|
||||||
|
|
||||||
public var highlights: [RDEPUBHighlight]
|
|
||||||
|
|
||||||
public var searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
spineIndex: Int,
|
|
||||||
href: String,
|
|
||||||
pageIndex: Int,
|
|
||||||
totalPagesInChapter: Int,
|
|
||||||
presentation: RDEPUBPresentationStyle,
|
|
||||||
targetLocation: RDEPUBLocation? = nil,
|
|
||||||
targetHighlightRangeInfo: String? = nil,
|
|
||||||
highlights: [RDEPUBHighlight] = [],
|
|
||||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
|
||||||
) {
|
|
||||||
self.spineIndex = spineIndex
|
|
||||||
self.href = href
|
|
||||||
self.pageIndex = pageIndex
|
|
||||||
self.totalPagesInChapter = totalPagesInChapter
|
|
||||||
self.presentation = presentation
|
|
||||||
self.targetLocation = targetLocation
|
|
||||||
self.targetHighlightRangeInfo = targetHighlightRangeInfo
|
|
||||||
self.highlights = highlights
|
|
||||||
self.searchPresentation = searchPresentation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBFixedRenderRequest: Equatable {
|
|
||||||
|
|
||||||
public var spread: EPUBFixedSpread
|
|
||||||
|
|
||||||
public var viewportSize: CGSize
|
|
||||||
|
|
||||||
public var contentInset: UIEdgeInsets
|
|
||||||
|
|
||||||
public var backgroundColorCSS: String?
|
|
||||||
|
|
||||||
public var fit: RDEPUBFixedLayoutFit
|
|
||||||
|
|
||||||
public var searchPresentation: RDEPUBSearchPresentation?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
spread: EPUBFixedSpread,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
contentInset: UIEdgeInsets,
|
|
||||||
backgroundColorCSS: String? = nil,
|
|
||||||
fit: RDEPUBFixedLayoutFit = .page,
|
|
||||||
searchPresentation: RDEPUBSearchPresentation? = nil
|
|
||||||
) {
|
|
||||||
self.spread = spread
|
|
||||||
self.viewportSize = viewportSize
|
|
||||||
self.contentInset = contentInset
|
|
||||||
self.backgroundColorCSS = backgroundColorCSS
|
|
||||||
self.fit = fit
|
|
||||||
self.searchPresentation = searchPresentation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum RDEPUBRenderRequest: Equatable {
|
|
||||||
|
|
||||||
case reflowable(RDEPUBReflowableRenderRequest)
|
|
||||||
|
|
||||||
case fixed(RDEPUBFixedRenderRequest)
|
|
||||||
|
|
||||||
public var isFixedLayout: Bool {
|
|
||||||
if case .fixed = self {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
public var primarySpineIndex: Int {
|
|
||||||
switch self {
|
|
||||||
case .reflowable(let request):
|
|
||||||
return request.spineIndex
|
|
||||||
case .fixed(let request):
|
|
||||||
return request.spread.primaryResource.spineIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public var primaryHref: String {
|
|
||||||
switch self {
|
|
||||||
case .reflowable(let request):
|
|
||||||
return request.href
|
|
||||||
case .fixed(let request):
|
|
||||||
return request.spread.primaryResource.href
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 加密 EPUB 资源数据提供者。
|
|
||||||
///
|
|
||||||
/// 宿主 App 实现该协议以支持“解压目录内单文件加密”的 EPUB:
|
|
||||||
/// SDK 读取章节 HTML、图片、内联 CSS 等资源时会优先向 provider 索取解密后的数据,
|
|
||||||
/// provider 返回 nil 表示该文件无需解密,SDK 回退为直接读取磁盘明文
|
|
||||||
/// (明文书、或加密书内的明文文件均走该兜底路径,因此对明文书零影响)。
|
|
||||||
///
|
|
||||||
/// 密钥推导与解密算法完全由宿主实现,SDK 不持有任何密钥。
|
|
||||||
///
|
|
||||||
/// 注意:方法会在排版 / 渲染的后台队列被同步调用,实现必须线程安全,
|
|
||||||
/// 且除必要的文件 I/O 与解密计算外不得阻塞(例如不要在其中发起网络请求)。
|
|
||||||
public protocol RDEPUBResourceDataProvider: AnyObject {
|
|
||||||
|
|
||||||
/// 返回指定文件解密后的数据。
|
|
||||||
/// - Parameter fileURL: 解压目录内资源文件的绝对路径
|
|
||||||
/// - Returns: 解密后的数据;返回 nil 表示该文件不需要解密,由 SDK 直读磁盘
|
|
||||||
func resourceData(at fileURL: URL) -> Data?
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 加密资源访问登记表。
|
|
||||||
///
|
|
||||||
/// DTCoreText 在解析 `<img>` 时由自己内部加载图片文件,拿不到 parser 实例,
|
|
||||||
/// 因此这里以弱引用登记所有启用了 provider 的 parser,
|
|
||||||
/// 供 `RDEPUBDecryptingImageAttachment` 按文件路径反查所属书籍的 provider。
|
|
||||||
enum RDEPUBResourceAccessRegistry {
|
|
||||||
|
|
||||||
private struct WeakParserBox {
|
|
||||||
weak var parser: RDEPUBParser?
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let lock = NSLock()
|
|
||||||
|
|
||||||
private static var boxes: [WeakParserBox] = []
|
|
||||||
|
|
||||||
/// 登记启用了 provider 的 parser(重复登记会被去重,弱引用失效项顺带清理)
|
|
||||||
static func register(_ parser: RDEPUBParser) {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
boxes.removeAll { $0.parser == nil || $0.parser === parser }
|
|
||||||
boxes.append(WeakParserBox(parser: parser))
|
|
||||||
#if canImport(DTCoreText)
|
|
||||||
RDEPUBDecryptingImageAttachment.registerTagClassIfNeeded()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按文件路径反查 provider 并返回解密数据;
|
|
||||||
/// 仅当文件位于某个已登记 parser 的解压目录内才会命中
|
|
||||||
static func providedResourceData(at fileURL: URL) -> Data? {
|
|
||||||
let targetPath = fileURL.resolvingSymlinksInPath().standardizedFileURL.path
|
|
||||||
lock.lock()
|
|
||||||
let parsers = boxes.compactMap(\.parser)
|
|
||||||
lock.unlock()
|
|
||||||
for parser in parsers {
|
|
||||||
guard let rootURL = parser.extractionRootURL else { continue }
|
|
||||||
let rootPath = rootURL.resolvingSymlinksInPath().standardizedFileURL.path
|
|
||||||
guard targetPath.hasPrefix(rootPath + "/") else { continue }
|
|
||||||
return parser.providedResourceData(at: fileURL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-175
@@ -1,175 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public final class RDEPUBResourceResolver {
|
|
||||||
|
|
||||||
private let parser: RDEPUBParser
|
|
||||||
|
|
||||||
public init(parser: RDEPUBParser) {
|
|
||||||
self.parser = parser
|
|
||||||
}
|
|
||||||
|
|
||||||
public var opfDirectoryURL: URL? {
|
|
||||||
parser.opfDirectoryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fileURL(forRelativePath relativePath: String) -> URL? {
|
|
||||||
parser.fileURL(forRelativePath: relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func resourceURL(forRelativePath relativePath: String) -> URL? {
|
|
||||||
parser.resourceURL(forRelativePath: relativePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fileURL(forResourceURL resourceURL: URL) -> URL? {
|
|
||||||
parser.fileURL(forResourceURL: resourceURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 是否配置了加密资源数据提供者
|
|
||||||
public var hasResourceDataProvider: Bool {
|
|
||||||
parser.resourceDataProvider != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 仅经 provider 获取解密数据;无 provider 或 provider 判定无需解密时返回 nil
|
|
||||||
public func providedResourceData(at fileURL: URL) -> Data? {
|
|
||||||
parser.providedResourceData(at: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 统一资源读取入口:优先 provider 解密,其次磁盘明文
|
|
||||||
public func resourceData(at fileURL: URL) -> Data? {
|
|
||||||
parser.resourceData(at: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func normalizedHref(_ href: String, relativeToSpineIndex spineIndex: Int? = nil) -> String? {
|
|
||||||
guard let opfDirectoryURL else {
|
|
||||||
return href.components(separatedBy: "#").first
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathPart = href.components(separatedBy: "#").first ?? href
|
|
||||||
if pathPart.isEmpty {
|
|
||||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return parser.spine[spineIndex].href.components(separatedBy: "#").first
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseURL: URL
|
|
||||||
if let spineIndex, parser.spine.indices.contains(spineIndex) {
|
|
||||||
baseURL = opfDirectoryURL
|
|
||||||
.appendingPathComponent(parser.spine[spineIndex].href)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
} else {
|
|
||||||
baseURL = opfDirectoryURL
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
|
|
||||||
return pathPart
|
|
||||||
}
|
|
||||||
|
|
||||||
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
|
|
||||||
let resolvedPath = resolvedURL.path
|
|
||||||
if resolvedPath.hasPrefix(opfPath) {
|
|
||||||
return String(resolvedPath.dropFirst(opfPath.count))
|
|
||||||
}
|
|
||||||
return pathPart
|
|
||||||
}
|
|
||||||
|
|
||||||
public func normalizedHref(_ href: String, relativeToHref baseHref: String) -> String? {
|
|
||||||
guard let opfDirectoryURL else {
|
|
||||||
return href.components(separatedBy: "#").first
|
|
||||||
}
|
|
||||||
|
|
||||||
let pathPart = href.components(separatedBy: "#").first ?? href
|
|
||||||
let basePath = baseHref.components(separatedBy: "#").first ?? baseHref
|
|
||||||
let baseURL = opfDirectoryURL
|
|
||||||
.appendingPathComponent(basePath)
|
|
||||||
.deletingLastPathComponent()
|
|
||||||
|
|
||||||
guard let resolvedURL = URL(string: pathPart, relativeTo: baseURL)?.standardizedFileURL else {
|
|
||||||
return pathPart
|
|
||||||
}
|
|
||||||
|
|
||||||
let opfPath = opfDirectoryURL.standardizedFileURL.path + "/"
|
|
||||||
if resolvedURL.path.hasPrefix(opfPath) {
|
|
||||||
return String(resolvedURL.path.dropFirst(opfPath.count))
|
|
||||||
}
|
|
||||||
return pathPart
|
|
||||||
}
|
|
||||||
|
|
||||||
public func fileURL(forReference href: String, relativeToHref baseHref: String) -> URL? {
|
|
||||||
guard let normalizedHref = normalizedHref(href, relativeToHref: baseHref) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fileURL(forRelativePath: normalizedHref)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func normalizedLocation(
|
|
||||||
_ location: RDEPUBLocation,
|
|
||||||
relativeToSpineIndex spineIndex: Int? = nil,
|
|
||||||
bookIdentifier: String?
|
|
||||||
) -> RDEPUBLocation? {
|
|
||||||
let rawHref = location.href
|
|
||||||
let hrefParts = rawHref.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
|
||||||
let pathPart = hrefParts.first.map(String.init) ?? rawHref
|
|
||||||
let fragment = location.fragment ?? (hrefParts.count > 1 ? String(hrefParts[1]) : nil)
|
|
||||||
|
|
||||||
let normalizedTargetHref: String
|
|
||||||
if pathPart.isEmpty {
|
|
||||||
guard let spineIndex, parser.spine.indices.contains(spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
normalizedTargetHref = parser.spine[spineIndex].href.components(separatedBy: "#").first ?? parser.spine[spineIndex].href
|
|
||||||
} else {
|
|
||||||
guard let href = normalizedHref(pathPart, relativeToSpineIndex: spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
normalizedTargetHref = href
|
|
||||||
}
|
|
||||||
|
|
||||||
return RDEPUBLocation(
|
|
||||||
bookIdentifier: bookIdentifier,
|
|
||||||
href: normalizedTargetHref,
|
|
||||||
progression: location.progression,
|
|
||||||
lastProgression: location.lastProgression,
|
|
||||||
fragment: fragment,
|
|
||||||
rangeAnchor: location.rangeAnchor,
|
|
||||||
cfi: location.cfi,
|
|
||||||
lastCFI: location.lastCFI,
|
|
||||||
rangeCFI: location.rangeCFI
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func spineIndex(forNormalizedHref normalizedHref: String) -> Int? {
|
|
||||||
parser.spine.firstIndex { item in
|
|
||||||
self.normalizedHref(item.href) == normalizedHref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func spineIndex(for location: RDEPUBLocation) -> Int? {
|
|
||||||
guard let normalizedHref = normalizedHref(location.href) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return spineIndex(forNormalizedHref: normalizedHref)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func href(forSpineIndex spineIndex: Int) -> String? {
|
|
||||||
guard parser.spine.indices.contains(spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return parser.spine[spineIndex].href
|
|
||||||
}
|
|
||||||
|
|
||||||
public func title(forSpineIndex spineIndex: Int) -> String? {
|
|
||||||
guard parser.spine.indices.contains(spineIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return parser.spine[spineIndex].title
|
|
||||||
}
|
|
||||||
|
|
||||||
public func manifestItem(forHref href: String) -> RDEPUBManifestItem? {
|
|
||||||
let targetHref = normalizedHref(href) ?? href.components(separatedBy: "#").first ?? href
|
|
||||||
return parser.manifest.values.first {
|
|
||||||
normalizedHref($0.href) == targetHref
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-349
@@ -1,349 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
public final class RDEPUBResourceURLSchemeHandler: NSObject, WKURLSchemeHandler {
|
|
||||||
struct DebugMetrics {
|
|
||||||
let streamedResponses: Int
|
|
||||||
let inMemoryResponses: Int
|
|
||||||
let failures: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let scheme = "ss-reader"
|
|
||||||
|
|
||||||
public static let host = "book"
|
|
||||||
|
|
||||||
private weak var parser: RDEPUBParser?
|
|
||||||
|
|
||||||
private let fileManager = FileManager.default
|
|
||||||
|
|
||||||
private let syncQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler")
|
|
||||||
|
|
||||||
private let ioQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.io", qos: .utility)
|
|
||||||
|
|
||||||
private var activeTasks: [ObjectIdentifier: Bool] = [:]
|
|
||||||
private static let debugMetricsQueue = DispatchQueue(label: "com.ssreaderview.epub.scheme-handler.metrics")
|
|
||||||
private static var streamedResponseCount = 0
|
|
||||||
private static var inMemoryResponseCount = 0
|
|
||||||
private static var failureCount = 0
|
|
||||||
|
|
||||||
public init(parser: RDEPUBParser) {
|
|
||||||
self.parser = parser
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func resetDebugMetrics() {
|
|
||||||
debugMetricsQueue.sync {
|
|
||||||
streamedResponseCount = 0
|
|
||||||
inMemoryResponseCount = 0
|
|
||||||
failureCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func debugMetricsSnapshot() -> DebugMetrics {
|
|
||||||
debugMetricsQueue.sync {
|
|
||||||
DebugMetrics(
|
|
||||||
streamedResponses: streamedResponseCount,
|
|
||||||
inMemoryResponses: inMemoryResponseCount,
|
|
||||||
failures: failureCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
|
||||||
let taskID = ObjectIdentifier(urlSchemeTask as AnyObject)
|
|
||||||
syncQueue.sync {
|
|
||||||
activeTasks[taskID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestURL = urlSchemeTask.request.url
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "start")
|
|
||||||
|
|
||||||
guard let parser,
|
|
||||||
let requestURL,
|
|
||||||
let fileURL = parser.fileURL(forResourceURL: requestURL),
|
|
||||||
fileManager.fileExists(atPath: fileURL.path) else {
|
|
||||||
if let requestURL, Self.isOptionalResource(requestURL) {
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-optional-resource")
|
|
||||||
let response = URLResponse(
|
|
||||||
url: requestURL,
|
|
||||||
mimeType: Self.mimeType(for: requestURL.pathExtension),
|
|
||||||
expectedContentLength: 0,
|
|
||||||
textEncodingName: Self.textEncodingName(for: requestURL.pathExtension)
|
|
||||||
)
|
|
||||||
// H-08 fix: These calls happen synchronously in webView(_:start:) which
|
|
||||||
// runs on the same queue WebKit calls start on, so no thread violation here.
|
|
||||||
urlSchemeTask.didReceive(response)
|
|
||||||
urlSchemeTask.didReceive(Data())
|
|
||||||
urlSchemeTask.didFinish()
|
|
||||||
} else {
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, event: "missing-file")
|
|
||||||
Self.recordFailure()
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
|
||||||
}
|
|
||||||
clearTask(taskID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "resolved")
|
|
||||||
|
|
||||||
let fileSize = resourceSize(for: fileURL)
|
|
||||||
// 配置了加密资源 provider 时不能走流式分支:密文必须整体解密后再响应,
|
|
||||||
// 统一改走内存分支(内部会先询问 provider,明文文件仍按磁盘直读)
|
|
||||||
if let fileSize, fileSize > 524_288, parser.resourceDataProvider == nil {
|
|
||||||
respondWithStreaming(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask, fileSize: fileSize)
|
|
||||||
} else {
|
|
||||||
respondWithInMemoryData(fileURL: fileURL, requestURL: requestURL, taskID: taskID, urlSchemeTask: urlSchemeTask)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: urlSchemeTask.request.url, event: "stop")
|
|
||||||
clearTask(ObjectIdentifier(urlSchemeTask as AnyObject))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func clearTask(_ id: ObjectIdentifier) {
|
|
||||||
syncQueue.sync {
|
|
||||||
activeTasks[id] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isTaskActive(_ id: ObjectIdentifier) -> Bool {
|
|
||||||
syncQueue.sync {
|
|
||||||
activeTasks[id] == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resourceSize(for url: URL) -> UInt64? {
|
|
||||||
guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
|
|
||||||
let size = attrs[.size] as? UInt64 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
/// H-08 fix: Dispatch all WKURLSchemeTask completion callbacks to the main queue,
|
|
||||||
/// ensuring they run on the same serial queue that WebKit calls start() on.
|
|
||||||
/// Also: cancelled tasks now call didFailWithError(NSURLErrorCancelled) instead
|
|
||||||
/// of silently returning, satisfying the protocol requirement that every started
|
|
||||||
/// task must receive a completion callback.
|
|
||||||
private func respondWithInMemoryData(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask) {
|
|
||||||
ioQueue.async { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
// 优先向加密资源 provider 索取解密数据,provider 放行的明文文件仍直读磁盘
|
|
||||||
let data: Data
|
|
||||||
if let provided = self.parser?.providedResourceData(at: fileURL) {
|
|
||||||
data = provided
|
|
||||||
} else {
|
|
||||||
data = try Data(contentsOf: fileURL)
|
|
||||||
}
|
|
||||||
guard self.isTaskActive(taskID) else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let response = URLResponse(
|
|
||||||
url: requestURL,
|
|
||||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
|
||||||
expectedContentLength: data.count,
|
|
||||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
|
||||||
)
|
|
||||||
self.dispatchTaskSuccessCallback(
|
|
||||||
taskID: taskID,
|
|
||||||
urlSchemeTask: urlSchemeTask
|
|
||||||
) {
|
|
||||||
urlSchemeTask.didReceive(response)
|
|
||||||
urlSchemeTask.didReceive(data)
|
|
||||||
urlSchemeTask.didFinish()
|
|
||||||
}
|
|
||||||
Self.recordInMemoryResponse()
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished")
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
guard self.isTaskActive(taskID) else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Self.recordFailure()
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "failed", error: error)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.clearTask(taskID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func respondWithStreaming(fileURL: URL, requestURL: URL, taskID: ObjectIdentifier, urlSchemeTask: any WKURLSchemeTask, fileSize: UInt64) {
|
|
||||||
ioQueue.async { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let response = URLResponse(
|
|
||||||
url: requestURL,
|
|
||||||
mimeType: Self.mimeType(for: fileURL.pathExtension),
|
|
||||||
expectedContentLength: Int(fileSize),
|
|
||||||
textEncodingName: Self.textEncodingName(for: fileURL.pathExtension)
|
|
||||||
)
|
|
||||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
|
||||||
urlSchemeTask.didReceive(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let fileHandle = try? FileHandle(forReadingFrom: fileURL) else {
|
|
||||||
guard self.isTaskActive(taskID) else {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Self.recordFailure()
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorFileDoesNotExist))
|
|
||||||
}
|
|
||||||
self.clearTask(taskID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let chunkSize = 65_536
|
|
||||||
defer {
|
|
||||||
fileHandle.closeFile()
|
|
||||||
}
|
|
||||||
while true {
|
|
||||||
guard self.isTaskActive(taskID) else {
|
|
||||||
// H-08 fix: Task was cancelled. Send didFailWithError on main queue.
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
}
|
|
||||||
self.clearTask(taskID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let data = fileHandle.readData(ofLength: chunkSize)
|
|
||||||
if data.isEmpty { break }
|
|
||||||
self.dispatchTaskCallbackIfActive(taskID: taskID) {
|
|
||||||
urlSchemeTask.didReceive(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.dispatchTaskSuccessCallback(
|
|
||||||
taskID: taskID,
|
|
||||||
urlSchemeTask: urlSchemeTask
|
|
||||||
) {
|
|
||||||
urlSchemeTask.didFinish()
|
|
||||||
}
|
|
||||||
Self.recordStreamedResponse()
|
|
||||||
RDEPUBWebViewDebug.logSchemeTask("ResourceScheme", requestURL: requestURL, fileURL: fileURL, event: "finished-streaming")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dispatchTaskCallbackIfActive(taskID: ObjectIdentifier, _ callback: @escaping () -> Void) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self, self.isTaskActive(taskID) else { return }
|
|
||||||
callback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func dispatchTaskSuccessCallback(
|
|
||||||
taskID: ObjectIdentifier,
|
|
||||||
urlSchemeTask: any WKURLSchemeTask,
|
|
||||||
_ callback: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard self.isTaskActive(taskID) else {
|
|
||||||
urlSchemeTask.didFailWithError(NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled))
|
|
||||||
self.clearTask(taskID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callback()
|
|
||||||
self.clearTask(taskID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func recordStreamedResponse() {
|
|
||||||
debugMetricsQueue.sync {
|
|
||||||
streamedResponseCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func recordInMemoryResponse() {
|
|
||||||
debugMetricsQueue.sync {
|
|
||||||
inMemoryResponseCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func recordFailure() {
|
|
||||||
debugMetricsQueue.sync {
|
|
||||||
failureCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mimeType(for pathExtension: String) -> String {
|
|
||||||
switch pathExtension.lowercased() {
|
|
||||||
case "html", "htm":
|
|
||||||
return "text/html"
|
|
||||||
case "xhtml":
|
|
||||||
return "application/xhtml+xml"
|
|
||||||
case "css":
|
|
||||||
return "text/css"
|
|
||||||
case "js":
|
|
||||||
return "application/javascript"
|
|
||||||
case "xml", "opf", "ncx":
|
|
||||||
return "application/xml"
|
|
||||||
case "svg":
|
|
||||||
return "image/svg+xml"
|
|
||||||
case "jpg", "jpeg":
|
|
||||||
return "image/jpeg"
|
|
||||||
case "png":
|
|
||||||
return "image/png"
|
|
||||||
case "gif":
|
|
||||||
return "image/gif"
|
|
||||||
case "webp":
|
|
||||||
return "image/webp"
|
|
||||||
case "ttf":
|
|
||||||
return "font/ttf"
|
|
||||||
case "otf":
|
|
||||||
return "font/otf"
|
|
||||||
case "woff":
|
|
||||||
return "font/woff"
|
|
||||||
case "woff2":
|
|
||||||
return "font/woff2"
|
|
||||||
case "mp3":
|
|
||||||
return "audio/mpeg"
|
|
||||||
case "mp4":
|
|
||||||
return "video/mp4"
|
|
||||||
default:
|
|
||||||
return "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func textEncodingName(for pathExtension: String) -> String? {
|
|
||||||
switch pathExtension.lowercased() {
|
|
||||||
case "html", "htm", "xhtml", "css", "js", "xml", "opf", "ncx", "txt":
|
|
||||||
return "utf-8"
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isOptionalResource(_ url: URL) -> Bool {
|
|
||||||
switch url.pathExtension.lowercased() {
|
|
||||||
case "ttf", "otf", "woff", "woff2", "css", "js", "jpg", "jpeg", "png", "gif", "webp", "svg":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import UIKit
|
|
||||||
|
|
||||||
/// Device-level safe area lookup modeled after GKNavigationBarSwift.
|
|
||||||
/// Reads the real safe area from the key window so correct values are
|
|
||||||
/// available even before a view has been laid out in the hierarchy.
|
|
||||||
/// Must be called on the main thread.
|
|
||||||
public enum RDEPUBSafeArea {
|
|
||||||
|
|
||||||
/// Minimum text margins applied when the device safe area on an edge is
|
|
||||||
/// smaller (e.g. no notch / no home indicator). These are aesthetic
|
|
||||||
/// paddings, not approximations of the safe area itself.
|
|
||||||
public static let minimumVerticalTextMargin: CGFloat = 20
|
|
||||||
|
|
||||||
public static let minimumHorizontalTextMargin: CGFloat = 16
|
|
||||||
|
|
||||||
public static func keyWindow() -> UIWindow? {
|
|
||||||
let scenes = UIApplication.shared.connectedScenes
|
|
||||||
.compactMap { $0 as? UIWindowScene }
|
|
||||||
if let window = scenes
|
|
||||||
.filter({ $0.activationState == .foregroundActive })
|
|
||||||
.flatMap({ $0.windows })
|
|
||||||
.first(where: { $0.isKeyWindow }) {
|
|
||||||
return window
|
|
||||||
}
|
|
||||||
if let window = scenes
|
|
||||||
.flatMap({ $0.windows })
|
|
||||||
.first(where: { $0.isKeyWindow }) {
|
|
||||||
return window
|
|
||||||
}
|
|
||||||
return UIApplication.shared.delegate?.window ?? nil
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func insets() -> UIEdgeInsets {
|
|
||||||
if let window = keyWindow() {
|
|
||||||
return window.safeAreaInsets
|
|
||||||
}
|
|
||||||
// No key window yet (early launch): create a detached window to read
|
|
||||||
// the device safe area, same fallback as GKNavigationBarSwift.
|
|
||||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
|
||||||
if window.safeAreaInsets.bottom <= 0 {
|
|
||||||
window.rootViewController = UIViewController()
|
|
||||||
}
|
|
||||||
return window.safeAreaInsets
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefers insets measured from a view already installed in the hierarchy;
|
|
||||||
/// falls back to the key-window insets when the view is not laid out yet
|
|
||||||
/// and reports .zero.
|
|
||||||
public static func resolve(_ measuredInsets: UIEdgeInsets?) -> UIEdgeInsets {
|
|
||||||
if let measuredInsets, measuredInsets != .zero {
|
|
||||||
return measuredInsets
|
|
||||||
}
|
|
||||||
return insets()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default reflowable content insets derived from the live device safe
|
|
||||||
/// area instead of hard-coded heights.
|
|
||||||
public static func defaultReflowableContentInsets() -> UIEdgeInsets {
|
|
||||||
let safe = insets()
|
|
||||||
return UIEdgeInsets(
|
|
||||||
top: max(safe.top, minimumVerticalTextMargin),
|
|
||||||
left: max(safe.left, minimumHorizontalTextMargin),
|
|
||||||
bottom: max(safe.bottom, minimumVerticalTextMargin),
|
|
||||||
right: max(safe.right, minimumHorizontalTextMargin)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
protocol RDEPUBSearchEngine {
|
|
||||||
|
|
||||||
func search(keyword: String) -> [RDEPUBSearchMatch]
|
|
||||||
}
|
|
||||||
|
|
||||||
final class RDEPUBHTMLSearchEngine: RDEPUBSearchEngine {
|
|
||||||
|
|
||||||
private let parser: RDEPUBParser
|
|
||||||
|
|
||||||
private let publication: RDEPUBPublication
|
|
||||||
|
|
||||||
init(parser: RDEPUBParser, publication: RDEPUBPublication) {
|
|
||||||
self.parser = parser
|
|
||||||
self.publication = publication
|
|
||||||
}
|
|
||||||
|
|
||||||
func search(keyword: String) -> [RDEPUBSearchMatch] {
|
|
||||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !normalizedKeyword.isEmpty else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var searchMatches: [RDEPUBSearchMatch] = []
|
|
||||||
for item in publication.spine where item.linear {
|
|
||||||
guard item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
|
|
||||||
let html = parser.htmlString(forRelativePath: item.href) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let plainText = plainText(fromHTML: html, baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent())
|
|
||||||
let normalizedHref = publication.resourceResolver.normalizedHref(item.href) ?? item.href
|
|
||||||
searchMatches.append(contentsOf: matches(in: plainText, href: normalizedHref, keyword: normalizedKeyword))
|
|
||||||
}
|
|
||||||
|
|
||||||
return searchMatches
|
|
||||||
}
|
|
||||||
|
|
||||||
private func plainText(fromHTML html: String, baseURL: URL?) -> String {
|
|
||||||
guard let data = html.data(using: .utf8) else {
|
|
||||||
return fallbackPlainText(fromHTML: html)
|
|
||||||
}
|
|
||||||
|
|
||||||
var options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
|
||||||
.documentType: NSAttributedString.DocumentType.html,
|
|
||||||
.characterEncoding: String.Encoding.utf8.rawValue
|
|
||||||
]
|
|
||||||
if let baseURL {
|
|
||||||
options[NSAttributedString.DocumentReadingOptionKey(rawValue: "NSBaseURLDocumentOption")] = baseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
|
||||||
return attributed.string
|
|
||||||
}
|
|
||||||
return fallbackPlainText(fromHTML: html)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fallbackPlainText(fromHTML html: String) -> String {
|
|
||||||
let stripped = html.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression)
|
|
||||||
return stripped
|
|
||||||
.replacingOccurrences(of: " ", with: " ")
|
|
||||||
.replacingOccurrences(of: "&", with: "&")
|
|
||||||
.replacingOccurrences(of: "<", with: "<")
|
|
||||||
.replacingOccurrences(of: ">", with: ">")
|
|
||||||
.replacingOccurrences(of: "'", with: "'")
|
|
||||||
.replacingOccurrences(of: """, with: "\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func matches(in text: String, href: String, keyword: String) -> [RDEPUBSearchMatch] {
|
|
||||||
let source = text as NSString
|
|
||||||
let fullLength = source.length
|
|
||||||
guard fullLength > 0 else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
var results: [RDEPUBSearchMatch] = []
|
|
||||||
var localMatchIndex = 0
|
|
||||||
var searchRange = NSRange(location: 0, length: fullLength)
|
|
||||||
|
|
||||||
while searchRange.length > 0 {
|
|
||||||
let foundRange = source.range(of: keyword, options: [.caseInsensitive], range: searchRange)
|
|
||||||
guard foundRange.location != NSNotFound else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let progressionDenominator = max(fullLength - 1, 1)
|
|
||||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
|
||||||
results.append(
|
|
||||||
RDEPUBSearchMatch(
|
|
||||||
href: href,
|
|
||||||
progression: progression,
|
|
||||||
previewText: previewText(in: source, matchRange: foundRange),
|
|
||||||
localMatchIndex: localMatchIndex,
|
|
||||||
rangeLocation: foundRange.location,
|
|
||||||
rangeLength: foundRange.length
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
localMatchIndex += 1
|
|
||||||
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
|
||||||
if nextLocation >= fullLength {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
|
||||||
let previewRadius = 12
|
|
||||||
let start = max(matchRange.location - previewRadius, 0)
|
|
||||||
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
|
||||||
let range = NSRange(location: start, length: max(end - start, 0))
|
|
||||||
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBSearchMatch: Codable, Equatable {
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var progression: Double
|
|
||||||
|
|
||||||
public var previewText: String
|
|
||||||
|
|
||||||
public var localMatchIndex: Int
|
|
||||||
|
|
||||||
public var rangeLocation: Int?
|
|
||||||
|
|
||||||
public var rangeLength: Int
|
|
||||||
|
|
||||||
public var rangeAnchor: RDEPUBTextRangeAnchor?
|
|
||||||
|
|
||||||
public var cfi: String?
|
|
||||||
|
|
||||||
public var rangeCFI: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
href: String,
|
|
||||||
progression: Double,
|
|
||||||
previewText: String,
|
|
||||||
localMatchIndex: Int,
|
|
||||||
rangeLocation: Int? = nil,
|
|
||||||
rangeLength: Int,
|
|
||||||
rangeAnchor: RDEPUBTextRangeAnchor? = nil,
|
|
||||||
cfi: String? = nil,
|
|
||||||
rangeCFI: String? = nil
|
|
||||||
) {
|
|
||||||
self.href = href
|
|
||||||
self.progression = progression
|
|
||||||
self.previewText = previewText
|
|
||||||
self.localMatchIndex = localMatchIndex
|
|
||||||
self.rangeLocation = rangeLocation
|
|
||||||
self.rangeLength = rangeLength
|
|
||||||
self.rangeAnchor = rangeAnchor
|
|
||||||
self.cfi = cfi
|
|
||||||
self.rangeCFI = rangeCFI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBSearchResult: Codable, Equatable {
|
|
||||||
|
|
||||||
public var keyword: String
|
|
||||||
|
|
||||||
public var totalMatchCount: Int
|
|
||||||
|
|
||||||
public var currentMatchIndex: Int?
|
|
||||||
|
|
||||||
public var currentMatch: RDEPUBSearchMatch?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
keyword: String,
|
|
||||||
totalMatchCount: Int,
|
|
||||||
currentMatchIndex: Int?,
|
|
||||||
currentMatch: RDEPUBSearchMatch?
|
|
||||||
) {
|
|
||||||
self.keyword = keyword
|
|
||||||
self.totalMatchCount = totalMatchCount
|
|
||||||
self.currentMatchIndex = currentMatchIndex
|
|
||||||
self.currentMatch = currentMatch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBSearchState: Codable, Equatable {
|
|
||||||
|
|
||||||
public var keyword: String
|
|
||||||
|
|
||||||
public var matches: [RDEPUBSearchMatch]
|
|
||||||
|
|
||||||
public var currentMatchIndex: Int?
|
|
||||||
|
|
||||||
public init(keyword: String, matches: [RDEPUBSearchMatch], currentMatchIndex: Int? = nil) {
|
|
||||||
self.keyword = keyword
|
|
||||||
self.matches = matches
|
|
||||||
self.currentMatchIndex = currentMatchIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
public var currentMatch: RDEPUBSearchMatch? {
|
|
||||||
guard let currentMatchIndex,
|
|
||||||
matches.indices.contains(currentMatchIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return matches[currentMatchIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
public var result: RDEPUBSearchResult {
|
|
||||||
RDEPUBSearchResult(
|
|
||||||
keyword: keyword,
|
|
||||||
totalMatchCount: matches.count,
|
|
||||||
currentMatchIndex: currentMatchIndex.map { $0 + 1 },
|
|
||||||
currentMatch: currentMatch
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBSearchPresentationResource: Codable, Equatable {
|
|
||||||
|
|
||||||
public var href: String
|
|
||||||
|
|
||||||
public var matchCount: Int
|
|
||||||
|
|
||||||
public var activeLocalMatchIndex: Int?
|
|
||||||
|
|
||||||
public init(href: String, matchCount: Int, activeLocalMatchIndex: Int? = nil) {
|
|
||||||
self.href = href
|
|
||||||
self.matchCount = matchCount
|
|
||||||
self.activeLocalMatchIndex = activeLocalMatchIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBSearchPresentation: Codable, Equatable {
|
|
||||||
|
|
||||||
public var keyword: String
|
|
||||||
|
|
||||||
public var resources: [RDEPUBSearchPresentationResource]
|
|
||||||
|
|
||||||
public init(keyword: String, resources: [RDEPUBSearchPresentationResource]) {
|
|
||||||
self.keyword = keyword
|
|
||||||
self.resources = resources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-215
@@ -1,215 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum RDEPUBStyleSheetBuilder {
|
|
||||||
|
|
||||||
public static func injectPaginationCSS(
|
|
||||||
into html: String,
|
|
||||||
presentation: RDEPUBPresentationStyle
|
|
||||||
) -> String {
|
|
||||||
let styleTag = "<style id=\"ss-reader-pagination\">\(measurementCSS(for: presentation))</style>"
|
|
||||||
if html.range(of: "</head>", options: .caseInsensitive) != nil {
|
|
||||||
return html.replacingOccurrences(of: "</head>", with: styleTag + "</head>", options: .caseInsensitive)
|
|
||||||
}
|
|
||||||
if html.range(of: "<body", options: .caseInsensitive) != nil {
|
|
||||||
return html.replacingOccurrences(of: "<body", with: styleTag + "<body", options: .caseInsensitive)
|
|
||||||
}
|
|
||||||
return styleTag + html
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func renderCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
|
||||||
let values = cssValues(for: presentation)
|
|
||||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
|
||||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
|
||||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
|
||||||
|
|
||||||
return """
|
|
||||||
html {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
position: relative !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
#ss-reader-viewport {
|
|
||||||
position: relative !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
#ss-reader-content {
|
|
||||||
position: relative !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
|
||||||
font-size: \(values.fontSize)px !important;
|
|
||||||
line-height: \(values.lineHeight) !important;
|
|
||||||
-webkit-column-width: \(values.contentWidth)px !important;
|
|
||||||
column-width: \(values.contentWidth)px !important;
|
|
||||||
\(columnCountCSS)
|
|
||||||
-webkit-column-gap: \(values.columnGap)px !important;
|
|
||||||
column-gap: \(values.columnGap)px !important;
|
|
||||||
-webkit-column-fill: auto !important;
|
|
||||||
column-fill: auto !important;
|
|
||||||
transform-origin: top left !important;
|
|
||||||
will-change: transform !important;
|
|
||||||
\(backgroundCSS)
|
|
||||||
\(textCSS)
|
|
||||||
}
|
|
||||||
#ss-reader-content img, #ss-reader-content svg, #ss-reader-content video, #ss-reader-content canvas, #ss-reader-content iframe {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
break-inside: avoid-column !important;
|
|
||||||
-webkit-column-break-inside: avoid !important;
|
|
||||||
}
|
|
||||||
#ss-reader-content mark.ss-reader-highlight {
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 2px;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
#ss-reader-content mark.ss-reader-highlight-highlight {
|
|
||||||
text-decoration: none !important;
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
#ss-reader-content mark.ss-reader-highlight-underline {
|
|
||||||
background: transparent !important;
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration-line: underline !important;
|
|
||||||
text-decoration-thickness: 2px !important;
|
|
||||||
text-decoration-skip-ink: auto !important;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func measurementCSS(for presentation: RDEPUBPresentationStyle) -> String {
|
|
||||||
let values = cssValues(for: presentation)
|
|
||||||
let backgroundCSS = presentation.themeBackgroundColor.map { "background: \($0) !important;" } ?? ""
|
|
||||||
let textCSS = presentation.themeTextColor.map { "color: \($0) !important;" } ?? ""
|
|
||||||
let columnCountCSS = values.numberOfColumns > 1 ? "-webkit-column-count: \(values.numberOfColumns) !important; column-count: \(values.numberOfColumns) !important;" : ""
|
|
||||||
|
|
||||||
return """
|
|
||||||
html {
|
|
||||||
margin: 0 !important;
|
|
||||||
padding: 0 !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0 !important;
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
position: relative !important;
|
|
||||||
width: \(values.viewportWidth)px !important;
|
|
||||||
height: \(values.viewportHeight)px !important;
|
|
||||||
min-height: \(values.viewportHeight)px !important;
|
|
||||||
overflow: visible !important;
|
|
||||||
padding: \(values.paddingTop)px \(values.paddingRight)px \(values.paddingBottom)px \(values.paddingLeft)px !important;
|
|
||||||
font-size: \(values.fontSize)px !important;
|
|
||||||
line-height: \(values.lineHeight) !important;
|
|
||||||
-webkit-column-width: \(values.contentWidth)px !important;
|
|
||||||
column-width: \(values.contentWidth)px !important;
|
|
||||||
\(columnCountCSS)
|
|
||||||
-webkit-column-gap: \(values.columnGap)px !important;
|
|
||||||
column-gap: \(values.columnGap)px !important;
|
|
||||||
-webkit-column-fill: auto !important;
|
|
||||||
column-fill: auto !important;
|
|
||||||
transform-origin: top left !important;
|
|
||||||
\(backgroundCSS)
|
|
||||||
\(textCSS)
|
|
||||||
}
|
|
||||||
img, svg, video, canvas, iframe {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
break-inside: avoid-column !important;
|
|
||||||
-webkit-column-break-inside: avoid !important;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func measurementScript(for presentation: RDEPUBPresentationStyle) -> String {
|
|
||||||
let style = measurementCSS(for: presentation)
|
|
||||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
||||||
.replacingOccurrences(of: "`", with: "\\`")
|
|
||||||
|
|
||||||
let viewportWidth = Double(max(1, presentation.viewportSize.width))
|
|
||||||
let viewportWidthJS = String(format: "%.3f", viewportWidth)
|
|
||||||
|
|
||||||
return """
|
|
||||||
(function() {
|
|
||||||
var style = document.getElementById('ss-reader-pagination');
|
|
||||||
if (!style) {
|
|
||||||
style = document.createElement('style');
|
|
||||||
style.id = 'ss-reader-pagination';
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
style.textContent = `\(style)`;
|
|
||||||
var scrollingElement = document.scrollingElement || document.documentElement || document.body;
|
|
||||||
var totalWidth = Math.max(
|
|
||||||
scrollingElement ? scrollingElement.scrollWidth : 0,
|
|
||||||
scrollingElement ? scrollingElement.offsetWidth : 0,
|
|
||||||
document.body ? document.body.scrollWidth : 0,
|
|
||||||
document.body ? document.body.offsetWidth : 0,
|
|
||||||
document.documentElement ? document.documentElement.scrollWidth : 0,
|
|
||||||
document.documentElement ? document.documentElement.offsetWidth : 0
|
|
||||||
);
|
|
||||||
return Math.max(1, Math.ceil(totalWidth / \(viewportWidthJS)));
|
|
||||||
})();
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func cssValues(for presentation: RDEPUBPresentationStyle) -> (
|
|
||||||
viewportWidth: String,
|
|
||||||
viewportHeight: String,
|
|
||||||
contentWidth: String,
|
|
||||||
columnGap: String,
|
|
||||||
numberOfColumns: Int,
|
|
||||||
paddingTop: String,
|
|
||||||
paddingRight: String,
|
|
||||||
paddingBottom: String,
|
|
||||||
paddingLeft: String,
|
|
||||||
fontSize: String,
|
|
||||||
lineHeight: String
|
|
||||||
) {
|
|
||||||
let viewportWidth = max(1, presentation.viewportSize.width)
|
|
||||||
let viewportHeight = max(1, presentation.viewportSize.height)
|
|
||||||
let contentInsets = presentation.contentInsets
|
|
||||||
let numberOfColumns = max(1, presentation.numberOfColumns)
|
|
||||||
let availableWidth = max(1, viewportWidth - contentInsets.left - contentInsets.right)
|
|
||||||
let totalGap = CGFloat(numberOfColumns - 1) * max(0, presentation.columnGap)
|
|
||||||
let contentWidth = max(1, (availableWidth - totalGap) / CGFloat(numberOfColumns))
|
|
||||||
let columnGap = max(0, presentation.columnGap)
|
|
||||||
|
|
||||||
return (
|
|
||||||
viewportWidth: String(format: "%.3f", viewportWidth),
|
|
||||||
viewportHeight: String(format: "%.3f", viewportHeight),
|
|
||||||
contentWidth: String(format: "%.3f", contentWidth),
|
|
||||||
columnGap: String(format: "%.3f", columnGap),
|
|
||||||
numberOfColumns: numberOfColumns,
|
|
||||||
paddingTop: String(format: "%.3f", contentInsets.top),
|
|
||||||
paddingRight: String(format: "%.3f", contentInsets.right),
|
|
||||||
paddingBottom: String(format: "%.3f", contentInsets.bottom),
|
|
||||||
paddingLeft: String(format: "%.3f", contentInsets.left),
|
|
||||||
fontSize: String(format: "%.3f", presentation.fontSize),
|
|
||||||
lineHeight: String(format: "%.3f", max(1, presentation.lineHeightMultiple))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public struct RDEPUBTextAnchor: Codable, Equatable {
|
|
||||||
|
|
||||||
public let fileIndex: Int
|
|
||||||
|
|
||||||
public let row: Int
|
|
||||||
|
|
||||||
public let column: Int
|
|
||||||
|
|
||||||
public let chapterOffset: Int
|
|
||||||
|
|
||||||
public let fragmentID: String?
|
|
||||||
|
|
||||||
public var spineIndex: Int { fileIndex }
|
|
||||||
|
|
||||||
public init(
|
|
||||||
fileIndex: Int,
|
|
||||||
row: Int,
|
|
||||||
column: Int,
|
|
||||||
chapterOffset: Int,
|
|
||||||
fragmentID: String? = nil
|
|
||||||
) {
|
|
||||||
self.fileIndex = fileIndex
|
|
||||||
self.row = row
|
|
||||||
self.column = column
|
|
||||||
self.chapterOffset = chapterOffset
|
|
||||||
self.fragmentID = fragmentID
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case fileIndex
|
|
||||||
case spineIndex
|
|
||||||
case row
|
|
||||||
case column
|
|
||||||
case chapterOffset
|
|
||||||
case fragmentID
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
||||||
let decodedFileIndex = try container.decodeIfPresent(Int.self, forKey: .fileIndex)
|
|
||||||
?? container.decode(Int.self, forKey: .spineIndex)
|
|
||||||
self.fileIndex = decodedFileIndex
|
|
||||||
self.row = try container.decodeIfPresent(Int.self, forKey: .row) ?? 0
|
|
||||||
self.column = try container.decodeIfPresent(Int.self, forKey: .column) ?? 0
|
|
||||||
self.chapterOffset = try container.decode(Int.self, forKey: .chapterOffset)
|
|
||||||
self.fragmentID = try container.decodeIfPresent(String.self, forKey: .fragmentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(fileIndex, forKey: .fileIndex)
|
|
||||||
try container.encode(row, forKey: .row)
|
|
||||||
try container.encode(column, forKey: .column)
|
|
||||||
try container.encode(chapterOffset, forKey: .chapterOffset)
|
|
||||||
try container.encodeIfPresent(fragmentID, forKey: .fragmentID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct RDEPUBTextRangeAnchor: Codable, Equatable {
|
|
||||||
|
|
||||||
public let start: RDEPUBTextAnchor
|
|
||||||
|
|
||||||
public let end: RDEPUBTextAnchor
|
|
||||||
|
|
||||||
public init(start: RDEPUBTextAnchor, end: RDEPUBTextAnchor) {
|
|
||||||
self.start = start
|
|
||||||
self.end = end
|
|
||||||
}
|
|
||||||
|
|
||||||
public var nsRange: NSRange {
|
|
||||||
NSRange(location: start.chapterOffset, length: max(end.chapterOffset - start.chapterOffset, 0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-109
@@ -1,109 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
|
||||||
|
|
||||||
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
|
|
||||||
let parser = publication.parser
|
|
||||||
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
|
|
||||||
if publicationKey == configuredPublicationKey, webView != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
teardownWebView()
|
|
||||||
|
|
||||||
let userContentController = WKUserContentController()
|
|
||||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
|
||||||
userContentController.add(self, name: $0)
|
|
||||||
}
|
|
||||||
userContentController.addUserScript(
|
|
||||||
WKUserScript(
|
|
||||||
source: RDEPUBJavaScriptBridge.userScript,
|
|
||||||
injectionTime: .atDocumentEnd,
|
|
||||||
forMainFrameOnly: true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
let configuration = WKWebViewConfiguration()
|
|
||||||
configuration.websiteDataStore = .nonPersistent()
|
|
||||||
configuration.userContentController = userContentController
|
|
||||||
let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser)
|
|
||||||
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme)
|
|
||||||
|
|
||||||
let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration)
|
|
||||||
webView.onSelectionAction = { [weak self] action in
|
|
||||||
guard let self else { return }
|
|
||||||
self.handleSelectionMenuAction(action)
|
|
||||||
}
|
|
||||||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
|
||||||
if #available(iOS 16.0, *) {
|
|
||||||
let interaction = UIEditMenuInteraction(delegate: webView)
|
|
||||||
webView.addInteraction(interaction)
|
|
||||||
webView._editMenuInteraction = interaction
|
|
||||||
} else {
|
|
||||||
UIMenuController.shared.menuItems = [
|
|
||||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
|
||||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
|
||||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
|
||||||
]
|
|
||||||
}
|
|
||||||
webView.navigationDelegate = self
|
|
||||||
webView.scrollView.isScrollEnabled = false
|
|
||||||
webView.scrollView.showsHorizontalScrollIndicator = false
|
|
||||||
webView.scrollView.showsVerticalScrollIndicator = false
|
|
||||||
webView.scrollView.bounces = false
|
|
||||||
webView.scrollView.alwaysBounceHorizontal = false
|
|
||||||
webView.scrollView.alwaysBounceVertical = false
|
|
||||||
webView.scrollView.clipsToBounds = true
|
|
||||||
webView.scrollView.panGestureRecognizer.isEnabled = false
|
|
||||||
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
|
|
||||||
webView.isOpaque = false
|
|
||||||
webView.backgroundColor = .clear
|
|
||||||
webView.clipsToBounds = true
|
|
||||||
if #available(iOS 16.4, *) {
|
|
||||||
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
addSubview(webView)
|
|
||||||
webView.frame = bounds
|
|
||||||
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
||||||
|
|
||||||
self.schemeHandler = schemeHandler
|
|
||||||
self.webView = webView
|
|
||||||
self.configuredPublicationKey = publicationKey
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
|
|
||||||
delegate?.epubWebView(self, didRequestSelectionAction: action)
|
|
||||||
clearWebSelection()
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearWebSelection() {
|
|
||||||
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
|
|
||||||
}
|
|
||||||
|
|
||||||
func teardownWebView() {
|
|
||||||
cancelFixedLayoutReadyFallback()
|
|
||||||
if let webView {
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
|
||||||
}
|
|
||||||
// M-15: Clean up UIEditMenuInteraction on iOS 16+
|
|
||||||
if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction {
|
|
||||||
webView?.removeInteraction(interaction)
|
|
||||||
}
|
|
||||||
webView?.navigationDelegate = nil
|
|
||||||
if let userContentController = webView?.configuration.userContentController {
|
|
||||||
RDEPUBJavaScriptBridge.messageNames.forEach {
|
|
||||||
userContentController.removeScriptMessageHandler(forName: $0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
|
||||||
webView?.removeFromSuperview()
|
|
||||||
webView = nil
|
|
||||||
schemeHandler = nil
|
|
||||||
configuredPublicationKey = nil
|
|
||||||
currentLoadSignature = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
|
|
||||||
import UIKit
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
|
||||||
|
|
||||||
public func loadFixedSpread(
|
|
||||||
parser: RDEPUBParser,
|
|
||||||
spread: EPUBFixedSpread,
|
|
||||||
viewportSize: CGSize,
|
|
||||||
contentInset: UIEdgeInsets,
|
|
||||||
backgroundColor: UIColor?
|
|
||||||
) {
|
|
||||||
let request = RDEPUBRenderRequest.fixed(
|
|
||||||
RDEPUBFixedRenderRequest(
|
|
||||||
spread: spread,
|
|
||||||
viewportSize: viewportSize,
|
|
||||||
contentInset: contentInset,
|
|
||||||
backgroundColorCSS: backgroundColor?.rd_hexString,
|
|
||||||
fit: .page,
|
|
||||||
searchPresentation: nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
load(publication: parser.makePublication(), request: request)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFixedLayoutLoad(
|
|
||||||
publication: RDEPUBPublication,
|
|
||||||
request: RDEPUBFixedRenderRequest,
|
|
||||||
loadSignature: String,
|
|
||||||
in webView: WKWebView
|
|
||||||
) {
|
|
||||||
currentPageIndex = 0
|
|
||||||
currentTotalPagesInChapter = 1
|
|
||||||
viewportSize = request.viewportSize
|
|
||||||
currentPadding = request.contentInset
|
|
||||||
currentFontSize = 16
|
|
||||||
currentLineHeightMultiple = 1.5
|
|
||||||
currentThemeBackgroundColor = request.backgroundColorCSS
|
|
||||||
currentThemeTextColor = nil
|
|
||||||
targetLocation = nil
|
|
||||||
pendingHighlights = []
|
|
||||||
fixedSpread = request.spread
|
|
||||||
isFixedLayout = true
|
|
||||||
pendingProgressionRequest = false
|
|
||||||
currentLoadSignature = loadSignature
|
|
||||||
scheduleFixedLayoutReadyFallback()
|
|
||||||
|
|
||||||
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
|
||||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
|
||||||
webView.loadHTMLString(
|
|
||||||
html,
|
|
||||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixedLayoutLoadSignature(publicationKey: String, request: RDEPUBFixedRenderRequest) -> String {
|
|
||||||
let searchSignature = [
|
|
||||||
request.searchPresentation?.keyword ?? "",
|
|
||||||
request.searchPresentation?.resources.map {
|
|
||||||
[
|
|
||||||
$0.href,
|
|
||||||
String($0.matchCount),
|
|
||||||
String($0.activeLocalMatchIndex ?? -1)
|
|
||||||
].joined(separator: "|")
|
|
||||||
}.joined(separator: ",") ?? ""
|
|
||||||
].joined(separator: "#")
|
|
||||||
return [
|
|
||||||
publicationKey,
|
|
||||||
"fixed",
|
|
||||||
request.spread.resources.map(\.href).joined(separator: "|"),
|
|
||||||
String(format: "%.2f", request.viewportSize.width),
|
|
||||||
String(format: "%.2f", request.viewportSize.height),
|
|
||||||
String(format: "%.2f", request.contentInset.top),
|
|
||||||
String(format: "%.2f", request.contentInset.left),
|
|
||||||
String(format: "%.2f", request.contentInset.bottom),
|
|
||||||
String(format: "%.2f", request.contentInset.right),
|
|
||||||
request.backgroundColorCSS ?? "",
|
|
||||||
request.fit.rawValue,
|
|
||||||
searchSignature
|
|
||||||
].joined(separator: "#")
|
|
||||||
}
|
|
||||||
|
|
||||||
func scheduleFixedLayoutReadyFallback() {
|
|
||||||
let workItem = DispatchWorkItem { [weak self] in
|
|
||||||
RDEPUBWebViewDebug.log(self?.debugScope ?? "ReaderWebView", message: "fixed ready fallback fired")
|
|
||||||
self?.applyFixedPresentationIfNeeded {
|
|
||||||
self?.refreshNativeDecorationsIfNeeded {
|
|
||||||
self?.rendered()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fixedLayoutReadyWorkItem = workItem
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelFixedLayoutReadyFallback() {
|
|
||||||
fixedLayoutReadyWorkItem?.cancel()
|
|
||||||
fixedLayoutReadyWorkItem = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyFixedPresentationIfNeeded(completion: (() -> Void)? = nil) {
|
|
||||||
guard let webView,
|
|
||||||
let currentRenderRequest,
|
|
||||||
case .fixed(let request) = currentRenderRequest else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let script = RDEPUBJavaScriptBridge.applyFixedPresentationScript(for: request)
|
|
||||||
webView.evaluateJavaScript(script) { [weak self] _, error in
|
|
||||||
guard let self else {
|
|
||||||
completion?()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let error {
|
|
||||||
self.delegate?.epubWebView(self, didLogJavaScriptError: error.localizedDescription)
|
|
||||||
}
|
|
||||||
self.applySearchDecorationsIfNeeded(completion: completion)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-149
@@ -1,149 +0,0 @@
|
|||||||
|
|
||||||
import Foundation
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
extension RDEPUBWebView {
|
|
||||||
|
|
||||||
func internalLocation(from url: URL) -> RDEPUBLocation? {
|
|
||||||
let href = url.path.removingPercentEncoding?.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
|
||||||
let fragment = url.fragment
|
|
||||||
guard !href.isEmpty || fragment != nil else { return nil }
|
|
||||||
return RDEPUBLocation(href: href, progression: 0, fragment: fragment)
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentLocation(from body: [String: Any]) -> RDEPUBLocation {
|
|
||||||
let progression = (body["progression"] as? NSNumber)?.doubleValue ?? 0
|
|
||||||
let lastProgression = (body["lastProgression"] as? NSNumber)?.doubleValue
|
|
||||||
let fragment = body["fragment"] as? String
|
|
||||||
let href = body["href"] as? String ?? currentHref
|
|
||||||
return RDEPUBLocation(href: href, progression: progression, lastProgression: lastProgression, fragment: fragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RDEPUBWebView: WKNavigationDelegate {
|
|
||||||
public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didStart", url: webView.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didCommit", url: webView.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
|
||||||
applyPresentation()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
||||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "decidePolicy", url: navigationAction.request.url)
|
|
||||||
guard navigationAction.navigationType == .linkActivated,
|
|
||||||
let url = navigationAction.request.url else {
|
|
||||||
decisionHandler(.allow)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if url.scheme == RDEPUBResourceURLSchemeHandler.scheme,
|
|
||||||
let location = internalLocation(from: url) {
|
|
||||||
delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex)
|
|
||||||
decisionHandler(.cancel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let scheme = url.scheme?.lowercased(), ["http", "https", "mailto", "tel"].contains(scheme) {
|
|
||||||
delegate?.epubWebView(self, didActivateExternalLink: url)
|
|
||||||
decisionHandler(.cancel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
decisionHandler(.allow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RDEPUBWebView: WKScriptMessageHandler {
|
|
||||||
|
|
||||||
public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
||||||
RDEPUBWebViewDebug.logMessage(debugScope, webView: message.webView, name: message.name, body: message.body)
|
|
||||||
switch message.name {
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.progressionChanged.rawValue:
|
|
||||||
guard let body = message.body as? [String: Any] else { return }
|
|
||||||
delegate?.epubWebView(self, didUpdateLocation: currentLocation(from: body), spineIndex: currentSpineIndex)
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.selectionChanged.rawValue:
|
|
||||||
if let body = message.body as? [String: Any],
|
|
||||||
let text = body["text"] as? String,
|
|
||||||
let rangeInfo = body["rangeInfo"] as? String {
|
|
||||||
let location = currentLocation(from: body)
|
|
||||||
let selection = RDEPUBSelection(location: location, text: text, rangeInfo: rangeInfo)
|
|
||||||
delegate?.epubWebView(self, didChangeSelection: selection, spineIndex: currentSpineIndex)
|
|
||||||
} else {
|
|
||||||
delegate?.epubWebView(self, didChangeSelection: nil, spineIndex: currentSpineIndex)
|
|
||||||
}
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.internalLink.rawValue:
|
|
||||||
guard let body = message.body as? [String: Any],
|
|
||||||
let href = body["href"] as? String else { return }
|
|
||||||
let location = RDEPUBLocation(href: href, progression: 0)
|
|
||||||
delegate?.epubWebView(self, didActivateInternalLink: location, fromSpineIndex: currentSpineIndex)
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.externalLink.rawValue:
|
|
||||||
guard let body = message.body as? [String: Any],
|
|
||||||
let urlString = body["url"] as? String,
|
|
||||||
let url = URL(string: urlString) else { return }
|
|
||||||
delegate?.epubWebView(self, didActivateExternalLink: url)
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.javaScriptError.rawValue:
|
|
||||||
delegate?.epubWebView(self, didLogJavaScriptError: "\(message.body)")
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.fixedLayoutReady.rawValue:
|
|
||||||
applyFixedPresentationIfNeeded {
|
|
||||||
self.refreshNativeDecorationsIfNeeded {
|
|
||||||
self.rendered()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.imageDidTap.rawValue:
|
|
||||||
guard let body = message.body as? [String: Any],
|
|
||||||
let src = body["src"] as? String else { return }
|
|
||||||
let sourceRect = sourceRect(from: body["rect"])
|
|
||||||
delegate?.epubWebView(self, didTapImageWithSource: src, sourceRect: sourceRect)
|
|
||||||
case RDEPUBJavaScriptBridgeMessage.footnoteDidTap.rawValue:
|
|
||||||
guard let body = message.body as? [String: Any],
|
|
||||||
let altText = body["alt"] as? String else { return }
|
|
||||||
let sourceRect = sourceRect(from: body["rect"])
|
|
||||||
delegate?.epubWebView(self, didTapFootnoteWithAltText: altText, sourceRect: sourceRect)
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sourceRect(from value: Any?) -> CGRect? {
|
|
||||||
guard let rectDict = value as? [String: Any] else { return nil }
|
|
||||||
return CGRect(
|
|
||||||
x: cgFloatValue(rectDict["x"]),
|
|
||||||
y: cgFloatValue(rectDict["y"]),
|
|
||||||
width: cgFloatValue(rectDict["width"]),
|
|
||||||
height: cgFloatValue(rectDict["height"])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cgFloatValue(_ value: Any?) -> CGFloat {
|
|
||||||
switch value {
|
|
||||||
case let number as NSNumber:
|
|
||||||
return CGFloat(truncating: number)
|
|
||||||
case let double as Double:
|
|
||||||
return CGFloat(double)
|
|
||||||
case let int as Int:
|
|
||||||
return CGFloat(int)
|
|
||||||
case let string as String:
|
|
||||||
return CGFloat(Double(string) ?? 0)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user