feat: optimize reader caching and document formats
This commit is contained in:
parent
52f803e8db
commit
68d9363f0a
5
.gitignore
vendored
5
.gitignore
vendored
@ -33,3 +33,8 @@ ReadViewDemo/build/
|
||||
|
||||
# AI tool output
|
||||
_ssoft-output/
|
||||
.claude/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@ -45,8 +45,8 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDEpubReaderView: 6bab24a23236a223b29a02f3d00b6ac7829a4b97
|
||||
RDPDFReaderView: ea8c225655c8fdc1f34d75026e05fa1e50744b36
|
||||
RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
|
||||
RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "RDEpubReaderView",
|
||||
"module_name": "RDEpubReaderView",
|
||||
"version": "0.0.2",
|
||||
"summary": "A reader view for EPUB and plain-text books",
|
||||
"summary": "A reader view for EPUB, TXT, MOBI, and CBZ books",
|
||||
"platforms": {
|
||||
"ios": "15.0"
|
||||
},
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"name": "RDPDFReaderView",
|
||||
"module_name": "RDPDFReaderView",
|
||||
"version": "0.0.1",
|
||||
"summary": "Independent UIKit PDF reader interaction engine",
|
||||
"summary": "UIKit PDF reader supporting host images and direct PDFKit parsing",
|
||||
"platforms": {
|
||||
"ios": "15.0"
|
||||
},
|
||||
@ -25,7 +25,8 @@
|
||||
},
|
||||
"frameworks": [
|
||||
"Vision",
|
||||
"CoreImage"
|
||||
"CoreImage",
|
||||
"PDFKit"
|
||||
],
|
||||
"requires_arc": true,
|
||||
"swift_version": "5.10"
|
||||
|
||||
4
ReadViewDemo/Pods/Manifest.lock
generated
4
ReadViewDemo/Pods/Manifest.lock
generated
@ -45,8 +45,8 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
|
||||
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
|
||||
RDEpubReaderView: 6bab24a23236a223b29a02f3d00b6ac7829a4b97
|
||||
RDPDFReaderView: ea8c225655c8fdc1f34d75026e05fa1e50744b36
|
||||
RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
|
||||
RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
|
||||
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
|
||||
4275
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
4275
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
File diff suppressed because it is too large
Load Diff
@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "PDFKit" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
|
||||
@ -5,7 +5,7 @@ GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText/DTCoreText.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation/DTFoundation.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView/RDEpubReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView/RDPDFReaderView.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit/SnapKit.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation/ZIPFoundation.framework/Headers" "$(SDKROOT)/usr/include/libxml2"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) /usr/lib/swift '@executable_path/Frameworks' '@loader_path/Frameworks'
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift $(SDKROOT)/usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
||||
OTHER_LDFLAGS = $(inherited) -l"swiftCoreGraphics" -l"xml2" -framework "CoreGraphics" -framework "CoreImage" -framework "CoreText" -framework "DTCoreText" -framework "DTFoundation" -framework "ImageIO" -framework "MediaPlayer" -framework "PDFKit" -framework "QuartzCore" -framework "RDEpubReaderView" -framework "RDPDFReaderView" -framework "SnapKit" -framework "Vision" -framework "ZIPFoundation"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/DTCoreText" "-F${PODS_CONFIGURATION_BUILD_DIR}/DTFoundation" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDEpubReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView" "-F${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" "-F${PODS_CONFIGURATION_BUILD_DIR}/ZIPFoundation"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
|
||||
@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "SnapKit" -framework "Vision"
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "PDFKit" -framework "SnapKit" -framework "Vision"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "SnapKit" -framework "Vision"
|
||||
OTHER_LDFLAGS = $(inherited) -framework "CoreImage" -framework "PDFKit" -framework "SnapKit" -framework "Vision"
|
||||
OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
||||
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */; };
|
||||
1A2B3C4D00000033AABBCC01 /* PDFDrawingModeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */; };
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||
@ -31,6 +32,18 @@
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
|
||||
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
|
||||
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
|
||||
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */; };
|
||||
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */; };
|
||||
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */; };
|
||||
2B3C4D5E00000004AABBCC01 /* HighlightsManagementTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000004AABBCC02 /* HighlightsManagementTests.swift */; };
|
||||
2B3C4D5E00000005AABBCC01 /* LargeBookOnDemandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000005AABBCC02 /* LargeBookOnDemandTests.swift */; };
|
||||
2B3C4D5E00000006AABBCC01 /* LocationPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000006AABBCC02 /* LocationPersistenceTests.swift */; };
|
||||
2B3C4D5E00000007AABBCC01 /* NavigationBackwardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000007AABBCC02 /* NavigationBackwardTests.swift */; };
|
||||
2B3C4D5E00000008AABBCC01 /* SelectionAnnotateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000008AABBCC02 /* SelectionAnnotateTests.swift */; };
|
||||
2B3C4D5E00000009AABBCC01 /* SelectionMenuTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E00000009AABBCC02 /* SelectionMenuTests.swift */; };
|
||||
2B3C4D5E0000000AAABBCC01 /* SettingsEffectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */; };
|
||||
2B3C4D5E0000000BAABBCC01 /* TOCInteractionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */; };
|
||||
2B3C4D5E0000000CAABBCC01 /* ToolbarStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@ -58,6 +71,7 @@
|
||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FixedLayoutRotationTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFDrawingModeTests.swift; sourceTree = "<group>"; };
|
||||
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPanelTests.swift; sourceTree = "<group>"; };
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderAnnotationTests.swift; sourceTree = "<group>"; };
|
||||
3A43AED288BFCA3ADBA97DD7 /* Pods-ReadViewDemo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ReadViewDemo.release.xcconfig"; path = "Target Support Files/Pods-ReadViewDemo/Pods-ReadViewDemo.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@ -72,6 +86,18 @@
|
||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkChromeStateTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookmarkManagementTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ErrorAndEdgeCaseTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000004AABBCC02 /* HighlightsManagementTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HighlightsManagementTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000005AABBCC02 /* LargeBookOnDemandTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LargeBookOnDemandTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000006AABBCC02 /* LocationPersistenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocationPersistenceTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000007AABBCC02 /* NavigationBackwardTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NavigationBackwardTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000008AABBCC02 /* SelectionAnnotateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SelectionAnnotateTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E00000009AABBCC02 /* SelectionMenuTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SelectionMenuTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsEffectTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TOCInteractionTests.swift; sourceTree = "<group>"; };
|
||||
2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ToolbarStateTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@ -187,6 +213,18 @@
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
|
||||
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
|
||||
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */,
|
||||
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */,
|
||||
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */,
|
||||
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */,
|
||||
2B3C4D5E00000004AABBCC02 /* HighlightsManagementTests.swift */,
|
||||
2B3C4D5E00000005AABBCC02 /* LargeBookOnDemandTests.swift */,
|
||||
2B3C4D5E00000006AABBCC02 /* LocationPersistenceTests.swift */,
|
||||
2B3C4D5E00000007AABBCC02 /* NavigationBackwardTests.swift */,
|
||||
2B3C4D5E00000008AABBCC02 /* SelectionAnnotateTests.swift */,
|
||||
2B3C4D5E00000009AABBCC02 /* SelectionMenuTests.swift */,
|
||||
2B3C4D5E0000000AAABBCC02 /* SettingsEffectTests.swift */,
|
||||
2B3C4D5E0000000BAABBCC02 /* TOCInteractionTests.swift */,
|
||||
2B3C4D5E0000000CAABBCC02 /* ToolbarStateTests.swift */,
|
||||
20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
|
||||
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
|
||||
@ -202,6 +240,7 @@
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */,
|
||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
|
||||
1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */,
|
||||
1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
sourceTree = "<group>";
|
||||
@ -355,6 +394,18 @@
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
|
||||
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
|
||||
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */,
|
||||
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */,
|
||||
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */,
|
||||
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */,
|
||||
2B3C4D5E00000004AABBCC01 /* HighlightsManagementTests.swift in Sources */,
|
||||
2B3C4D5E00000005AABBCC01 /* LargeBookOnDemandTests.swift in Sources */,
|
||||
2B3C4D5E00000006AABBCC01 /* LocationPersistenceTests.swift in Sources */,
|
||||
2B3C4D5E00000007AABBCC01 /* NavigationBackwardTests.swift in Sources */,
|
||||
2B3C4D5E00000008AABBCC01 /* SelectionAnnotateTests.swift in Sources */,
|
||||
2B3C4D5E00000009AABBCC01 /* SelectionMenuTests.swift in Sources */,
|
||||
2B3C4D5E0000000AAABBCC01 /* SettingsEffectTests.swift in Sources */,
|
||||
2B3C4D5E0000000BAABBCC01 /* TOCInteractionTests.swift in Sources */,
|
||||
2B3C4D5E0000000CAABBCC01 /* ToolbarStateTests.swift in Sources */,
|
||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
|
||||
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
|
||||
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
|
||||
@ -370,6 +421,7 @@
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */,
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
|
||||
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */,
|
||||
1A2B3C4D00000033AABBCC01 /* PDFDrawingModeTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@ -10,19 +10,23 @@ enum PDFDemoImageTextSource: String {
|
||||
case none
|
||||
}
|
||||
|
||||
/// PDFKit 适配层:只负责把宿主 PDF 变成 SDK 的页面、缩略图和目录数据。
|
||||
private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding {
|
||||
/// 模拟“宿主只能提供页面图片”的适配层,用于验证自定义加密 PDF 接入方式。
|
||||
private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding, RDPDFReaderAsyncOutlineProviding {
|
||||
private let document: PDFDocument
|
||||
private let identifier: String
|
||||
private let title: String
|
||||
private let textSource: PDFDemoImageTextSource
|
||||
private var images: [Int: UIImage] = [:]
|
||||
private let renderQueue = DispatchQueue(label: "com.readviewsdk.demo-pdf-render", qos: .userInitiated)
|
||||
private let images = NSCache<NSNumber, UIImage>()
|
||||
private var outlineItemsCache: [RDPDFReaderOutlineItem]?
|
||||
|
||||
init(document: PDFDocument, identifier: String, title: String, textSource: PDFDemoImageTextSource) {
|
||||
self.document = document
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.textSource = textSource
|
||||
images.countLimit = 6
|
||||
images.totalCostLimit = 96 * 1024 * 1024
|
||||
}
|
||||
|
||||
func readerBookDescriptor() -> RDPDFReaderBookDescriptor {
|
||||
@ -35,19 +39,46 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
case .hostFixture: textRuns = fixtureTextRuns(for: index)
|
||||
case .vision, .none: textRuns = nil
|
||||
}
|
||||
completion(.init(index: index, image: image(at: index), textRuns: textRuns))
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let image = self.image(at: index)
|
||||
DispatchQueue.main.async { completion(.init(index: index, image: image, textRuns: textRuns)) }
|
||||
}
|
||||
}
|
||||
|
||||
func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void) {
|
||||
guard let page = document.page(at: index) else { completion(nil); return }
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let page = self.document.page(at: index) else {
|
||||
DispatchQueue.main.async { completion(nil) }
|
||||
return
|
||||
}
|
||||
let box = page.bounds(for: .mediaBox)
|
||||
let scale = min(targetSize.width / box.width, targetSize.height / box.height) * UIScreen.main.scale
|
||||
let size = CGSize(width: box.width * scale, height: box.height * scale)
|
||||
completion(render(page, size: size, scale: scale))
|
||||
let image = self.render(page, size: size, scale: scale)
|
||||
DispatchQueue.main.async { completion(image) }
|
||||
}
|
||||
}
|
||||
|
||||
func readerOutlineItems() -> [RDPDFReaderOutlineItem] {
|
||||
guard let root = document.outlineRoot else { return pageOutlineItems() }
|
||||
renderQueue.sync { outlineItemsLocked() }
|
||||
}
|
||||
|
||||
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void) {
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let items = self.outlineItemsLocked()
|
||||
DispatchQueue.main.async { completion(items) }
|
||||
}
|
||||
}
|
||||
|
||||
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
|
||||
if let outlineItemsCache { return outlineItemsCache }
|
||||
guard let root = document.outlineRoot else {
|
||||
let items = pageOutlineItems()
|
||||
outlineItemsCache = items
|
||||
return items
|
||||
}
|
||||
var items: [RDPDFReaderOutlineItem] = []
|
||||
func append(_ outline: PDFOutline, level: Int) {
|
||||
for index in 0..<outline.numberOfChildren {
|
||||
@ -60,7 +91,9 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
}
|
||||
}
|
||||
append(root, level: 0)
|
||||
return items.isEmpty ? pageOutlineItems() : items
|
||||
let resolved = items.isEmpty ? pageOutlineItems() : items
|
||||
outlineItemsCache = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
func transformed(_ image: UIImage, for theme: RDPDFReaderThemeOption) -> UIImage {
|
||||
@ -81,12 +114,14 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
|
||||
}
|
||||
|
||||
private func image(at index: Int) -> UIImage? {
|
||||
if let image = images[index] { return image }
|
||||
if let image = images.object(forKey: NSNumber(value: index)) { return image }
|
||||
guard let page = document.page(at: index) else { return nil }
|
||||
let box = page.bounds(for: .mediaBox)
|
||||
let scale = max(2, UIScreen.main.scale)
|
||||
let image = render(page, size: CGSize(width: box.width * scale, height: box.height * scale), scale: scale)
|
||||
images[index] = image
|
||||
let cost = image.cgImage.map { $0.bytesPerRow * $0.height }
|
||||
?? Int(image.size.width * image.size.height * image.scale * image.scale * 4)
|
||||
images.setObject(image, forKey: NSNumber(value: index), cost: cost)
|
||||
return image
|
||||
}
|
||||
|
||||
@ -118,6 +153,8 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
||||
private let reader: RDPDFReaderViewController
|
||||
private var navigationBarHiddenBeforeReader: Bool?
|
||||
private var display = "pagecurl"
|
||||
private var lastPageIndex = 0
|
||||
private var lastCopiedText: String?
|
||||
private let stateLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.accessibilityIdentifier = "demo.reader.state"
|
||||
@ -127,19 +164,44 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
||||
}()
|
||||
|
||||
init(url: URL, bookIdentifier: String? = nil, textSource: PDFDemoImageTextSource = .vision) {
|
||||
guard let document = PDFDocument(url: url) else { fatalError("无法打开 PDF") }
|
||||
let identifier = bookIdentifier ?? url.lastPathComponent
|
||||
let provider = PDFDemoPageProvider(document: document, identifier: identifier, title: url.deletingPathExtension().lastPathComponent, textSource: textSource)
|
||||
let store = RDPDFReaderPersistenceStore(rootURL: Self.annotationStorageURL(for: identifier, legacyBookURL: url))
|
||||
var configuration = RDPDFReaderViewController.Configuration()
|
||||
configuration.enablesOCR = textSource == .vision
|
||||
configuration.missingTextSource = .region
|
||||
configuration.recognitionLanguages = ["zh-Hans", "en-US"]
|
||||
configuration.pageImageTransform = { [weak provider] image, theme in provider?.transformed(image, for: theme) ?? image }
|
||||
reader = RDPDFReaderViewController(pageProvider: provider, annotationPersistence: store, configuration: configuration)
|
||||
|
||||
if textSource != .vision {
|
||||
guard let document = PDFDocument(url: url) else { fatalError("无法打开 PDF") }
|
||||
let provider = PDFDemoPageProvider(
|
||||
document: document,
|
||||
identifier: identifier,
|
||||
title: url.deletingPathExtension().lastPathComponent,
|
||||
textSource: textSource
|
||||
)
|
||||
configuration.pageImageTransform = { [weak provider] image, theme in
|
||||
provider?.transformed(image, for: theme) ?? image
|
||||
}
|
||||
reader = RDPDFReaderViewController(
|
||||
pageProvider: provider,
|
||||
annotationPersistence: store,
|
||||
configuration: configuration
|
||||
)
|
||||
} else {
|
||||
do {
|
||||
reader = try RDPDFReaderViewController(
|
||||
pdfURL: url,
|
||||
bookIdentifier: identifier,
|
||||
annotationPersistence: store,
|
||||
configuration: configuration
|
||||
)
|
||||
} catch {
|
||||
fatalError("无法打开 PDF:\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
reader.delegate = self
|
||||
title = provider.readerBookDescriptor().title
|
||||
title = url.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
@ -182,6 +244,11 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
||||
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) { updateState(page: pageIndex) }
|
||||
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {
|
||||
lastCopiedText = text
|
||||
updateState(page: lastPageIndex)
|
||||
}
|
||||
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {
|
||||
stateLabel.text = "reader=opened mode=pdf annotationStorage=error"
|
||||
guard presentedViewController == nil else { return }
|
||||
@ -190,7 +257,16 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
private func updateState(page: Int) { stateLabel.text = "reader=opened mode=pdf page=\(page + 1) display=\(display)" }
|
||||
private func updateState(page: Int) {
|
||||
lastPageIndex = page
|
||||
var state = "reader=opened mode=pdf page=\(page + 1) display=\(display)"
|
||||
if let lastCopiedText {
|
||||
// 状态字段以空格分隔,文本需百分号编码;UI 测试端会解码还原。
|
||||
let encoded = lastCopiedText.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
|
||||
state += " copy=success copiedText=\(encoded)"
|
||||
}
|
||||
stateLabel.text = state
|
||||
}
|
||||
|
||||
private static func annotationStorageURL(for bookIdentifier: String, legacyBookURL: URL) -> URL {
|
||||
let root = annotationStorageRoot()
|
||||
|
||||
@ -112,7 +112,7 @@ final class ViewController: UIViewController {
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.font = .systemFont(ofSize: 15)
|
||||
label.text = "book 目录下暂时没有可阅读的 txt 或 epub 文件"
|
||||
label.text = "book 目录下暂时没有可阅读的 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
|
||||
label.accessibilityIdentifier = "demo.books.empty"
|
||||
return label
|
||||
}()
|
||||
@ -165,7 +165,7 @@ final class ViewController: UIViewController {
|
||||
|
||||
private func updateChrome() {
|
||||
if books.isEmpty {
|
||||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件"
|
||||
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
|
||||
tableView.backgroundView = emptyStateLabel
|
||||
} else {
|
||||
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
|
||||
@ -191,7 +191,7 @@ final class ViewController: UIViewController {
|
||||
return fileURLs
|
||||
.filter { url in
|
||||
let ext = url.pathExtension.lowercased()
|
||||
return ext == "epub" || ext == "txt" || ext == "pdf"
|
||||
return RDReaderDocumentFormat.supports(url) || ext == "pdf"
|
||||
}
|
||||
.reduce(into: [URL]()) { result, url in
|
||||
if !result.contains(url) {
|
||||
|
||||
@ -44,9 +44,9 @@ enum IDs {
|
||||
/// The text layer is the precise, image-coordinate based interaction surface.
|
||||
static let pdfTextLayer = "pdf.reader.text.layer"
|
||||
static let pdfOCRStatus = "pdf.reader.ocr.status"
|
||||
/// PDF 选区菜单与 EPUB 相同,使用系统编辑菜单,按菜单项标题(复制/高亮/注释)定位。
|
||||
/// PDF 选区菜单与 EPUB 相同,使用系统编辑菜单,按菜单项标题(复制/划线/注释)定位。
|
||||
static let pdfSelectionMenuCopy = "复制"
|
||||
static let pdfSelectionMenuHighlight = "高亮"
|
||||
static let pdfSelectionMenuHighlight = "划线"
|
||||
static let pdfSelectionMenuAnnotate = "注释"
|
||||
static let pdfCopyStatus = "pdf.reader.copy.status"
|
||||
|
||||
|
||||
@ -119,7 +119,9 @@ final class HighlightsManagementTests: XCTestCase {
|
||||
let deleteControl = highlightsTable.buttons["删除"].firstMatch
|
||||
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
|
||||
deleteControl.tap()
|
||||
app.buttons["删除"].lastMatch.tap()
|
||||
// 编辑态的行内“删除”与确认用的“删除”同名;确认按钮是最后出现的那个。
|
||||
let deleteButtons = app.buttons.matching(identifier: "删除")
|
||||
deleteButtons.element(boundBy: max(0, deleteButtons.count - 1)).tap()
|
||||
|
||||
XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
|
||||
}
|
||||
|
||||
@ -153,7 +153,7 @@ final class LocationPersistenceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testHighlightNavigationSurvivesFontChoiceChange() throws {
|
||||
app.launchAndOpenSampleBook(resetsReaderState: true, pageNumber: 3)
|
||||
app.launchAndOpenSampleBook(pageNumber: 3, resetsReaderState: true)
|
||||
app.waitForReader(timeout: 12)
|
||||
|
||||
let initialState = app.waitForDemoReaderState(timeout: 8, description: "初始位于第 3 页") { state in
|
||||
|
||||
@ -58,6 +58,46 @@ final class PDFDrawingModeTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// 放大状态下同一规则:未选中工具单指拖动画面,选中画笔后单指只用于绘制。
|
||||
func testZoomedPageDragsOnlyWhenDrawingToolIsDeselected() {
|
||||
app.launchAndOpenSampleBook(bookTitleQuery: "英汉大词典", displayType: "pagecurl")
|
||||
app.waitForReader()
|
||||
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
XCTAssertTrue(content.waitForExistence(timeout: 8), "PDF 阅读页面未出现")
|
||||
|
||||
app.showReaderChromeIfNeeded(requiredButtonIDs: [IDs.pdfDrawing])
|
||||
let drawing = app.buttons[IDs.pdfDrawing]
|
||||
XCTAssertTrue(drawing.waitForExistence(timeout: 5), "画笔入口未出现")
|
||||
drawing.tap()
|
||||
|
||||
let pen = app.buttons[IDs.pdfDrawingPen]
|
||||
XCTAssertTrue(pen.waitForExistence(timeout: 5), "画笔工具未出现")
|
||||
|
||||
// 未选中工具 → 双指放大
|
||||
content.pinch(withScale: 2.5, velocity: 1)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(1.0))
|
||||
let zoomedFrame = content.frame
|
||||
XCTAssertGreaterThan(zoomedFrame.width, app.windows.firstMatch.frame.width * 1.2, "画笔面板打开时无法双指放大页面")
|
||||
|
||||
// 未选中工具 → 单指拖动应移动页面
|
||||
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.3))
|
||||
start.press(forDuration: 0.05, thenDragTo: end)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.8))
|
||||
let draggedFrame = content.frame
|
||||
XCTAssertNotEqual(draggedFrame.origin, zoomedFrame.origin, "未选中工具时,放大页面无法单指拖动")
|
||||
|
||||
// 选中画笔 → 单指应绘制而非拖动
|
||||
pen.tap()
|
||||
let frameBeforeStroke = content.frame
|
||||
let strokeStart = content.coordinate(withNormalizedOffset: CGVector(dx: 0.45, dy: 0.45))
|
||||
let strokeEnd = content.coordinate(withNormalizedOffset: CGVector(dx: 0.6, dy: 0.6))
|
||||
strokeStart.press(forDuration: 0.05, thenDragTo: strokeEnd)
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.8))
|
||||
XCTAssertEqual(content.frame.origin, frameBeforeStroke.origin, "选中画笔后,单指仍拖动了放大页面而不是绘制")
|
||||
}
|
||||
|
||||
private func waitForLandscapeLayout() -> Bool {
|
||||
let deadline = Date().addingTimeInterval(8)
|
||||
while Date() < deadline {
|
||||
@ -69,8 +109,12 @@ final class PDFDrawingModeTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func scrollToAnotherPage(_ content: XCUIElement, from page: Int) -> Bool {
|
||||
let window = app.windows.firstMatch
|
||||
for _ in 0..<5 {
|
||||
content.swipeUp()
|
||||
// 从屏幕上半部拖动,避开底部画笔工具条区域。
|
||||
let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.35))
|
||||
let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.05))
|
||||
start.press(forDuration: 0.05, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0)
|
||||
let deadline = Date().addingTimeInterval(1.5)
|
||||
while Date() < deadline {
|
||||
if let current = app.currentDemoReaderState()?.page, current != page { return true }
|
||||
|
||||
@ -0,0 +1,238 @@
|
||||
import UIKit
|
||||
import ZIPFoundation
|
||||
|
||||
public enum RDCBZReaderError: LocalizedError {
|
||||
case archiveOpenFailed
|
||||
case noImages
|
||||
case tooManyPages
|
||||
case archiveTooLarge
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .archiveOpenFailed: return "无法打开 CBZ 文件"
|
||||
case .noImages: return "CBZ 文件中没有可显示的图片"
|
||||
case .tooManyPages: return "CBZ 图片数量超过安全限制"
|
||||
case .archiveTooLarge: return "CBZ 解压后的内容超过安全限制"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Image-based comic reader for CBZ archives. Pages use the same paging engine
|
||||
/// as the EPUB reader and are extracted to a bounded disk cache on demand at
|
||||
/// controller creation time, avoiding retention of all decoded images.
|
||||
public final class RDCBZReaderController: UIViewController {
|
||||
public let readerView = RDEpubReaderView(frame: .zero)
|
||||
|
||||
private let bookURL: URL
|
||||
private let configuration: RDEPUBReaderConfiguration
|
||||
private var pageURLs: [URL] = []
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .large)
|
||||
|
||||
public init(bookURL: URL, configuration: RDEPUBReaderConfiguration = .default) {
|
||||
self.bookURL = bookURL
|
||||
self.configuration = configuration
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = configuration.theme.contentBackgroundColor
|
||||
title = bookURL.deletingPathExtension().lastPathComponent
|
||||
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(loadingIndicator)
|
||||
NSLayoutConstraint.activate([
|
||||
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
loadingIndicator.startAnimating()
|
||||
|
||||
let bookURL = self.bookURL
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
let result = Result { try RDCBZArchiveExtractor().extractPages(from: bookURL) }
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.loadingIndicator.stopAnimating()
|
||||
switch result {
|
||||
case let .success(pageURLs):
|
||||
self.pageURLs = pageURLs
|
||||
self.configureReaderView()
|
||||
case let .failure(error):
|
||||
self.show(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureReaderView() {
|
||||
readerView.pageProvider = self
|
||||
readerView.delegate = self
|
||||
readerView.currentDisplayType = configuration.displayType
|
||||
readerView.landscapeDualPageEnabled = configuration.landscapeDualPageEnabled
|
||||
readerView.coverPageIndex = pageURLs.isEmpty ? nil : 0
|
||||
readerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(readerView)
|
||||
readerView.register(
|
||||
contentView: RDCBZPageView.self,
|
||||
contentViewWithReuseIdentifier: NSStringFromClass(RDCBZPageView.self)
|
||||
)
|
||||
NSLayoutConstraint.activate([
|
||||
readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
readerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
readerView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
readerView.reloadData()
|
||||
readerView.transitionToPage(pageNum: restoredPageIndex(), animated: false)
|
||||
}
|
||||
|
||||
public var currentPageNumber: Int? {
|
||||
readerView.currentPage >= 0 ? readerView.currentPage + 1 : nil
|
||||
}
|
||||
|
||||
public func setDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
|
||||
readerView.switchReaderDisplayType(displayType)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func goToPage(_ pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard pageNumber > 0, pageNumber <= pageURLs.count else { return false }
|
||||
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
|
||||
return true
|
||||
}
|
||||
|
||||
private func restoredPageIndex() -> Int {
|
||||
min(max(UserDefaults.standard.integer(forKey: persistenceKey), 0), max(pageURLs.count - 1, 0))
|
||||
}
|
||||
|
||||
private var persistenceKey: String {
|
||||
"ssreader.cbz.page.\(bookURL.lastPathComponent)"
|
||||
}
|
||||
|
||||
private func show(error: Error) {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.text = error.localizedDescription
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
|
||||
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
|
||||
label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension RDCBZReaderController: RDEpubReaderPageProvider, RDEpubReaderDelegate {
|
||||
public func numberOfPages(in readerView: RDEpubReaderView) -> Int { pageURLs.count }
|
||||
|
||||
public func readerView(_ readerView: RDEpubReaderView, viewForPageAt index: Int, reusableView: UIView?) -> UIView {
|
||||
let pageView = (reusableView as? RDCBZPageView) ?? RDCBZPageView()
|
||||
pageView.configure(imageURL: pageURLs[index], backgroundColor: configuration.theme.contentBackgroundColor)
|
||||
return pageView
|
||||
}
|
||||
|
||||
public func pageIdentifier(in readerView: RDEpubReaderView, index: Int) -> String? {
|
||||
NSStringFromClass(RDCBZPageView.self)
|
||||
}
|
||||
|
||||
public func pageNum(readerView: RDEpubReaderView, pageNum: Int) {
|
||||
UserDefaults.standard.set(pageNum, forKey: persistenceKey)
|
||||
}
|
||||
}
|
||||
|
||||
private final class RDCBZPageView: UIView {
|
||||
private let imageView = UIImageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.clipsToBounds = true
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
NSLayoutConstraint.activate([
|
||||
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
imageView.topAnchor.constraint(equalTo: topAnchor),
|
||||
imageView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func configure(imageURL: URL, backgroundColor: UIColor) {
|
||||
self.backgroundColor = backgroundColor
|
||||
imageView.image = UIImage(contentsOfFile: imageURL.path)
|
||||
accessibilityLabel = imageURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
}
|
||||
|
||||
private struct RDCBZArchiveExtractor {
|
||||
private let maximumPageCount = 10_000
|
||||
private let maximumUncompressedSize: UInt64 = 2 * 1024 * 1024 * 1024
|
||||
private let imageExtensions: Set<String> = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "bmp", "tif", "tiff"]
|
||||
|
||||
func extractPages(from archiveURL: URL) throws -> [URL] {
|
||||
let archive: Archive
|
||||
do {
|
||||
archive = try Archive(url: archiveURL, accessMode: .read)
|
||||
} catch {
|
||||
throw RDCBZReaderError.archiveOpenFailed
|
||||
}
|
||||
let entries = archive.filter { entry in
|
||||
entry.type == .file && imageExtensions.contains(URL(fileURLWithPath: entry.path).pathExtension.lowercased())
|
||||
}.sorted { lhs, rhs in
|
||||
lhs.path.localizedStandardCompare(rhs.path) == .orderedAscending
|
||||
}
|
||||
guard entries.isEmpty == false else { throw RDCBZReaderError.noImages }
|
||||
guard entries.count <= maximumPageCount else { throw RDCBZReaderError.tooManyPages }
|
||||
let totalSize = entries.reduce(UInt64(0)) { partial, entry in
|
||||
let (sum, overflow) = partial.addingReportingOverflow(UInt64(entry.uncompressedSize))
|
||||
return overflow ? UInt64.max : sum
|
||||
}
|
||||
guard totalSize <= maximumUncompressedSize else { throw RDCBZReaderError.archiveTooLarge }
|
||||
|
||||
let directory = cacheDirectory(for: archiveURL)
|
||||
let fileManager = FileManager.default
|
||||
let existing = existingPages(in: directory)
|
||||
if existing.count == entries.count { return existing }
|
||||
|
||||
try? fileManager.removeItem(at: directory)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
do {
|
||||
var output: [URL] = []
|
||||
output.reserveCapacity(entries.count)
|
||||
for (index, entry) in entries.enumerated() {
|
||||
let ext = URL(fileURLWithPath: entry.path).pathExtension.lowercased()
|
||||
let destination = directory.appendingPathComponent(String(format: "%06d.%@", index, ext))
|
||||
_ = try archive.extract(entry, to: destination)
|
||||
output.append(destination)
|
||||
}
|
||||
return output
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: directory)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func existingPages(in directory: URL) -> [URL] {
|
||||
((try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? [])
|
||||
.filter { imageExtensions.contains($0.pathExtension.lowercased()) }
|
||||
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
||||
}
|
||||
|
||||
private func cacheDirectory(for archiveURL: URL) -> URL {
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: archiveURL.path)
|
||||
let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let rawName = archiveURL.deletingPathExtension().lastPathComponent
|
||||
let safeName = rawName.replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression)
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory
|
||||
return base.appendingPathComponent("ssreaderview-cbz", isDirectory: true)
|
||||
.appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true)
|
||||
}
|
||||
}
|
||||
214
Sources/RDEpubReaderView/DocumentFormats/RDMOBIParser.swift
Normal file
214
Sources/RDEpubReaderView/DocumentFormats/RDMOBIParser.swift
Normal file
@ -0,0 +1,214 @@
|
||||
import Foundation
|
||||
|
||||
public struct RDMOBIDocument: Equatable, Sendable {
|
||||
public let title: String
|
||||
public let text: String
|
||||
|
||||
public init(title: String, text: String) {
|
||||
self.title = title
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
public enum RDMOBIParserError: LocalizedError, Equatable {
|
||||
case invalidHeader
|
||||
case invalidRecordTable
|
||||
case encryptedBook
|
||||
case unsupportedCompression(UInt16)
|
||||
case emptyText
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidHeader:
|
||||
return "MOBI 文件头无效"
|
||||
case .invalidRecordTable:
|
||||
return "MOBI 记录表损坏"
|
||||
case .encryptedBook:
|
||||
return "暂不支持受 DRM 保护的 MOBI 文件"
|
||||
case let .unsupportedCompression(value):
|
||||
return "暂不支持此 MOBI 压缩方式(\(value))"
|
||||
case .emptyText:
|
||||
return "MOBI 文件中没有可阅读的正文"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reader for classic PalmDOC/MOBI6 books. Uncompressed and PalmDOC-compressed
|
||||
/// text records are supported; DRM and HUFF/CDIC books fail with a clear error.
|
||||
public final class RDMOBIParser {
|
||||
public init() {}
|
||||
|
||||
public func parse(fileURL: URL) throws -> RDMOBIDocument {
|
||||
try parse(data: Data(contentsOf: fileURL), fallbackTitle: fileURL.deletingPathExtension().lastPathComponent)
|
||||
}
|
||||
|
||||
public func parse(data: Data, fallbackTitle: String = "MOBI") throws -> RDMOBIDocument {
|
||||
guard data.count >= 86,
|
||||
let recordCount = data.rdUInt16BE(at: 76),
|
||||
recordCount > 0 else {
|
||||
throw RDMOBIParserError.invalidHeader
|
||||
}
|
||||
|
||||
let tableEnd = 78 + Int(recordCount) * 8
|
||||
guard tableEnd <= data.count else {
|
||||
throw RDMOBIParserError.invalidRecordTable
|
||||
}
|
||||
|
||||
var offsets: [Int] = []
|
||||
offsets.reserveCapacity(Int(recordCount) + 1)
|
||||
for index in 0..<Int(recordCount) {
|
||||
guard let offset = data.rdUInt32BE(at: 78 + index * 8),
|
||||
Int(offset) >= tableEnd,
|
||||
Int(offset) < data.count,
|
||||
offsets.last.map({ Int(offset) > $0 }) ?? true else {
|
||||
throw RDMOBIParserError.invalidRecordTable
|
||||
}
|
||||
offsets.append(Int(offset))
|
||||
}
|
||||
offsets.append(data.count)
|
||||
|
||||
let recordZero = data.subdata(in: offsets[0]..<offsets[1])
|
||||
guard recordZero.count >= 16,
|
||||
let compression = recordZero.rdUInt16BE(at: 0),
|
||||
let textLengthValue = recordZero.rdUInt32BE(at: 4),
|
||||
let textRecordCount = recordZero.rdUInt16BE(at: 8),
|
||||
let encryption = recordZero.rdUInt16BE(at: 12) else {
|
||||
throw RDMOBIParserError.invalidHeader
|
||||
}
|
||||
guard encryption == 0 else { throw RDMOBIParserError.encryptedBook }
|
||||
guard compression == 1 || compression == 2 else {
|
||||
throw RDMOBIParserError.unsupportedCompression(compression)
|
||||
}
|
||||
|
||||
let availableTextRecords = min(Int(textRecordCount), max(0, offsets.count - 2))
|
||||
guard availableTextRecords > 0 else { throw RDMOBIParserError.emptyText }
|
||||
|
||||
var decoded = Data()
|
||||
decoded.reserveCapacity(min(Int(textLengthValue), 16 * 1024 * 1024))
|
||||
for recordIndex in 1...availableTextRecords {
|
||||
let record = data.subdata(in: offsets[recordIndex]..<offsets[recordIndex + 1])
|
||||
if compression == 1 {
|
||||
decoded.append(record)
|
||||
} else {
|
||||
decoded.append(try decompressPalmDOC(record))
|
||||
}
|
||||
if decoded.count >= Int(textLengthValue) { break }
|
||||
}
|
||||
if decoded.count > Int(textLengthValue) {
|
||||
decoded = decoded.prefix(Int(textLengthValue))
|
||||
}
|
||||
|
||||
let encodingCode = recordZero.count >= 32 ? recordZero.rdUInt32BE(at: 28) : nil
|
||||
let rawText = decodeText(decoded, encodingCode: encodingCode)
|
||||
let text = normalizeMarkup(rawText)
|
||||
guard text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else {
|
||||
throw RDMOBIParserError.emptyText
|
||||
}
|
||||
|
||||
let pdbTitle = decodePDBTitle(data.prefix(32))
|
||||
return RDMOBIDocument(title: pdbTitle.isEmpty ? fallbackTitle : pdbTitle, text: text)
|
||||
}
|
||||
|
||||
private func decompressPalmDOC(_ input: Data) throws -> Data {
|
||||
let bytes = [UInt8](input)
|
||||
var output: [UInt8] = []
|
||||
output.reserveCapacity(bytes.count * 2)
|
||||
var index = 0
|
||||
|
||||
while index < bytes.count {
|
||||
let byte = bytes[index]
|
||||
index += 1
|
||||
switch byte {
|
||||
case 0:
|
||||
output.append(0)
|
||||
case 1...8:
|
||||
let count = Int(byte)
|
||||
guard index + count <= bytes.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
output.append(contentsOf: bytes[index..<(index + count)])
|
||||
index += count
|
||||
case 9...0x7f:
|
||||
output.append(byte)
|
||||
case 0x80...0xbf:
|
||||
guard index < bytes.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
let pair = (UInt16(byte) << 8) | UInt16(bytes[index])
|
||||
index += 1
|
||||
let distance = Int((pair & 0x3fff) >> 3)
|
||||
let count = Int(pair & 0x0007) + 3
|
||||
guard distance > 0, distance <= output.count else { throw RDMOBIParserError.invalidRecordTable }
|
||||
for _ in 0..<count {
|
||||
output.append(output[output.count - distance])
|
||||
}
|
||||
default:
|
||||
output.append(0x20)
|
||||
output.append(byte ^ 0x80)
|
||||
}
|
||||
}
|
||||
return Data(output)
|
||||
}
|
||||
|
||||
private func decodeText(_ data: Data, encodingCode: UInt32?) -> String {
|
||||
if encodingCode == 65001, let value = String(data: data, encoding: .utf8) { return value }
|
||||
if encodingCode == 1252, let value = String(data: data, encoding: .windowsCP1252) { return value }
|
||||
if let value = String(data: data, encoding: .utf8) { return value }
|
||||
if let value = String(data: data, encoding: .windowsCP1252) { return value }
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
private func decodePDBTitle(_ data: Data.SubSequence) -> String {
|
||||
let titleBytes = data.prefix { $0 != 0 }
|
||||
return String(data: Data(titleBytes), encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
private func normalizeMarkup(_ source: String) -> String {
|
||||
var text = source
|
||||
text = text.replacingOccurrences(
|
||||
of: #"(?is)<(br\s*/?|/p|/div|/h[1-6]|/li|/blockquote|/tr)>"#,
|
||||
with: "\n",
|
||||
options: .regularExpression
|
||||
)
|
||||
text = text.replacingOccurrences(of: #"(?is)<[^>]+>"#, with: "", options: .regularExpression)
|
||||
let entities: [(String, String)] = [
|
||||
(" ", " "), ("&", "&"), ("<", "<"),
|
||||
(">", ">"), (""", "\""), ("'", "'"), ("'", "'")
|
||||
]
|
||||
for (entity, value) in entities {
|
||||
text = text.replacingOccurrences(of: entity, with: value, options: .caseInsensitive)
|
||||
}
|
||||
text = decodeNumericEntities(text)
|
||||
text = text.replacingOccurrences(of: #"[\t\u{00a0}]+"#, with: " ", options: .regularExpression)
|
||||
text = text.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression)
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func decodeNumericEntities(_ source: String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: #"&#(x[0-9A-Fa-f]+|\d+);"#, options: [.caseInsensitive]) else {
|
||||
return source
|
||||
}
|
||||
let result = NSMutableString(string: source)
|
||||
let matches = regex.matches(in: source, range: NSRange(location: 0, length: result.length))
|
||||
for match in matches.reversed() {
|
||||
let token = result.substring(with: match.range(at: 1))
|
||||
let radix = token.lowercased().hasPrefix("x") ? 16 : 10
|
||||
let digits = radix == 16 ? String(token.dropFirst()) : token
|
||||
guard let value = UInt32(digits, radix: radix), let scalar = UnicodeScalar(value) else { continue }
|
||||
result.replaceCharacters(in: match.range, with: String(Character(scalar)))
|
||||
}
|
||||
return result as String
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func rdUInt16BE(at offset: Int) -> UInt16? {
|
||||
guard offset >= 0, offset + 2 <= count else { return nil }
|
||||
return (UInt16(self[offset]) << 8) | UInt16(self[offset + 1])
|
||||
}
|
||||
|
||||
func rdUInt32BE(at offset: Int) -> UInt32? {
|
||||
guard offset >= 0, offset + 4 <= count else { return nil }
|
||||
return (UInt32(self[offset]) << 24)
|
||||
| (UInt32(self[offset + 1]) << 16)
|
||||
| (UInt32(self[offset + 2]) << 8)
|
||||
| UInt32(self[offset + 3])
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
/// File formats that can be opened by `RDEpubURLReaderController`.
|
||||
public enum RDReaderDocumentFormat: String, CaseIterable, Sendable {
|
||||
case epub
|
||||
case plainText = "txt"
|
||||
case mobi
|
||||
case cbz
|
||||
|
||||
public init?(fileURL: URL) {
|
||||
self.init(rawValue: fileURL.pathExtension.lowercased())
|
||||
}
|
||||
|
||||
public static func supports(_ fileURL: URL) -> Bool {
|
||||
RDReaderDocumentFormat(fileURL: fileURL) != nil
|
||||
}
|
||||
|
||||
public static var supportedPathExtensions: Set<String> {
|
||||
Set(allCases.map(\.rawValue))
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,18 @@ public final class RDEpubPlainTextBookBuilder {
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = rd_decodeTextFile(url: textFileURL)
|
||||
return try build(text: rawText, pageSize: pageSize, style: style)
|
||||
}
|
||||
|
||||
/// Builds a paginated book from text already held in memory. This is used
|
||||
/// by container formats such as MOBI, whose text does not exist as a
|
||||
/// standalone file on disk.
|
||||
public func build(
|
||||
text: String,
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle
|
||||
) throws -> RDEPUBTextBook {
|
||||
let rawText = text
|
||||
let chapterSpecs = splitChapters(from: rawText)
|
||||
|
||||
var chapters: [RDEPUBTextChapter] = []
|
||||
|
||||
@ -73,7 +73,11 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
}
|
||||
|
||||
public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) {
|
||||
readerController?.configuration.displayType = displayType
|
||||
if let readerController {
|
||||
readerController.configuration.displayType = displayType
|
||||
} else {
|
||||
cbzReaderController?.setDisplayType(displayType)
|
||||
}
|
||||
emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
|
||||
}
|
||||
|
||||
@ -145,14 +149,16 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
|
||||
private func embedReaderController() {
|
||||
let controller: UIViewController
|
||||
if bookURL.pathExtension.lowercased() == "epub" {
|
||||
switch RDReaderDocumentFormat(fileURL: bookURL) {
|
||||
case .epub:
|
||||
controller = RDEPUBReaderController(
|
||||
epubURL: bookURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
case .cbz:
|
||||
controller = RDCBZReaderController(bookURL: bookURL, configuration: epubConfiguration)
|
||||
case .mobi, .plainText:
|
||||
let bookIdentifier = bookURL.lastPathComponent
|
||||
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
|
||||
let pageSize = currentTextPageSize()
|
||||
let renderStyle = currentTextRenderStyle()
|
||||
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
|
||||
@ -176,23 +182,33 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
imageMaxHeightRatio: 0.85
|
||||
)
|
||||
)
|
||||
if let textBook = try? builder.build(textFileURL: bookURL, pageSize: pageSize, style: renderStyle) {
|
||||
do {
|
||||
let source: (textURL: URL, title: String)
|
||||
if RDReaderDocumentFormat(fileURL: bookURL) == .mobi {
|
||||
let document = try RDMOBIParser().parse(fileURL: bookURL)
|
||||
source = (try cachedMOBITextURL(text: document.text), document.title)
|
||||
} else {
|
||||
source = (bookURL, bookURL.deletingPathExtension().lastPathComponent)
|
||||
}
|
||||
let textBook = try builder.build(textFileURL: source.textURL, pageSize: pageSize, style: renderStyle)
|
||||
controller = RDEPUBReaderController(
|
||||
textBook: textBook,
|
||||
bookIdentifier: bookIdentifier,
|
||||
title: bookTitle,
|
||||
textFileURL: bookURL,
|
||||
title: source.title,
|
||||
textFileURL: source.textURL,
|
||||
configuration: epubConfiguration
|
||||
)
|
||||
} else {
|
||||
|
||||
let fallback = UIViewController()
|
||||
let textView = UITextView()
|
||||
textView.isEditable = false
|
||||
textView.text = rd_decodeTextFile(url: bookURL)
|
||||
fallback.view = textView
|
||||
controller = fallback
|
||||
} catch {
|
||||
controller = makeErrorController(error)
|
||||
}
|
||||
case nil:
|
||||
controller = makeErrorController(
|
||||
NSError(
|
||||
domain: "RDEpubReaderView.UnsupportedFormat",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "不支持此文件格式"]
|
||||
)
|
||||
)
|
||||
}
|
||||
embeddedController = controller
|
||||
addChild(controller)
|
||||
@ -213,8 +229,15 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
embeddedController as? RDEPUBReaderController
|
||||
}
|
||||
|
||||
private var cbzReaderController: RDCBZReaderController? {
|
||||
embeddedController as? RDCBZReaderController
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
|
||||
if let cbzReaderController {
|
||||
return cbzReaderController.goToPage(pageNumber, animated: animated)
|
||||
}
|
||||
guard let readerController else { return false }
|
||||
guard pageNumber > 0 else { return false }
|
||||
|
||||
@ -451,18 +474,41 @@ public final class RDEpubURLReaderController: UIViewController {
|
||||
)
|
||||
}
|
||||
|
||||
private func rd_decodeTextFile(url: URL) -> String {
|
||||
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
return content
|
||||
private func cachedMOBITextURL(text: String) throws -> URL {
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: bookURL.path)
|
||||
let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
|
||||
let safeName = bookURL.deletingPathExtension().lastPathComponent
|
||||
.replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression)
|
||||
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("ssreaderview-mobi", isDirectory: true)
|
||||
.appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true)
|
||||
let textURL = directory.appendingPathComponent("content.txt")
|
||||
if FileManager.default.fileExists(atPath: textURL.path) { return textURL }
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try Data(text.utf8).write(to: textURL, options: .atomic)
|
||||
return textURL
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String {
|
||||
return content
|
||||
}
|
||||
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String {
|
||||
return content
|
||||
}
|
||||
return ""
|
||||
|
||||
private func makeErrorController(_ error: Error) -> UIViewController {
|
||||
let fallback = UIViewController()
|
||||
fallback.view.backgroundColor = .systemBackground
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .secondaryLabel
|
||||
label.text = error.localizedDescription
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
fallback.view.addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: fallback.view.leadingAnchor, constant: 24),
|
||||
label.trailingAnchor.constraint(equalTo: fallback.view.trailingAnchor, constant: -24),
|
||||
label.centerYAnchor.constraint(equalTo: fallback.view.centerYAnchor)
|
||||
])
|
||||
return fallback
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension RDEpubURLReaderController: RDEPUBReaderDelegate {
|
||||
|
||||
@ -2,7 +2,7 @@ Pod::Spec.new do |s|
|
||||
s.name = "RDEpubReaderView"
|
||||
s.module_name = "RDEpubReaderView"
|
||||
s.version = "0.0.2"
|
||||
s.summary = "A reader view for EPUB and plain-text books"
|
||||
s.summary = "A reader view for EPUB, TXT, MOBI, and CBZ books"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
# RDEpubReaderView
|
||||
|
||||
项目内置的 EPUB 阅读器 CocoaPods 源码。
|
||||
项目内置的 EPUB、TXT、MOBI 与 CBZ 阅读器 CocoaPods 源码。
|
||||
|
||||
统一使用 `RDEpubURLReaderController(bookURL:)` 打开文件,支持扩展名:
|
||||
|
||||
- `epub`:完整 EPUB 解析与排版
|
||||
- `txt`:纯文本章节识别与排版
|
||||
- `mobi`:无 DRM 的经典 MOBI6/PalmDOC(未压缩或 PalmDOC 压缩)
|
||||
- `cbz`:ZIP 漫画图片包,文件名自然排序、图片分页与阅读进度保存
|
||||
|
||||
MOBI 的 HUFF/CDIC 压缩、KF8 专有排版以及 DRM 内容会返回明确的不支持错误;CBZ 会限制页数和解压体积,避免异常压缩包耗尽设备资源。
|
||||
|
||||
`Podfile` 通过 `:path => 'Vendor/RDEpubReaderView'` 引用本目录;自动复制工程到其他目录打包时,不再依赖仓库外的 `ReadViewSDK` 路径。
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ Pod::Spec.new do |s|
|
||||
s.name = "RDPDFReaderView"
|
||||
s.module_name = "RDPDFReaderView"
|
||||
s.version = "0.0.1"
|
||||
s.summary = "Independent UIKit PDF reader interaction engine"
|
||||
s.summary = "UIKit PDF reader supporting host images and direct PDFKit parsing"
|
||||
s.platform = :ios, "15.0"
|
||||
s.swift_versions = ["5.10"]
|
||||
s.homepage = "https://example.invalid/RDPDFReaderView"
|
||||
@ -11,6 +11,6 @@ Pod::Spec.new do |s|
|
||||
s.license = "MIT"
|
||||
s.source_files = "{Sources,ReaderView}/**/*.swift"
|
||||
s.dependency "SnapKit", "~> 5.7"
|
||||
s.frameworks = "Vision", "CoreImage"
|
||||
s.frameworks = "Vision", "CoreImage", "PDFKit"
|
||||
s.requires_arc = true
|
||||
end
|
||||
|
||||
@ -4,6 +4,32 @@
|
||||
|
||||
主工程的下载、解密、缓存、链接、标注和持久化仍由现有 PDF Feature 管理;后续通过适配层逐步迁移,避免把业务问题和手势问题混在一起。
|
||||
|
||||
## PDF 数据来源
|
||||
|
||||
普通 PDF 可以由 SDK 使用 PDFKit 直接解析:
|
||||
|
||||
```swift
|
||||
let reader = try RDPDFReaderViewController(
|
||||
pdfURL: fileURL,
|
||||
bookIdentifier: stableBookID,
|
||||
password: standardPDFPassword,
|
||||
persistence: persistence,
|
||||
annotationPersistence: annotationStore
|
||||
)
|
||||
```
|
||||
|
||||
自定义加密 PDF 继续由宿主解密并提供页面图片,不需要把原始文件交给 SDK:
|
||||
|
||||
```swift
|
||||
let reader = RDPDFReaderViewController(
|
||||
pageProvider: encryptedImageProvider,
|
||||
persistence: persistence,
|
||||
annotationPersistence: annotationStore
|
||||
)
|
||||
```
|
||||
|
||||
两种入口最终都转换为 `RDPDFReaderPageProvider`,共用翻页、缩放、目录、书签、OCR、标注和画笔功能。内置 PDFKit 数据源按需渲染页面、限制最大图片尺寸并使用内存缓存;能够读取的原生 PDF 文字会直接用于选择和标注,图片扫描页则按配置回退到 OCR。
|
||||
|
||||
## 调试入口
|
||||
|
||||
在任意测试宿主中 push `RDPDFReaderDebugViewController()`:
|
||||
|
||||
@ -45,6 +45,8 @@ extension RDPDFReaderView: UICollectionViewDataSource, UICollectionViewDelegateF
|
||||
|
||||
private func updateCurrentPageAfterScroll(_ scrollView: UIScrollView) {
|
||||
guard currentDisplayType != .pageCurl else { return }
|
||||
// 用户主动滚动后保留其页内阅读位置;不再应用先前跳页等待异步布局的顶部锚点。
|
||||
pendingWidthFitJumpPage = nil
|
||||
if currentDisplayType == .verticalScroll {
|
||||
// 宽度适配下各 cell 高度不同,页码取视口中心命中的 cell。
|
||||
// 顶部/底部回弹时中心点会越出内容范围,钳制回内容内再取 cell。
|
||||
|
||||
296
Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift
Normal file
296
Sources/RDPDFReaderView/Sources/RDPDFKitPageProvider.swift
Normal file
@ -0,0 +1,296 @@
|
||||
import UIKit
|
||||
import PDFKit
|
||||
|
||||
public enum RDPDFKitPageProviderError: LocalizedError {
|
||||
case cannotOpen(URL)
|
||||
case passwordRequired
|
||||
case incorrectPassword
|
||||
case emptyDocument
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .cannotOpen:
|
||||
return "无法打开 PDF 文件"
|
||||
case .passwordRequired:
|
||||
return "PDF 文件需要密码"
|
||||
case .incorrectPassword:
|
||||
return "PDF 密码不正确"
|
||||
case .emptyDocument:
|
||||
return "PDF 文件中没有可阅读的页面"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SDK 内置的普通 PDF 数据源。自定义加密 PDF 仍可继续实现
|
||||
/// `RDPDFReaderPageProvider`,无需把原始文件交给 SDK。
|
||||
public final class RDPDFKitPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding, RDPDFReaderAsyncOutlineProviding {
|
||||
private let document: PDFDocument
|
||||
private let descriptor: RDPDFReaderBookDescriptor
|
||||
private let renderQueue = DispatchQueue(label: "com.readviewsdk.pdfkit.render", qos: .userInitiated)
|
||||
private let pageCache = NSCache<NSNumber, UIImage>()
|
||||
private let thumbnailCache = NSCache<NSString, UIImage>()
|
||||
private let maximumPagePixelDimension: CGFloat
|
||||
private let screenScale: CGFloat
|
||||
private let textRunDiskCache: RDPDFReaderTextRunDiskCache?
|
||||
/// 只在 `renderQueue` 上访问,PDFDocument 与文字选择始终串行。
|
||||
private var textRunsCache: [Int: [RDPDFReaderTextRun]] = [:]
|
||||
/// 只在 `renderQueue` 上读写。目录通常不变,缓存后打开导航面板无需再次遍历 PDFOutline。
|
||||
private var outlineCache: [RDPDFReaderOutlineItem]?
|
||||
|
||||
/// 默认在设备原生分辨率基础上留出约 2 倍捏合放大余量,并封顶 4096:
|
||||
/// 大屏 iPad 保持清晰度,小屏 iPhone 不为超大开本页面付出数十 MB 的位图。
|
||||
public static var defaultMaximumPagePixelDimension: CGFloat {
|
||||
let screenSize = UIScreen.main.bounds.size
|
||||
let screenMaxPixels = max(screenSize.width, screenSize.height) * UIScreen.main.scale
|
||||
return min(4_096, max(2_048, screenMaxPixels * 2))
|
||||
}
|
||||
|
||||
public init(
|
||||
pdfURL: URL,
|
||||
bookIdentifier: String? = nil,
|
||||
title: String? = nil,
|
||||
password: String? = nil,
|
||||
maximumPagePixelDimension: CGFloat = RDPDFKitPageProvider.defaultMaximumPagePixelDimension,
|
||||
cachesTextRunsOnDisk: Bool = true,
|
||||
textRunDiskCacheVersion: Int = 1
|
||||
) throws {
|
||||
guard let document = PDFDocument(url: pdfURL) else {
|
||||
throw RDPDFKitPageProviderError.cannotOpen(pdfURL)
|
||||
}
|
||||
if document.isLocked {
|
||||
guard let password, password.isEmpty == false else {
|
||||
throw RDPDFKitPageProviderError.passwordRequired
|
||||
}
|
||||
guard document.unlock(withPassword: password), document.isLocked == false else {
|
||||
throw RDPDFKitPageProviderError.incorrectPassword
|
||||
}
|
||||
}
|
||||
guard document.pageCount > 0 else { throw RDPDFKitPageProviderError.emptyDocument }
|
||||
|
||||
self.document = document
|
||||
self.maximumPagePixelDimension = max(1_024, maximumPagePixelDimension)
|
||||
screenScale = max(UIScreen.main.scale, 2)
|
||||
let metadataTitle = document.documentAttributes?[PDFDocumentAttribute.titleAttribute] as? String
|
||||
let resolvedIdentifier = bookIdentifier ?? Self.defaultBookIdentifier(for: pdfURL)
|
||||
descriptor = RDPDFReaderBookDescriptor(
|
||||
identifier: resolvedIdentifier,
|
||||
title: title ?? metadataTitle?.nonEmptyPDFTitle ?? pdfURL.deletingPathExtension().lastPathComponent,
|
||||
totalPages: document.pageCount
|
||||
)
|
||||
textRunDiskCache = cachesTextRunsOnDisk
|
||||
? RDPDFReaderTextRunDiskCache(
|
||||
bookIdentifier: resolvedIdentifier,
|
||||
cacheVersion: textRunDiskCacheVersion,
|
||||
namespace: "pdfkit-native",
|
||||
profile: "cropbox-normalized-1"
|
||||
)
|
||||
: nil
|
||||
pageCache.countLimit = 6
|
||||
pageCache.totalCostLimit = 96 * 1024 * 1024
|
||||
thumbnailCache.countLimit = 80
|
||||
thumbnailCache.totalCostLimit = 24 * 1024 * 1024
|
||||
}
|
||||
|
||||
public func readerBookDescriptor() -> RDPDFReaderBookDescriptor { descriptor }
|
||||
|
||||
public func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void) {
|
||||
guard index >= 0, index < descriptor.totalPages else {
|
||||
completion(.init(index: index, image: nil, textRuns: nil))
|
||||
return
|
||||
}
|
||||
if let image = pageCache.object(forKey: NSNumber(value: index)) {
|
||||
renderQueue.async { [weak self] in
|
||||
let runs = self?.cachedTextRuns(for: index)
|
||||
DispatchQueue.main.async {
|
||||
completion(.init(index: index, image: image, textRuns: runs?.isEmpty == false ? runs : nil))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let page = self.document.page(at: index) else {
|
||||
DispatchQueue.main.async { completion(.init(index: index, image: nil, textRuns: nil)) }
|
||||
return
|
||||
}
|
||||
let image = autoreleasepool { self.renderPage(page) }
|
||||
if let image {
|
||||
self.pageCache.setObject(image, forKey: NSNumber(value: index), cost: image.rdEstimatedMemoryCost)
|
||||
}
|
||||
let runs = self.cachedTextRuns(for: index, page: page)
|
||||
DispatchQueue.main.async {
|
||||
completion(.init(index: index, image: image, textRuns: runs.isEmpty ? nil : runs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func readerThumbnail(at index: Int, targetSize: CGSize, completion: @escaping (UIImage?) -> Void) {
|
||||
guard index >= 0, index < descriptor.totalPages,
|
||||
targetSize.width > 0, targetSize.height > 0 else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
let key = NSString(string: "\(index)-\(Int(targetSize.width.rounded()))x\(Int(targetSize.height.rounded()))")
|
||||
if let cached = thumbnailCache.object(forKey: key) {
|
||||
completion(cached)
|
||||
return
|
||||
}
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self, let page = self.document.page(at: index) else {
|
||||
DispatchQueue.main.async { completion(nil) }
|
||||
return
|
||||
}
|
||||
let image = autoreleasepool { page.thumbnail(of: targetSize, for: .cropBox) }
|
||||
self.thumbnailCache.setObject(image, forKey: key, cost: image.rdEstimatedMemoryCost)
|
||||
DispatchQueue.main.async { completion(image) }
|
||||
}
|
||||
}
|
||||
|
||||
public func readerOutlineItems() -> [RDPDFReaderOutlineItem] {
|
||||
renderQueue.sync { outlineItemsLocked() }
|
||||
}
|
||||
|
||||
public func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void) {
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let items = self.outlineItemsLocked()
|
||||
DispatchQueue.main.async { completion(items) }
|
||||
}
|
||||
}
|
||||
|
||||
public func removeCachedImages() {
|
||||
pageCache.removeAllObjects()
|
||||
thumbnailCache.removeAllObjects()
|
||||
renderQueue.async { [weak self] in self?.textRunsCache.removeAll(keepingCapacity: false) }
|
||||
}
|
||||
|
||||
/// 与控制器的页面图片缓存窗口保持一致,避免原生文本结果在长 PDF 中无限累积。
|
||||
public func trimTextRuns(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
renderQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.textRunsCache = self.textRunsCache.filter { abs($0.key - pageIndex) <= safeRadius }
|
||||
}
|
||||
}
|
||||
|
||||
private func renderPage(_ page: PDFPage) -> UIImage? {
|
||||
let bounds = page.bounds(for: .cropBox)
|
||||
guard bounds.width > 0, bounds.height > 0 else { return nil }
|
||||
let requestedMaximum = max(bounds.width, bounds.height) * screenScale
|
||||
let scale = min(screenScale, maximumPagePixelDimension / max(bounds.width, bounds.height))
|
||||
let effectiveScale = requestedMaximum > maximumPagePixelDimension ? max(scale, 0.1) : screenScale
|
||||
let pixelSize = CGSize(
|
||||
width: max(1, (bounds.width * effectiveScale).rounded(.up)),
|
||||
height: max(1, (bounds.height * effectiveScale).rounded(.up))
|
||||
)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
format.opaque = true
|
||||
return UIGraphicsImageRenderer(size: pixelSize, format: format).image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: pixelSize))
|
||||
context.cgContext.translateBy(x: 0, y: pixelSize.height)
|
||||
context.cgContext.scaleBy(x: effectiveScale, y: -effectiveScale)
|
||||
context.cgContext.translateBy(x: -bounds.minX, y: -bounds.minY)
|
||||
page.draw(with: .cropBox, to: context.cgContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func cachedTextRuns(for index: Int, page suppliedPage: PDFPage? = nil) -> [RDPDFReaderTextRun] {
|
||||
if let cached = textRunsCache[index] { return cached }
|
||||
if let cached = textRunDiskCache?.loadSynchronously(pageIndex: index) {
|
||||
textRunsCache[index] = cached
|
||||
return cached
|
||||
}
|
||||
guard let page = suppliedPage ?? document.page(at: index) else { return [] }
|
||||
let runs = textRuns(for: page)
|
||||
textRunsCache[index] = runs
|
||||
textRunDiskCache?.save(runs, pageIndex: index)
|
||||
return runs
|
||||
}
|
||||
|
||||
/// 身份签名只取文件名、大小和头部内容。绝对路径在 iOS 容器迁移(应用更新、
|
||||
/// 备份恢复)后会变化,修改时间在重新下载后会变化;它们参与签名会让以
|
||||
/// identifier 为 key 的书签、笔迹和阅读进度整体失效。
|
||||
private static func defaultBookIdentifier(for url: URL) -> String {
|
||||
let fileURL = url.standardizedFileURL
|
||||
let fileSize = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))?.fileSize
|
||||
var signature = "\(fileURL.lastPathComponent)|\(fileSize.map(String.init) ?? "")"
|
||||
if let handle = try? FileHandle(forReadingFrom: fileURL) {
|
||||
let head = handle.readData(ofLength: 64 * 1024)
|
||||
handle.closeFile()
|
||||
if head.isEmpty == false {
|
||||
signature += "|\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: head))"
|
||||
}
|
||||
}
|
||||
return "pdf.\(RDPDFReaderTextRunDiskCache.stableIdentifier(for: signature))"
|
||||
}
|
||||
|
||||
private func outlineItemsLocked() -> [RDPDFReaderOutlineItem] {
|
||||
if let outlineCache { return outlineCache }
|
||||
guard let root = document.outlineRoot else {
|
||||
let items = fallbackOutline()
|
||||
outlineCache = items
|
||||
return items
|
||||
}
|
||||
var items: [RDPDFReaderOutlineItem] = []
|
||||
func appendChildren(of outline: PDFOutline, level: Int) {
|
||||
for childIndex in 0..<outline.numberOfChildren {
|
||||
guard let child = outline.child(at: childIndex) else { continue }
|
||||
if let page = child.destination?.page {
|
||||
let pageIndex = document.index(for: page)
|
||||
if pageIndex != NSNotFound {
|
||||
items.append(.init(
|
||||
title: child.label?.nonEmptyPDFTitle ?? "第 \(pageIndex + 1) 页",
|
||||
pageIndex: pageIndex,
|
||||
level: level
|
||||
))
|
||||
}
|
||||
}
|
||||
appendChildren(of: child, level: level + 1)
|
||||
}
|
||||
}
|
||||
appendChildren(of: root, level: 0)
|
||||
let resolved = items.isEmpty ? fallbackOutline() : items
|
||||
outlineCache = resolved
|
||||
return resolved
|
||||
}
|
||||
|
||||
private func textRuns(for page: PDFPage) -> [RDPDFReaderTextRun] {
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
guard pageBounds.width > 0, pageBounds.height > 0,
|
||||
let selection = page.selection(for: pageBounds) else { return [] }
|
||||
return selection.selectionsByLine().enumerated().compactMap { order, line in
|
||||
guard let text = line.string?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false else {
|
||||
return nil
|
||||
}
|
||||
let rect = line.bounds(for: page).intersection(pageBounds)
|
||||
guard rect.isNull == false, rect.isEmpty == false else { return nil }
|
||||
let normalized = CGRect(
|
||||
x: (rect.minX - pageBounds.minX) / pageBounds.width,
|
||||
y: (pageBounds.maxY - rect.maxY) / pageBounds.height,
|
||||
width: rect.width / pageBounds.width,
|
||||
height: rect.height / pageBounds.height
|
||||
)
|
||||
return RDPDFReaderTextRun(text: text, normalizedRect: normalized, readingOrder: order)
|
||||
}
|
||||
}
|
||||
|
||||
private func fallbackOutline() -> [RDPDFReaderOutlineItem] {
|
||||
(0..<descriptor.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
var rdEstimatedMemoryCost: Int {
|
||||
guard let cgImage else { return Int(size.width * size.height * scale * scale * 4) }
|
||||
return cgImage.bytesPerRow * cgImage.height
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmptyPDFTitle: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,8 @@ public protocol RDPDFReaderPageInteractable: AnyObject {
|
||||
var readerHasActiveTextSelection: Bool { get }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool)
|
||||
func readerClearTextSelection()
|
||||
/// 显式跳页前提交正在进行的笔迹,避免页面视图被替换时丢失最后一笔。
|
||||
func readerFinishActiveDrawingStroke()
|
||||
func readerBeginExternalPinch(at point: CGPoint)
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint)
|
||||
func readerEndExternalPinch()
|
||||
@ -39,6 +41,7 @@ public extension RDPDFReaderPageInteractable {
|
||||
var readerHasActiveTextSelection: Bool { false }
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) {}
|
||||
func readerClearTextSelection() {}
|
||||
func readerFinishActiveDrawingStroke() {}
|
||||
func readerBeginExternalPinch(at point: CGPoint) {}
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {}
|
||||
func readerEndExternalPinch() {}
|
||||
@ -137,6 +140,9 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
/// UICollectionViewFlowLayout 不会因为仅修改 itemSize 就立即丢弃旋转前的布局缓存。
|
||||
/// 记录上一次实际应用的尺寸,横竖屏变化时主动失效,避免横屏首帧仍排入两个竖屏 cell。
|
||||
private var appliedCollectionItemSize = CGSize.zero
|
||||
/// 手机横屏宽度适配下,远跳的目标页可能先按占位高度布局,待真实页面数据
|
||||
/// 到达后需要重新将目标页锚定到顶部。
|
||||
var pendingWidthFitJumpPage: Int?
|
||||
private var reusableTypes: [String: UIView.Type] = [:]
|
||||
|
||||
public override init(frame: CGRect) {
|
||||
@ -263,8 +269,19 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
public func transitionToPage(pageNum: Int, animated: Bool = false) {
|
||||
guard isPagingEnabled, !isCurrentPageZoomed, !isCurrentPageTextSelectionActive, let page = clamped(pageNum) else { return }
|
||||
guard let page = clamped(pageNum) else { return }
|
||||
// 目录、缩略图、书签和宿主 API 是显式导航:提交笔迹并清除选区后允许跳页。
|
||||
// `isPagingEnabled` 仅限制用户的翻页手势,不能阻断这些入口。
|
||||
visiblePageContentViews().compactMap { $0 as? RDPDFReaderPageInteractable }.forEach {
|
||||
$0.readerFinishActiveDrawingStroke()
|
||||
$0.readerClearTextSelection()
|
||||
}
|
||||
isCurrentPageTextSelectionActive = false
|
||||
// 目录、缩略图、书签及宿主 API 均通过此入口进行显式跳页。放大层会接管
|
||||
// 当前页视图,必须先归还该视图,才能让目标页正常显示。
|
||||
if isCurrentPageZoomed { dismissZoomOverlay() }
|
||||
let displayedPage = usesLandscapeSpread ? spreadResolver.pair(for: page, totalPages: pageCount()).left : page
|
||||
if usesWidthFitVerticalScroll { pendingWidthFitJumpPage = displayedPage }
|
||||
if currentDisplayType == .pageCurl {
|
||||
transitionCurl(to: displayedPage, animated: animated)
|
||||
} else {
|
||||
@ -295,12 +312,35 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
/// 宽度适配竖滑下页面异步加载完成后,cell 高度会从"一屏高"变为真实纸张高度,
|
||||
/// 宿主拿到页面数据时调用此方法刷新布局。
|
||||
public func invalidateWidthFitLayoutIfNeeded() {
|
||||
public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
|
||||
guard usesWidthFitVerticalScroll else { return }
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
// 仅修正由目录、缩略图、书签等显式跳页建立的锚点。普通阅读中页面
|
||||
// 异步加载不应把用户正在浏览的页内位置强制拉回顶部。
|
||||
guard let updatedPage, updatedPage == pendingWidthFitJumpPage else { return }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self,
|
||||
self.usesWidthFitVerticalScroll,
|
||||
self.currentPage == updatedPage,
|
||||
self.pendingWidthFitJumpPage == updatedPage else { return }
|
||||
// 页面数据到达时用户可能已经按住屏幕拖动;此时手指拥有位置,放弃锚点回写。
|
||||
guard self.collectionView.isTracking == false else {
|
||||
self.pendingWidthFitJumpPage = nil
|
||||
return
|
||||
}
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.collectionView.setContentOffset(
|
||||
CGPoint(x: 0, y: self.clampedVerticalOffset(self.anchorY(forItem: updatedPage))),
|
||||
animated: false
|
||||
)
|
||||
self.pendingWidthFitJumpPage = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
|
||||
|
||||
/// 画笔/橡皮被选中时调用。放大层随之把单指手势让给页面画布,只保留双指拖动与捏合。
|
||||
public func setDrawingToolActive(_ active: Bool) { zoomOverlayView.isDrawingToolActive = active }
|
||||
public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } }
|
||||
public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() }
|
||||
|
||||
@ -528,6 +568,11 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
var onZoomStateChanged: ((Bool) -> Void)?
|
||||
private(set) var isPresented = false
|
||||
|
||||
/// 画笔/橡皮被选中时,放大层把单指让给页面内的画布,仅保留双指拖动与捏合。
|
||||
var isDrawingToolActive = false {
|
||||
didSet { scrollView.panGestureRecognizer.minimumNumberOfTouches = isDrawingToolActive ? 2 : 1 }
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
@ -536,8 +581,13 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
scrollView.minimumZoomScale = 0.65
|
||||
scrollView.maximumZoomScale = 3
|
||||
scrollView.bouncesZoom = true
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
// 画笔选中时笔迹必须立即送达画布,不能等滚动判定;未选中时画布不接收触摸,无副作用。
|
||||
// 保留 canCancelContentTouches 默认值:双指拖动仍可从画布手中接管触摸,
|
||||
// 画布在收到第二根手指或触摸取消时会自行丢弃未完成笔迹。
|
||||
scrollView.delaysContentTouches = false
|
||||
// Overlay 显示后的后续捏合由 UIScrollView 自己接收,也必须遵循缩小回弹流程。
|
||||
scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:)))
|
||||
addSubview(scrollView)
|
||||
@ -558,6 +608,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
|
||||
func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) {
|
||||
self.backgroundColor = backgroundColor
|
||||
// Overlay 仍隐藏时完成所有换父视图和居中布局,避免把中间态提交到屏幕。
|
||||
layoutIfNeeded()
|
||||
restoreLeasedPages()
|
||||
let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height))
|
||||
canvasView.frame = CGRect(origin: .zero, size: safeSize)
|
||||
@ -570,12 +622,14 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
$0.readerSetInternalGesturesEnabled(false)
|
||||
}
|
||||
scrollView.setZoomScale(1, animated: false)
|
||||
scrollView.contentOffset = .zero
|
||||
updateInsets()
|
||||
// UIScrollView 在带 contentInset 的静止位置不是 .zero,而是负 inset。
|
||||
// 先写 .zero 再等待 UIKit 校正会产生一帧书页贴顶的闪动。
|
||||
scrollView.contentOffset = CGPoint(x: -scrollView.contentInset.left, y: -scrollView.contentInset.top)
|
||||
// 容器在 present 后已进入放大态;即便用户第一步就是缩小,也要保证结束时能发出 false。
|
||||
reportedZoomed = true
|
||||
isPresented = true
|
||||
isHidden = false
|
||||
updateInsets()
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
@ -677,6 +731,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
|
||||
page.autoresizingMask = []
|
||||
canvasView.addSubview(page)
|
||||
page.frame = frame
|
||||
page.setNeedsLayout()
|
||||
page.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func restoreLeasedPages() {
|
||||
@ -736,6 +792,8 @@ final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pageViews: [UIView] { [leftPage, rightPage].compactMap { $0 } }
|
||||
|
||||
var readerContentTapHandler: ((CGPoint) -> Void)?
|
||||
var readerZoomStateChangedHandler: ((Bool) -> Void)?
|
||||
var readerSelectionStateChangedHandler: ((Bool) -> Void)?
|
||||
@ -744,6 +802,7 @@ final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
|
||||
|
||||
func readerSetInternalGesturesEnabled(_ enabled: Bool) { pages.forEach { $0.readerSetInternalGesturesEnabled(enabled) } }
|
||||
func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } }
|
||||
func readerFinishActiveDrawingStroke() { pages.forEach { $0.readerFinishActiveDrawingStroke() } }
|
||||
func readerBeginExternalPinch(at point: CGPoint) { activePage(at: point)?.readerBeginExternalPinch(at: point) }
|
||||
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { activePage(at: point)?.readerUpdateExternalPinch(scale: scale, at: point) }
|
||||
func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } }
|
||||
|
||||
@ -238,7 +238,8 @@ public struct RDPDFReaderHighlight: Equatable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 主程序实现此协议,把“已经解析好的页面”提供给 SDK。
|
||||
/// 主程序实现此协议,把“已经解析好的页面”提供给 SDK。页面和缩略图回调可在任意
|
||||
/// 队列触发;SDK 会在接收结果后自行切回主线程更新界面。
|
||||
public protocol RDPDFReaderPageProvider: AnyObject {
|
||||
func readerBookDescriptor() -> RDPDFReaderBookDescriptor
|
||||
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void)
|
||||
@ -267,6 +268,12 @@ public protocol RDPDFReaderOutlineProviding: AnyObject {
|
||||
func readerOutlineItems() -> [RDPDFReaderOutlineItem]
|
||||
}
|
||||
|
||||
/// 可选的异步目录提供协议。适用于目录解析可能耗时的文件格式;SDK 会在加载完成后
|
||||
/// 再展示导航面板,避免阻塞主线程。未实现时继续兼容 `RDPDFReaderOutlineProviding`。
|
||||
public protocol RDPDFReaderAsyncOutlineProviding: AnyObject {
|
||||
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void)
|
||||
}
|
||||
|
||||
/// 媒体、购买、外链等业务行为由宿主处理,SDK 不引用路由或播放器实现。
|
||||
public enum RDPDFReaderHostAction: Equatable {
|
||||
case back
|
||||
|
||||
@ -8,21 +8,36 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
public let tool: RDPDFReaderDrawingTool
|
||||
/// 笔迹所属图层。旧版文件没有该字段时会归入默认图层。
|
||||
public let layerID: UUID?
|
||||
/// 创建笔迹时的实际书页尺寸。渲染时会从该尺寸映射到当前书页尺寸,
|
||||
/// 因而横竖屏切换只改变显示比例,不改变笔迹相对 PDF 页的位置和粗细。
|
||||
/// 旧版数据没有该字段时继续按原始 UIKit 点坐标显示。
|
||||
public let referencePageSize: CGSize?
|
||||
public var points: [CGPoint]
|
||||
public let createdAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), page: Int, color: String, lineWidth: CGFloat, tool: RDPDFReaderDrawingTool, layerID: UUID? = nil, points: [CGPoint], createdAt: Date = Date()) {
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
page: Int,
|
||||
color: String,
|
||||
lineWidth: CGFloat,
|
||||
tool: RDPDFReaderDrawingTool,
|
||||
layerID: UUID? = nil,
|
||||
referencePageSize: CGSize? = nil,
|
||||
points: [CGPoint],
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.page = page
|
||||
self.color = color
|
||||
self.lineWidth = lineWidth
|
||||
self.tool = tool
|
||||
self.layerID = layerID
|
||||
self.referencePageSize = referencePageSize
|
||||
self.points = points
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case id, page, color, lineWidth, tool, layerID, points, createdAt }
|
||||
private enum CodingKeys: String, CodingKey { case id, page, color, lineWidth, tool, layerID, referencePageSize, points, createdAt }
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
@ -32,6 +47,14 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
lineWidth = try container.decode(CGFloat.self, forKey: .lineWidth)
|
||||
tool = try container.decode(RDPDFReaderDrawingTool.self, forKey: .tool)
|
||||
layerID = try container.decodeIfPresent(UUID.self, forKey: .layerID)
|
||||
if let values = try container.decodeIfPresent([CGFloat].self, forKey: .referencePageSize),
|
||||
values.count >= 2,
|
||||
values[0] > 0,
|
||||
values[1] > 0 {
|
||||
referencePageSize = CGSize(width: values[0], height: values[1])
|
||||
} else {
|
||||
referencePageSize = nil
|
||||
}
|
||||
createdAt = Date(timeIntervalSince1970: try container.decode(TimeInterval.self, forKey: .createdAt))
|
||||
let rawPoints = try container.decode([[CGFloat]].self, forKey: .points)
|
||||
points = rawPoints.compactMap { values in values.count >= 2 ? CGPoint(x: values[0], y: values[1]) : nil }
|
||||
@ -45,6 +68,9 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
|
||||
try container.encode(lineWidth, forKey: .lineWidth)
|
||||
try container.encode(tool, forKey: .tool)
|
||||
try container.encodeIfPresent(layerID, forKey: .layerID)
|
||||
if let referencePageSize {
|
||||
try container.encode([referencePageSize.width, referencePageSize.height], forKey: .referencePageSize)
|
||||
}
|
||||
try container.encode(points.map { [$0.x, $0.y] }, forKey: .points)
|
||||
try container.encode(createdAt.timeIntervalSince1970, forKey: .createdAt)
|
||||
}
|
||||
@ -115,6 +141,8 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
super.init(frame: frame)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
// 书页尺寸变化时重新按 referencePageSize 绘制,避免复用旧位图造成失真或模糊。
|
||||
contentMode = .redraw
|
||||
contentScaleFactor = UIScreen.main.scale
|
||||
isMultipleTouchEnabled = true
|
||||
}
|
||||
@ -128,7 +156,17 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
|
||||
layers = [defaultLayer]
|
||||
paths = paths.map { path in
|
||||
RDPDFReaderDrawingPath(id: path.id, page: path.page, color: path.color, lineWidth: path.lineWidth, tool: path.tool, layerID: defaultLayer.id, points: path.points, createdAt: path.createdAt)
|
||||
RDPDFReaderDrawingPath(
|
||||
id: path.id,
|
||||
page: path.page,
|
||||
color: path.color,
|
||||
lineWidth: path.lineWidth,
|
||||
tool: path.tool,
|
||||
layerID: defaultLayer.id,
|
||||
referencePageSize: path.referencePageSize,
|
||||
points: path.points,
|
||||
createdAt: path.createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
activeLayerID = layers.first(where: \.isVisible)?.id ?? layers.first?.id
|
||||
@ -142,6 +180,12 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
public func loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
|
||||
|
||||
public func currentPaths() -> [RDPDFReaderDrawingPath] { paths }
|
||||
/// 显式跳页或关闭画笔时提交当前已采集的笔迹,避免 UIView 被移除导致最后一笔丢失。
|
||||
public func finishCurrentStroke() {
|
||||
guard currentPath != nil else { return }
|
||||
commitCurrentStroke()
|
||||
activeDrawingTouch = nil
|
||||
}
|
||||
public func drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
|
||||
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
|
||||
public func selectedDrawingLayerID() -> UUID? { activeLayerID }
|
||||
@ -299,23 +343,25 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
|
||||
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
|
||||
guard !path.points.isEmpty else { return }
|
||||
let renderedPoints = path.points.map { renderedPoint($0, for: path) }
|
||||
let renderedLineWidth = path.lineWidth * renderedScale(for: path)
|
||||
context.setBlendMode(path.tool == .eraser ? .clear : .normal)
|
||||
context.setStrokeColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setFillColor(UIColor(hexString: path.color).cgColor)
|
||||
context.setLineWidth(path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth)
|
||||
context.setLineWidth(path.tool == .eraser ? eraserLineWidth(for: path, renderedLineWidth: renderedLineWidth) : renderedLineWidth)
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
context.setAlpha(path.tool == .highlighter ? 0.3 : 1)
|
||||
if path.points.count == 1 {
|
||||
let radius = (path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth) / 2
|
||||
let point = path.points[0]
|
||||
if renderedPoints.count == 1 {
|
||||
let radius = (path.tool == .eraser ? eraserLineWidth(for: path, renderedLineWidth: renderedLineWidth) : renderedLineWidth) / 2
|
||||
let point = renderedPoints[0]
|
||||
context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
return
|
||||
}
|
||||
context.beginPath()
|
||||
addSmoothedCurve(for: path.points, to: context)
|
||||
addSmoothedCurve(for: renderedPoints, to: context)
|
||||
context.strokePath()
|
||||
context.setAlpha(1)
|
||||
context.setBlendMode(.normal)
|
||||
@ -339,13 +385,35 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
|
||||
lineWidth: tool == .highlighter ? 8 : lineWidth,
|
||||
tool: tool,
|
||||
layerID: activeLayerID,
|
||||
referencePageSize: bounds.size,
|
||||
points: [clampedPoint(point)]
|
||||
)
|
||||
}
|
||||
|
||||
private func eraserLineWidth(for path: RDPDFReaderDrawingPath) -> CGFloat {
|
||||
// SheetMusic 的橡皮与画笔共享大小滑杆;在 UIKit 点坐标系中给出可感知的对应直径。
|
||||
max(path.lineWidth * 2, 12)
|
||||
private func renderedPoint(_ point: CGPoint, for path: RDPDFReaderDrawingPath) -> CGPoint {
|
||||
guard let referenceSize = path.referencePageSize,
|
||||
referenceSize.width > 0,
|
||||
referenceSize.height > 0,
|
||||
bounds.width > 0,
|
||||
bounds.height > 0 else { return point }
|
||||
return CGPoint(
|
||||
x: point.x * bounds.width / referenceSize.width,
|
||||
y: point.y * bounds.height / referenceSize.height
|
||||
)
|
||||
}
|
||||
|
||||
private func renderedScale(for path: RDPDFReaderDrawingPath) -> CGFloat {
|
||||
guard let referenceSize = path.referencePageSize,
|
||||
referenceSize.width > 0,
|
||||
referenceSize.height > 0,
|
||||
bounds.width > 0,
|
||||
bounds.height > 0 else { return 1 }
|
||||
return min(bounds.width / referenceSize.width, bounds.height / referenceSize.height)
|
||||
}
|
||||
|
||||
private func eraserLineWidth(for path: RDPDFReaderDrawingPath, renderedLineWidth: CGFloat) -> CGFloat {
|
||||
// 橡皮最小直径也随实际书页缩放,避免横竖屏切换后擦除范围与笔迹比例脱节。
|
||||
max(renderedLineWidth * 2, 12 * renderedScale(for: path))
|
||||
}
|
||||
|
||||
/// 触点使用 Catmull–Rom 到三次贝塞尔的转换。这样保留原始笔迹数据和撤销语义,
|
||||
|
||||
@ -346,10 +346,13 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
|
||||
}
|
||||
|
||||
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// UIKit 会就“命中本视图的所有手势”(含外层翻页滚动、阅读器捏合)调用此方法;
|
||||
// 这里只允许约束本层自己的选择手势,其余一律交还默认判定,否则画笔会话
|
||||
// 关闭选区(isSelectionEnabled = false)时会连带否决外层的拖动与缩放。
|
||||
let ownsGesture = gestureRecognizer === longPressGestureRecognizer
|
||||
|| gestureRecognizer === selectionHandlePanGestureRecognizer
|
||||
guard ownsGesture else { return super.gestureRecognizerShouldBegin(gestureRecognizer) }
|
||||
guard isSelectionEnabled, bounds.width > 0, bounds.height > 0 else { return false }
|
||||
if gestureRecognizer === longPressGestureRecognizer {
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === selectionHandlePanGestureRecognizer {
|
||||
guard let selection = selectedSelection, selection.source != .region else { return false }
|
||||
return selectionHandle(at: gestureRecognizer.location(in: self)) != nil
|
||||
|
||||
@ -20,6 +20,11 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
label: "com.readoor.pdf-reader.image-text-recognizer",
|
||||
qos: .userInitiated
|
||||
)
|
||||
private let requestLock = NSLock()
|
||||
private var pendingRequests: [UUID: DispatchWorkItem] = [:]
|
||||
/// 已进入 `perform` 的请求。`DispatchWorkItem.cancel()` 中断不了它们,
|
||||
/// 必须对 VNRequest 本身调用 `cancel()`。
|
||||
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
|
||||
|
||||
public init(
|
||||
recognitionLevel: VNRequestTextRecognitionLevel = .accurate,
|
||||
@ -32,10 +37,12 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
}
|
||||
|
||||
/// 异步识别页面图片中的文字。结果的矩形以图片左上角为原点,范围为 0...1。
|
||||
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) {
|
||||
@discardableResult
|
||||
public func recognizeTextRuns(in image: UIImage, completion: @escaping Completion) -> UUID {
|
||||
let requestID = UUID()
|
||||
guard let cgImage = Self.makeCGImage(from: image) else {
|
||||
deliver([], to: completion)
|
||||
return
|
||||
return requestID
|
||||
}
|
||||
|
||||
let configuration = Configuration(
|
||||
@ -45,22 +52,50 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
)
|
||||
let orientation = CGImagePropertyOrientation(orientation: image.imageOrientation)
|
||||
|
||||
processingQueue.async {
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self else { return }
|
||||
let request = VNRecognizeTextRequest { request, _ in
|
||||
let observations = request.results as? [VNRecognizedTextObservation] ?? []
|
||||
let runs = Self.makeTextRuns(from: observations)
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
self.deliver(runs, to: completion)
|
||||
}
|
||||
request.recognitionLevel = configuration.recognitionLevel
|
||||
request.recognitionLanguages = configuration.recognitionLanguages
|
||||
request.usesLanguageCorrection = configuration.usesLanguageCorrection
|
||||
|
||||
guard self.registerActiveVisionRequest(request, for: requestID) else { return }
|
||||
var performFailed = false
|
||||
do {
|
||||
try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
|
||||
} catch {
|
||||
performFailed = true
|
||||
}
|
||||
self.unregisterActiveVisionRequest(requestID)
|
||||
if performFailed {
|
||||
guard self.removeRequest(requestID) else { return }
|
||||
self.deliver([], to: completion)
|
||||
}
|
||||
}
|
||||
requestLock.lock()
|
||||
pendingRequests[requestID] = workItem
|
||||
requestLock.unlock()
|
||||
processingQueue.async(execute: workItem)
|
||||
return requestID
|
||||
}
|
||||
|
||||
/// 丢弃尚未开始的识别请求,并忽略正在执行请求的完成结果。
|
||||
/// 用于快速翻页后优先让当前停留页进入串行 OCR 队列。
|
||||
public func cancelAllRequests() {
|
||||
requestLock.lock()
|
||||
let queued = pendingRequests.values
|
||||
let inFlight = activeVisionRequests.values
|
||||
pendingRequests.removeAll()
|
||||
activeVisionRequests.removeAll()
|
||||
requestLock.unlock()
|
||||
queued.forEach { $0.cancel() }
|
||||
// 让正在执行的识别尽快返回,当前停留页不必等整页识别跑完才能入队。
|
||||
inFlight.forEach { $0.cancel() }
|
||||
}
|
||||
|
||||
/// Swift Concurrency 版本,与回调版本使用相同的识别和主线程回调语义。
|
||||
@ -171,6 +206,30 @@ public final class RDPDFReaderImageTextRecognizer {
|
||||
completion(runs)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有仍处于活跃状态的请求才能进入执行阶段并登记可取消的 VNRequest。
|
||||
private func registerActiveVisionRequest(_ request: VNRecognizeTextRequest, for requestID: UUID) -> Bool {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard pendingRequests[requestID]?.isCancelled == false else { return false }
|
||||
activeVisionRequests[requestID] = request
|
||||
return true
|
||||
}
|
||||
|
||||
private func unregisterActiveVisionRequest(_ requestID: UUID) {
|
||||
requestLock.lock()
|
||||
activeVisionRequests.removeValue(forKey: requestID)
|
||||
requestLock.unlock()
|
||||
}
|
||||
|
||||
/// 仅活跃请求能取得完成权;被取消或已替代的请求结果必须丢弃。
|
||||
@discardableResult
|
||||
private func removeRequest(_ requestID: UUID) -> Bool {
|
||||
requestLock.lock()
|
||||
defer { requestLock.unlock() }
|
||||
guard let request = pendingRequests.removeValue(forKey: requestID), !request.isCancelled else { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGImagePropertyOrientation {
|
||||
@ -197,3 +256,87 @@ private extension CGImagePropertyOrientation {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面文本结果的可清除磁盘缓存。OCR 与 PDFKit 原生文本使用不同命名空间,避免混用。
|
||||
final class RDPDFReaderTextRunDiskCache {
|
||||
private struct Document: Codable {
|
||||
let version: Int
|
||||
let runs: [RDPDFReaderTextRun]
|
||||
}
|
||||
|
||||
private static let documentVersion = 1
|
||||
private let rootURL: URL
|
||||
private let queue = DispatchQueue(label: "com.readoor.pdf-reader.ocr-disk-cache", qos: .utility)
|
||||
|
||||
init(bookIdentifier: String, cacheVersion: Int, namespace: String, profile: String = "") {
|
||||
// 目录分两级:书籍散列在外、命名空间+版本+配置在内。这样 version/profile
|
||||
// 变更后能定位并删除同一本书同一命名空间下不再使用的旧目录。
|
||||
let variantName = "\(namespace)-v\(cacheVersion)-\(Self.stableIdentifier(for: profile))"
|
||||
let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
let bookRootURL = cachesURL
|
||||
.appendingPathComponent("RDPDFReaderView", isDirectory: true)
|
||||
.appendingPathComponent("TextRuns", isDirectory: true)
|
||||
.appendingPathComponent(Self.stableIdentifier(for: bookIdentifier), isDirectory: true)
|
||||
rootURL = bookRootURL.appendingPathComponent(variantName, isDirectory: true)
|
||||
queue.async {
|
||||
let entries = (try? FileManager.default.contentsOfDirectory(
|
||||
at: bookRootURL,
|
||||
includingPropertiesForKeys: nil
|
||||
)) ?? []
|
||||
for entry in entries
|
||||
where entry.lastPathComponent.hasPrefix("\(namespace)-") && entry.lastPathComponent != variantName {
|
||||
try? FileManager.default.removeItem(at: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func load(pageIndex: Int, completion: @escaping ([RDPDFReaderTextRun]?) -> Void) {
|
||||
queue.async {
|
||||
let runs = self.loadLocked(pageIndex: pageIndex)
|
||||
DispatchQueue.main.async { completion(runs) }
|
||||
}
|
||||
}
|
||||
|
||||
/// PDFKit 的渲染队列在后台调用,避免为了读磁盘缓存再切换到主线程。
|
||||
func loadSynchronously(pageIndex: Int) -> [RDPDFReaderTextRun]? {
|
||||
queue.sync { loadLocked(pageIndex: pageIndex) }
|
||||
}
|
||||
|
||||
func save(_ runs: [RDPDFReaderTextRun], pageIndex: Int) {
|
||||
let url = fileURL(for: pageIndex)
|
||||
queue.async {
|
||||
guard let data = try? JSONEncoder().encode(Document(version: Self.documentVersion, runs: runs)) else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: self.rootURL, withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
// 文本缓存不可用时仍可重新提取/识别,不能影响阅读流程。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fileURL(for pageIndex: Int) -> URL {
|
||||
rootURL.appendingPathComponent("\(max(0, pageIndex)).json")
|
||||
}
|
||||
|
||||
static func stableIdentifier(for value: String) -> String {
|
||||
stableIdentifier(for: Array(value.utf8))
|
||||
}
|
||||
|
||||
static func stableIdentifier<Bytes: Sequence>(for bytes: Bytes) -> String where Bytes.Element == UInt8 {
|
||||
var hash: UInt64 = 14_695_981_039_346_656_037
|
||||
for byte in bytes {
|
||||
hash ^= UInt64(byte)
|
||||
hash &*= 1_099_511_628_211
|
||||
}
|
||||
return String(hash, radix: 16)
|
||||
}
|
||||
|
||||
private func loadLocked(pageIndex: Int) -> [RDPDFReaderTextRun]? {
|
||||
let url = fileURL(for: pageIndex)
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let document = try? JSONDecoder().decode(Document.self, from: data),
|
||||
document.version == Self.documentVersion else { return nil }
|
||||
return document.runs
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
|
||||
public func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) }
|
||||
public func readerClearTextSelection() { textLayer.clearSelection() }
|
||||
public func readerFinishActiveDrawingStroke() { drawingCanvas.finishCurrentStroke() }
|
||||
public func readerBeginExternalPinch(at point: CGPoint) { zoomView.beginExternalPinch(at: convert(point, to: zoomView)) }
|
||||
public func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {
|
||||
zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView))
|
||||
@ -135,6 +136,8 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
|
||||
) {
|
||||
textLayer.pageIndex = pageIndex
|
||||
textLayer.textSource = textSource
|
||||
// 无文字来源时长按空白进入区域框选;有文字时保持与 EPUB 一致只响应文本。
|
||||
textLayer.allowsRegionSelection = textSource == .region
|
||||
textLayer.textRuns = textRuns
|
||||
textLayer.annotations = annotations
|
||||
updateAccessibilityViewport()
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import UIKit
|
||||
import SnapKit
|
||||
|
||||
/// 成品 PDF 阅读控制器。宿主提供已解析的页面和可选存储;阅读交互、OCR、标注及面板
|
||||
/// 由 SDK 统一编排,因此 SDK 不需要接触 PDFKit、数据库或应用路由。
|
||||
/// 成品 PDF 阅读控制器。自定义加密文件由宿主提供页面;普通 PDF 可由 SDK 使用
|
||||
/// PDFKit 直接解析。两种来源共用阅读交互、OCR、标注及面板。
|
||||
public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate {
|
||||
|
||||
public struct Configuration {
|
||||
@ -13,9 +13,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
public var initialThemeIdentifier: Int = 0
|
||||
public var enablesOCR = true
|
||||
public var recognitionLanguages: [String] = []
|
||||
/// 将 Vision 识别结果写入系统可清除的缓存目录;再次打开同一书籍时可直接复用。
|
||||
public var cachesOCRResultsOnDisk = true
|
||||
/// 书籍内容、识别语言或 OCR 算法策略变更时递增,以使旧缓存自动失效。
|
||||
public var ocrDiskCacheVersion = 1
|
||||
/// 将 PDFKit 原生文本与坐标写入系统可清除的缓存目录。
|
||||
public var cachesNativeTextResultsOnDisk = true
|
||||
/// PDF 内容、裁剪框规则或原生文本坐标转换策略变更时递增。
|
||||
public var nativeTextDiskCacheVersion = 1
|
||||
/// 未提供文字且 OCR 关闭时,页面仍可使用区域标注。
|
||||
public var missingTextSource: RDPDFReaderAnnotationSource = .region
|
||||
/// 页面图片归宿主所有;宿主可在此进行缓存、反色或其它主题渲染。
|
||||
/// SDK 直读 PDF 时,控制器保留当前页前后页面描述的半径,避免长 PDF 阅读时
|
||||
/// 持续持有已离开的页面图片。宿主 Provider 的原有缓存行为保持不变。
|
||||
public var pageDescriptorCacheRadius = 3
|
||||
/// 内存中保留当前页前后的 OCR 结果数量;默认沿用页面描述缓存半径。
|
||||
public var ocrMemoryCacheRadius: Int?
|
||||
/// 宿主或内置 Provider 提供页面后,可在此进行反色或其它主题渲染。
|
||||
public var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
|
||||
|
||||
public init() {}
|
||||
@ -29,14 +42,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
private let readerView = RDPDFReaderView()
|
||||
private let recognizer: RDPDFReaderImageTextRecognizer
|
||||
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
|
||||
private var book: RDPDFReaderBookDescriptor
|
||||
private var currentTheme: RDPDFReaderThemeOption
|
||||
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
|
||||
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
|
||||
private var recognizingPages = Set<Int>()
|
||||
/// 每页的逻辑请求标识。快速翻页取消旧请求后,迟到回调不能覆盖当前状态。
|
||||
private var ocrRequestTokens: [Int: UUID] = [:]
|
||||
private var bookmarks = Set<Int>()
|
||||
private weak var topToolbar: RDPDFReaderKitTopToolView?
|
||||
private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
|
||||
private var navigationLoadingIndicator: UIActivityIndicatorView?
|
||||
private let drawingToolbar = RDPDFReaderDrawingToolbar()
|
||||
private var isDrawingMode = false
|
||||
/// `nil` 时处于画笔面板内的浏览状态,可单指拖动画面。
|
||||
@ -66,10 +83,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
currentTheme = configuration.themes.first { $0.identifier == configuration.initialThemeIdentifier } ?? configuration.themes[0]
|
||||
preferredDisplayType = configuration.displayType
|
||||
recognizer = RDPDFReaderImageTextRecognizer(recognitionLanguages: configuration.recognitionLanguages)
|
||||
ocrDiskCache = configuration.cachesOCRResultsOnDisk
|
||||
? RDPDFReaderTextRunDiskCache(
|
||||
bookIdentifier: book.identifier,
|
||||
cacheVersion: configuration.ocrDiskCacheVersion,
|
||||
namespace: "vision-ocr",
|
||||
profile: "\(configuration.recognitionLanguages.sorted().joined(separator: ","))|accurate-1"
|
||||
)
|
||||
: nil
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
title = book.title
|
||||
}
|
||||
|
||||
/// 直接打开普通 PDF。标准 PDF 密码可通过 `password` 传入;自定义加密文件应继续
|
||||
/// 使用 `init(pageProvider:...)`,由宿主解密后提供页面图片。
|
||||
public convenience init(
|
||||
pdfURL: URL,
|
||||
bookIdentifier: String? = nil,
|
||||
title: String? = nil,
|
||||
password: String? = nil,
|
||||
persistence: RDPDFReaderPersistence? = nil,
|
||||
annotationPersistence: RDPDFReaderAnnotationPersisting? = nil,
|
||||
configuration: Configuration = .init()
|
||||
) throws {
|
||||
let provider = try RDPDFKitPageProvider(
|
||||
pdfURL: pdfURL,
|
||||
bookIdentifier: bookIdentifier,
|
||||
title: title,
|
||||
password: password,
|
||||
cachesTextRunsOnDisk: configuration.cachesNativeTextResultsOnDisk,
|
||||
textRunDiskCacheVersion: configuration.nativeTextDiskCacheVersion
|
||||
)
|
||||
self.init(
|
||||
pageProvider: provider,
|
||||
persistence: persistence,
|
||||
annotationPersistence: annotationPersistence,
|
||||
configuration: configuration
|
||||
)
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
public override func viewDidLoad() {
|
||||
@ -100,6 +152,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? [])
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
cancelOutstandingOCRRequests()
|
||||
// 保留仍在屏幕上的页:描述被清掉后没有任何路径会重新请求它,
|
||||
// 当前页会因取不到 image 而永远无法恢复选字/划线。
|
||||
let visibleIndexes = Set(visiblePageViews().map(\.tag))
|
||||
pageDescriptors = pageDescriptors.filter { visibleIndexes.contains($0.key) }
|
||||
ocrRuns = ocrRuns.filter { visibleIndexes.contains($0.key) }
|
||||
(pageProvider as? RDPDFKitPageProvider)?.removeCachedImages()
|
||||
visibleIndexes.sorted().forEach { startOCRIfNeeded($0) }
|
||||
}
|
||||
|
||||
public func switchDisplayType(_ type: RDPDFReaderView.DisplayType) {
|
||||
preferredDisplayType = type
|
||||
applyDisplayTypeForCurrentInterface()
|
||||
@ -170,6 +234,21 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
|
||||
public func pageNum(readerView: RDPDFReaderView, pageNum: Int) {
|
||||
guard pageNum >= 0 else { return }
|
||||
// OCR 队列是串行的。快速翻页后取消过期任务,避免停留页排在大量离开页之后。
|
||||
cancelOutstandingOCRRequests()
|
||||
// 取消是全量的:双页的右页和竖滑可见邻页也被一并取消,必须重新排队,
|
||||
// 否则它们在 cell 重建前一直没有文字层。当前页先入队以获得串行队列优先权。
|
||||
startOCRIfNeeded(pageNum)
|
||||
visiblePageViews().map(\.tag).filter { $0 != pageNum }.sorted().forEach { startOCRIfNeeded($0) }
|
||||
trimOCRCache(
|
||||
around: pageNum,
|
||||
radius: configuration.ocrMemoryCacheRadius ?? configuration.pageDescriptorCacheRadius
|
||||
)
|
||||
trimPageDescriptorCache(around: pageNum, radius: configuration.pageDescriptorCacheRadius)
|
||||
(pageProvider as? RDPDFKitPageProvider)?.trimTextRuns(
|
||||
around: pageNum,
|
||||
radius: configuration.pageDescriptorCacheRadius
|
||||
)
|
||||
title = "PDF · \(pageNum + 1) / \(book.totalPages)"
|
||||
topToolbar?.setTitle(title ?? book.title)
|
||||
topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum))
|
||||
@ -177,6 +256,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
delegate?.pdfReaderViewController(self, didChangePage: pageNum)
|
||||
}
|
||||
|
||||
private func trimPageDescriptorCache(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
let stalePages = pageDescriptors.keys.filter { abs($0 - pageIndex) > safeRadius }
|
||||
for cachedPage in stalePages {
|
||||
pageDescriptors.removeValue(forKey: cachedPage)
|
||||
}
|
||||
}
|
||||
|
||||
private func trimOCRCache(around pageIndex: Int, radius: Int) {
|
||||
let safeRadius = max(0, radius)
|
||||
let stalePages = ocrRuns.keys.filter { abs($0 - pageIndex) > safeRadius }
|
||||
stalePages.forEach { ocrRuns.removeValue(forKey: $0) }
|
||||
}
|
||||
|
||||
private func configure(_ page: RDPDFReaderPageView, at index: Int) {
|
||||
let descriptor = pageDescriptors[index]
|
||||
page.image = descriptor.flatMap { renderedImage($0.image) }
|
||||
@ -203,13 +296,15 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
guard descriptor == nil else { startOCRIfNeeded(index); return }
|
||||
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
|
||||
DispatchQueue.main.async { [weak self, weak page] in
|
||||
guard let self, descriptor.index == index else { return }
|
||||
self.pageDescriptors[index] = descriptor
|
||||
// 宽度适配竖滑下,占位的一屏高 cell 要按真实纸张比例重新排版。
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded()
|
||||
self.readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
|
||||
if let page { self.configure(page, at: index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func renderedImage(_ image: UIImage?) -> UIImage? {
|
||||
guard let image else { return nil }
|
||||
@ -220,12 +315,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil,
|
||||
!recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return }
|
||||
recognizingPages.insert(index)
|
||||
recognizer.recognizeTextRuns(in: image) { [weak self] runs in
|
||||
guard let self else { return }
|
||||
self.recognizingPages.remove(index)
|
||||
self.ocrRuns[index] = runs
|
||||
self.refreshVisiblePage(index)
|
||||
let token = UUID()
|
||||
ocrRequestTokens[index] = token
|
||||
ocrDiskCache?.load(pageIndex: index) { [weak self] cachedRuns in
|
||||
guard let self, self.ocrRequestTokens[index] == token else { return }
|
||||
if let cachedRuns {
|
||||
self.completeOCR(cachedRuns, pageIndex: index, token: token, shouldPersist: false)
|
||||
return
|
||||
}
|
||||
self.recognizer.recognizeTextRuns(in: image) { [weak self] runs in
|
||||
self?.completeOCR(runs, pageIndex: index, token: token, shouldPersist: true)
|
||||
}
|
||||
}
|
||||
// 没有磁盘缓存时直接入队,避免等待一个永远不会调用的读取回调。
|
||||
if ocrDiskCache == nil {
|
||||
recognizer.recognizeTextRuns(in: image) { [weak self] runs in
|
||||
self?.completeOCR(runs, pageIndex: index, token: token, shouldPersist: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func completeOCR(
|
||||
_ runs: [RDPDFReaderTextRun],
|
||||
pageIndex: Int,
|
||||
token: UUID,
|
||||
shouldPersist: Bool
|
||||
) {
|
||||
guard ocrRequestTokens[pageIndex] == token else { return }
|
||||
ocrRequestTokens.removeValue(forKey: pageIndex)
|
||||
recognizingPages.remove(pageIndex)
|
||||
ocrRuns[pageIndex] = runs
|
||||
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
|
||||
// 只有仍在屏幕上的页会被重新配置;快速翻过的页面不抢占当前页 UI 更新。
|
||||
refreshVisiblePage(pageIndex)
|
||||
}
|
||||
|
||||
private func cancelOutstandingOCRRequests() {
|
||||
recognizer.cancelAllRequests()
|
||||
recognizingPages.removeAll()
|
||||
ocrRequestTokens.removeAll()
|
||||
}
|
||||
|
||||
private func refreshVisiblePage(_ index: Int) {
|
||||
@ -233,6 +361,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
configure(page, at: index)
|
||||
}
|
||||
|
||||
/// 屏幕上所有内容页视图。双页模式下 `visiblePageContentViews` 返回的是 spread
|
||||
/// 容器,需要拆出左右两个内容页。
|
||||
private func visiblePageViews() -> [RDPDFReaderPageView] {
|
||||
readerView.visiblePageContentViews()
|
||||
.flatMap { view -> [UIView] in
|
||||
if let spread = view as? RDPDFReaderPageSpreadView { return spread.pageViews }
|
||||
return [view]
|
||||
}
|
||||
.compactMap { $0 as? RDPDFReaderPageView }
|
||||
.filter { $0.tag >= 0 }
|
||||
}
|
||||
|
||||
private func annotations(for index: Int) -> [RDPDFReaderAnnotation] {
|
||||
do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] }
|
||||
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] }
|
||||
@ -248,12 +388,52 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
|
||||
private func showNavigation() {
|
||||
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems() ?? (0..<book.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
if let asyncOutlineProvider = pageProvider as? RDPDFReaderAsyncOutlineProviding {
|
||||
showNavigationLoading()
|
||||
asyncOutlineProvider.readerOutlineItems { [weak self] outline in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.hideNavigationLoading()
|
||||
self.presentNavigation(outline: outline)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
let outline = (pageProvider as? RDPDFReaderOutlineProviding)?.readerOutlineItems()
|
||||
?? (0..<book.totalPages).map { .init(title: "第 \($0 + 1) 页", pageIndex: $0) }
|
||||
presentNavigation(outline: outline)
|
||||
}
|
||||
|
||||
private func presentNavigation(outline: [RDPDFReaderOutlineItem]) {
|
||||
let marks = bookmarks.sorted().map { RDPDFReaderBookmark(pageIndex: $0, title: "第 \($0 + 1) 页") }
|
||||
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: outline, bookmarks: marks, totalPages: book.totalPages, thumbnailProvider: { [weak self] index, size, completion in self?.pageProvider.readerThumbnail(at: index, targetSize: size, completion: completion) }, onSelectPage: { [weak self] page in self?.readerView.transitionToPage(pageNum: page, animated: false); self?.readerView.hideToolViewIfNeeded() })
|
||||
let panel = RDPDFReaderNavigationPanelViewController(outlineItems: outline, bookmarks: marks, totalPages: book.totalPages, thumbnailProvider: { [weak self] index, size, completion in
|
||||
guard let self else {
|
||||
DispatchQueue.main.async { completion(nil) }
|
||||
return
|
||||
}
|
||||
self.pageProvider.readerThumbnail(at: index, targetSize: size) { image in
|
||||
DispatchQueue.main.async { completion(image) }
|
||||
}
|
||||
}, onSelectPage: { [weak self] page in self?.readerView.transitionToPage(pageNum: page, animated: false); self?.readerView.hideToolViewIfNeeded() })
|
||||
RDPDFReaderPanelPresenter.present(UINavigationController(rootViewController: panel), from: self, layout: .navigation)
|
||||
}
|
||||
|
||||
private func showNavigationLoading() {
|
||||
guard navigationLoadingIndicator == nil else { return }
|
||||
let indicator = UIActivityIndicatorView(style: .large)
|
||||
indicator.hidesWhenStopped = true
|
||||
view.addSubview(indicator)
|
||||
indicator.snp.makeConstraints { $0.center.equalToSuperview() }
|
||||
indicator.startAnimating()
|
||||
navigationLoadingIndicator = indicator
|
||||
}
|
||||
|
||||
private func hideNavigationLoading() {
|
||||
navigationLoadingIndicator?.stopAnimating()
|
||||
navigationLoadingIndicator?.removeFromSuperview()
|
||||
navigationLoadingIndicator = nil
|
||||
}
|
||||
|
||||
private func showSettings() {
|
||||
let panel = RDPDFReaderSettingsPanelViewController(
|
||||
displayType: readerView.currentDisplayType,
|
||||
@ -319,6 +499,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
/// 由外层 collectionView 承担,不能在画笔面板打开期间一概禁用。
|
||||
private func updateDrawingInteractionState() {
|
||||
readerView.setPagingEnabled(!isDrawingMode || currentDrawingTool == nil)
|
||||
readerView.setDrawingToolActive(isDrawingMode && currentDrawingTool != nil)
|
||||
configureVisibleDrawingPages()
|
||||
}
|
||||
|
||||
@ -438,7 +619,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
|
||||
}
|
||||
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didCopyText text: String) {
|
||||
delegate?.pdfReaderViewController(self, didCopyText: text)
|
||||
}
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
|
||||
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
|
||||
@ -475,10 +658,13 @@ public protocol RDPDFReaderViewControllerDelegate: AnyObject {
|
||||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController)
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int)
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error)
|
||||
/// 用户通过选区菜单复制文字后回调;宿主可在此做提示或埋点。
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String)
|
||||
}
|
||||
|
||||
public extension RDPDFReaderViewControllerDelegate {
|
||||
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {}
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {}
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {}
|
||||
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user