feat: optimize reader caching and document formats

This commit is contained in:
shenlei 2026-07-20 17:22:06 +09:00
parent 52f803e8db
commit 68d9363f0a
35 changed files with 3771 additions and 2229 deletions

5
.gitignore vendored
View File

@ -33,3 +33,8 @@ ReadViewDemo/build/
# AI tool output # AI tool output
_ssoft-output/ _ssoft-output/
.claude/
# Python bytecode
__pycache__/
*.py[cod]

View File

@ -45,8 +45,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDEpubReaderView: 6bab24a23236a223b29a02f3d00b6ac7829a4b97 RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
RDPDFReaderView: ea8c225655c8fdc1f34d75026e05fa1e50744b36 RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

View File

@ -2,7 +2,7 @@
"name": "RDEpubReaderView", "name": "RDEpubReaderView",
"module_name": "RDEpubReaderView", "module_name": "RDEpubReaderView",
"version": "0.0.2", "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": { "platforms": {
"ios": "15.0" "ios": "15.0"
}, },

View File

@ -2,7 +2,7 @@
"name": "RDPDFReaderView", "name": "RDPDFReaderView",
"module_name": "RDPDFReaderView", "module_name": "RDPDFReaderView",
"version": "0.0.1", "version": "0.0.1",
"summary": "Independent UIKit PDF reader interaction engine", "summary": "UIKit PDF reader supporting host images and direct PDFKit parsing",
"platforms": { "platforms": {
"ios": "15.0" "ios": "15.0"
}, },
@ -25,7 +25,8 @@
}, },
"frameworks": [ "frameworks": [
"Vision", "Vision",
"CoreImage" "CoreImage",
"PDFKit"
], ],
"requires_arc": true, "requires_arc": true,
"swift_version": "5.10" "swift_version": "5.10"

View File

@ -45,8 +45,8 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb DTCoreText: 11b7fe2104f476f82e75a4e3dbdde74d7186cecb
DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0 DTFoundation: 76b624967cf5bcaae6bb057d622c536c36ef36d0
RDEpubReaderView: 6bab24a23236a223b29a02f3d00b6ac7829a4b97 RDEpubReaderView: 5a09a7856f04b6f2f8610aa6fd965d35cadb8e17
RDPDFReaderView: ea8c225655c8fdc1f34d75026e05fa1e50744b36 RDPDFReaderView: f7c2d0ea7c129aa4ab15ae7f9daad2974573d749
SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a SnapKit: d612e99e678a2d3b95bf60b0705ed0a35c03484a
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351

File diff suppressed because it is too large Load Diff

View File

@ -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" 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' 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 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_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 OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}

View File

@ -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" 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' 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 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_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 OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}

View File

@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift 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 OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -3,7 +3,7 @@ CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/RDPDFReaderView
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit" FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/SnapKit"
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1 GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
LIBRARY_SEARCH_PATHS = $(inherited) "${TOOLCHAIN_DIR}/usr/lib/swift/${PLATFORM_NAME}" /usr/lib/swift 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 OTHER_SWIFT_FLAGS = $(inherited) -D COCOAPODS
PODS_BUILD_DIR = ${BUILD_DIR} PODS_BUILD_DIR = ${BUILD_DIR}
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME) PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)

View File

@ -20,6 +20,7 @@
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; }; 1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; }; 1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.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 */; }; 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.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 */; }; 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 */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -58,6 +71,7 @@
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AsyncChapterLoadingTests.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@ -187,6 +213,18 @@
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */, FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */, 1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.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 */, 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */,
1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */, 1A2B3C4D0000000AAABBCC01 /* ReaderAnnotationExtendedTests.swift */,
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */, 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */,
@ -202,6 +240,7 @@
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */, 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */,
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */, 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */, 1A2B3C4D00000031AABBCC01 /* FixedLayoutRotationTests.swift */,
1A2B3C4D00000032AABBCC01 /* PDFDrawingModeTests.swift */,
); );
path = ReaderUITests; path = ReaderUITests;
sourceTree = "<group>"; sourceTree = "<group>";
@ -355,6 +394,18 @@
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */, 1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.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 */, 52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */,
1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */, 1A2B3C4D00000009AABBCC01 /* ReaderAnnotationExtendedTests.swift in Sources */,
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */, C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */,
@ -370,6 +421,7 @@
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */, 1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */,
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */, 1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */, 1A2B3C4D00000030AABBCC01 /* FixedLayoutRotationTests.swift in Sources */,
1A2B3C4D00000033AABBCC01 /* PDFDrawingModeTests.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -10,19 +10,23 @@ enum PDFDemoImageTextSource: String {
case none case none
} }
/// PDFKit 宿 PDF SDK /// 宿 PDF
private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding { private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOutlineProviding, RDPDFReaderAsyncOutlineProviding {
private let document: PDFDocument private let document: PDFDocument
private let identifier: String private let identifier: String
private let title: String private let title: String
private let textSource: PDFDemoImageTextSource 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) { init(document: PDFDocument, identifier: String, title: String, textSource: PDFDemoImageTextSource) {
self.document = document self.document = document
self.identifier = identifier self.identifier = identifier
self.title = title self.title = title
self.textSource = textSource self.textSource = textSource
images.countLimit = 6
images.totalCostLimit = 96 * 1024 * 1024
} }
func readerBookDescriptor() -> RDPDFReaderBookDescriptor { func readerBookDescriptor() -> RDPDFReaderBookDescriptor {
@ -35,19 +39,46 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
case .hostFixture: textRuns = fixtureTextRuns(for: index) case .hostFixture: textRuns = fixtureTextRuns(for: index)
case .vision, .none: textRuns = nil 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) { 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
let box = page.bounds(for: .mediaBox) guard let self, let page = self.document.page(at: index) else {
let scale = min(targetSize.width / box.width, targetSize.height / box.height) * UIScreen.main.scale DispatchQueue.main.async { completion(nil) }
let size = CGSize(width: box.width * scale, height: box.height * scale) return
completion(render(page, size: size, scale: scale)) }
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)
let image = self.render(page, size: size, scale: scale)
DispatchQueue.main.async { completion(image) }
}
} }
func readerOutlineItems() -> [RDPDFReaderOutlineItem] { 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] = [] var items: [RDPDFReaderOutlineItem] = []
func append(_ outline: PDFOutline, level: Int) { func append(_ outline: PDFOutline, level: Int) {
for index in 0..<outline.numberOfChildren { for index in 0..<outline.numberOfChildren {
@ -60,7 +91,9 @@ private final class PDFDemoPageProvider: RDPDFReaderPageProvider, RDPDFReaderOut
} }
} }
append(root, level: 0) 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 { 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? { 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 } guard let page = document.page(at: index) else { return nil }
let box = page.bounds(for: .mediaBox) let box = page.bounds(for: .mediaBox)
let scale = max(2, UIScreen.main.scale) let scale = max(2, UIScreen.main.scale)
let image = render(page, size: CGSize(width: box.width * scale, height: box.height * scale), scale: 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 return image
} }
@ -118,6 +153,8 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
private let reader: RDPDFReaderViewController private let reader: RDPDFReaderViewController
private var navigationBarHiddenBeforeReader: Bool? private var navigationBarHiddenBeforeReader: Bool?
private var display = "pagecurl" private var display = "pagecurl"
private var lastPageIndex = 0
private var lastCopiedText: String?
private let stateLabel: UILabel = { private let stateLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.accessibilityIdentifier = "demo.reader.state" label.accessibilityIdentifier = "demo.reader.state"
@ -127,19 +164,44 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
}() }()
init(url: URL, bookIdentifier: String? = nil, textSource: PDFDemoImageTextSource = .vision) { init(url: URL, bookIdentifier: String? = nil, textSource: PDFDemoImageTextSource = .vision) {
guard let document = PDFDocument(url: url) else { fatalError("无法打开 PDF") }
let identifier = bookIdentifier ?? url.lastPathComponent 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)) let store = RDPDFReaderPersistenceStore(rootURL: Self.annotationStorageURL(for: identifier, legacyBookURL: url))
var configuration = RDPDFReaderViewController.Configuration() var configuration = RDPDFReaderViewController.Configuration()
configuration.enablesOCR = textSource == .vision configuration.enablesOCR = textSource == .vision
configuration.missingTextSource = .region configuration.missingTextSource = .region
configuration.recognitionLanguages = ["zh-Hans", "en-US"] 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) super.init(nibName: nil, bundle: nil)
reader.delegate = self reader.delegate = self
title = provider.readerBookDescriptor().title title = url.deletingPathExtension().lastPathComponent
} }
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } 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, 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) { func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {
stateLabel.text = "reader=opened mode=pdf annotationStorage=error" stateLabel.text = "reader=opened mode=pdf annotationStorage=error"
guard presentedViewController == nil else { return } guard presentedViewController == nil else { return }
@ -190,7 +257,16 @@ final class PDFDemoReaderViewController: UIViewController, RDPDFReaderViewContro
present(alert, animated: true) 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 { private static func annotationStorageURL(for bookIdentifier: String, legacyBookURL: URL) -> URL {
let root = annotationStorageRoot() let root = annotationStorageRoot()

View File

@ -112,7 +112,7 @@ final class ViewController: UIViewController {
label.textAlignment = .center label.textAlignment = .center
label.textColor = .secondaryLabel label.textColor = .secondaryLabel
label.font = .systemFont(ofSize: 15) label.font = .systemFont(ofSize: 15)
label.text = "book 目录下暂时没有可阅读的 txt 或 epub 文件" label.text = "book 目录下暂时没有可阅读的 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
label.accessibilityIdentifier = "demo.books.empty" label.accessibilityIdentifier = "demo.books.empty"
return label return label
}() }()
@ -165,7 +165,7 @@ final class ViewController: UIViewController {
private func updateChrome() { private func updateChrome() {
if books.isEmpty { if books.isEmpty {
statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 txt 或 epub 文件" statusLabel.text = "ReadViewSDK Demo\nbook 目录下未找到 EPUB、TXT、MOBI、CBZ 或 PDF 文件"
tableView.backgroundView = emptyStateLabel tableView.backgroundView = emptyStateLabel
} else { } else {
statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器" statusLabel.text = "ReadViewSDK Demo\n已发现 \(books.count) 本本地图书,点击即可进入阅读器"
@ -191,7 +191,7 @@ final class ViewController: UIViewController {
return fileURLs return fileURLs
.filter { url in .filter { url in
let ext = url.pathExtension.lowercased() let ext = url.pathExtension.lowercased()
return ext == "epub" || ext == "txt" || ext == "pdf" return RDReaderDocumentFormat.supports(url) || ext == "pdf"
} }
.reduce(into: [URL]()) { result, url in .reduce(into: [URL]()) { result, url in
if !result.contains(url) { if !result.contains(url) {

View File

@ -44,9 +44,9 @@ enum IDs {
/// The text layer is the precise, image-coordinate based interaction surface. /// The text layer is the precise, image-coordinate based interaction surface.
static let pdfTextLayer = "pdf.reader.text.layer" static let pdfTextLayer = "pdf.reader.text.layer"
static let pdfOCRStatus = "pdf.reader.ocr.status" static let pdfOCRStatus = "pdf.reader.ocr.status"
/// PDF EPUB 使// /// PDF EPUB 使/线/
static let pdfSelectionMenuCopy = "复制" static let pdfSelectionMenuCopy = "复制"
static let pdfSelectionMenuHighlight = "高亮" static let pdfSelectionMenuHighlight = "划线"
static let pdfSelectionMenuAnnotate = "注释" static let pdfSelectionMenuAnnotate = "注释"
static let pdfCopyStatus = "pdf.reader.copy.status" static let pdfCopyStatus = "pdf.reader.copy.status"

View File

@ -119,7 +119,9 @@ final class HighlightsManagementTests: XCTestCase {
let deleteControl = highlightsTable.buttons["删除"].firstMatch let deleteControl = highlightsTable.buttons["删除"].firstMatch
XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件") XCTAssertTrue(deleteControl.waitForExistence(timeout: 3), "编辑状态应显示删除控件")
deleteControl.tap() 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), "删除后应显示空状态") XCTAssertTrue(app.staticTexts[IDs.readerHighlightsEmptyLabel].waitForExistence(timeout: 5), "删除后应显示空状态")
} }

View File

@ -153,7 +153,7 @@ final class LocationPersistenceTests: XCTestCase {
} }
func testHighlightNavigationSurvivesFontChoiceChange() throws { func testHighlightNavigationSurvivesFontChoiceChange() throws {
app.launchAndOpenSampleBook(resetsReaderState: true, pageNumber: 3) app.launchAndOpenSampleBook(pageNumber: 3, resetsReaderState: true)
app.waitForReader(timeout: 12) app.waitForReader(timeout: 12)
let initialState = app.waitForDemoReaderState(timeout: 8, description: "初始位于第 3 页") { state in let initialState = app.waitForDemoReaderState(timeout: 8, description: "初始位于第 3 页") { state in

View File

@ -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 { private func waitForLandscapeLayout() -> Bool {
let deadline = Date().addingTimeInterval(8) let deadline = Date().addingTimeInterval(8)
while Date() < deadline { while Date() < deadline {
@ -69,8 +109,12 @@ final class PDFDrawingModeTests: XCTestCase {
} }
private func scrollToAnotherPage(_ content: XCUIElement, from page: Int) -> Bool { private func scrollToAnotherPage(_ content: XCUIElement, from page: Int) -> Bool {
let window = app.windows.firstMatch
for _ in 0..<5 { 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) let deadline = Date().addingTimeInterval(1.5)
while Date() < deadline { while Date() < deadline {
if let current = app.currentDemoReaderState()?.page, current != page { return true } if let current = app.currentDemoReaderState()?.page, current != page { return true }

View File

@ -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)
}
}

View 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)] = [
("&nbsp;", " "), ("&amp;", "&"), ("&lt;", "<"),
("&gt;", ">"), ("&quot;", "\""), ("&apos;", "'"), ("&#39;", "'")
]
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])
}
}

View File

@ -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))
}
}

View File

@ -18,6 +18,18 @@ public final class RDEpubPlainTextBookBuilder {
style: RDEPUBTextRenderStyle style: RDEPUBTextRenderStyle
) throws -> RDEPUBTextBook { ) throws -> RDEPUBTextBook {
let rawText = rd_decodeTextFile(url: textFileURL) 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) let chapterSpecs = splitChapters(from: rawText)
var chapters: [RDEPUBTextChapter] = [] var chapters: [RDEPUBTextChapter] = []

View File

@ -73,7 +73,11 @@ public final class RDEpubURLReaderController: UIViewController {
} }
public func applyDemoDisplayType(_ displayType: RDEpubReaderView.DisplayType) { 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)") emitDemoState(prefix: "display=\(displayType.demoArgumentValue)")
} }
@ -145,14 +149,16 @@ public final class RDEpubURLReaderController: UIViewController {
private func embedReaderController() { private func embedReaderController() {
let controller: UIViewController let controller: UIViewController
if bookURL.pathExtension.lowercased() == "epub" { switch RDReaderDocumentFormat(fileURL: bookURL) {
case .epub:
controller = RDEPUBReaderController( controller = RDEPUBReaderController(
epubURL: bookURL, epubURL: bookURL,
configuration: epubConfiguration configuration: epubConfiguration
) )
} else { case .cbz:
controller = RDCBZReaderController(bookURL: bookURL, configuration: epubConfiguration)
case .mobi, .plainText:
let bookIdentifier = bookURL.lastPathComponent let bookIdentifier = bookURL.lastPathComponent
let bookTitle = bookURL.deletingPathExtension().lastPathComponent
let pageSize = currentTextPageSize() let pageSize = currentTextPageSize()
let renderStyle = currentTextRenderStyle() let renderStyle = currentTextRenderStyle()
let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets) let safeInsets = RDEPUBSafeArea.resolve(view.safeAreaInsets)
@ -176,23 +182,33 @@ public final class RDEpubURLReaderController: UIViewController {
imageMaxHeightRatio: 0.85 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( controller = RDEPUBReaderController(
textBook: textBook, textBook: textBook,
bookIdentifier: bookIdentifier, bookIdentifier: bookIdentifier,
title: bookTitle, title: source.title,
textFileURL: bookURL, textFileURL: source.textURL,
configuration: epubConfiguration configuration: epubConfiguration
) )
} else { } catch {
controller = makeErrorController(error)
let fallback = UIViewController()
let textView = UITextView()
textView.isEditable = false
textView.text = rd_decodeTextFile(url: bookURL)
fallback.view = textView
controller = fallback
} }
case nil:
controller = makeErrorController(
NSError(
domain: "RDEpubReaderView.UnsupportedFormat",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "不支持此文件格式"]
)
)
} }
embeddedController = controller embeddedController = controller
addChild(controller) addChild(controller)
@ -213,8 +229,15 @@ public final class RDEpubURLReaderController: UIViewController {
embeddedController as? RDEPUBReaderController embeddedController as? RDEPUBReaderController
} }
private var cbzReaderController: RDCBZReaderController? {
embeddedController as? RDCBZReaderController
}
@discardableResult @discardableResult
private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool { private func performDemoPageNavigation(_ pageNumber: Int, animated: Bool) -> Bool {
if let cbzReaderController {
return cbzReaderController.goToPage(pageNumber, animated: animated)
}
guard let readerController else { return false } guard let readerController else { return false }
guard pageNumber > 0 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 { private func cachedMOBITextURL(text: String) throws -> URL {
if let content = try? NSString(contentsOf: url, encoding: String.Encoding.utf8.rawValue) as String { let attributes = try? FileManager.default.attributesOfItem(atPath: bookURL.path)
return content let size = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0
} let modified = (attributes?[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0
if let content = try? NSString(contentsOf: url, encoding: 0x80000632) as String { let safeName = bookURL.deletingPathExtension().lastPathComponent
return content .replacingOccurrences(of: #"[^A-Za-z0-9._-]"#, with: "_", options: .regularExpression)
} let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
if let content = try? NSString(contentsOf: url, encoding: 0x80000631) as String { ?? FileManager.default.temporaryDirectory
return content let directory = base.appendingPathComponent("ssreaderview-mobi", isDirectory: true)
} .appendingPathComponent("\(safeName)-\(size)-\(Int64(modified))", isDirectory: true)
return "" 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
} }
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 { extension RDEpubURLReaderController: RDEPUBReaderDelegate {

View File

@ -2,7 +2,7 @@ Pod::Spec.new do |s|
s.name = "RDEpubReaderView" s.name = "RDEpubReaderView"
s.module_name = "RDEpubReaderView" s.module_name = "RDEpubReaderView"
s.version = "0.0.2" 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.platform = :ios, "15.0"
s.swift_versions = ["5.10"] s.swift_versions = ["5.10"]
s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git" s.homepage = "http://192.168.21.200:8418/4v5u09Z5a4Yuc/ReadViewSDK.git"

View File

@ -1,6 +1,15 @@
# RDEpubReaderView # 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` 路径。 `Podfile` 通过 `:path => 'Vendor/RDEpubReaderView'` 引用本目录;自动复制工程到其他目录打包时,不再依赖仓库外的 `ReadViewSDK` 路径。

View File

@ -2,7 +2,7 @@ Pod::Spec.new do |s|
s.name = "RDPDFReaderView" s.name = "RDPDFReaderView"
s.module_name = "RDPDFReaderView" s.module_name = "RDPDFReaderView"
s.version = "0.0.1" 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.platform = :ios, "15.0"
s.swift_versions = ["5.10"] s.swift_versions = ["5.10"]
s.homepage = "https://example.invalid/RDPDFReaderView" s.homepage = "https://example.invalid/RDPDFReaderView"
@ -11,6 +11,6 @@ Pod::Spec.new do |s|
s.license = "MIT" s.license = "MIT"
s.source_files = "{Sources,ReaderView}/**/*.swift" s.source_files = "{Sources,ReaderView}/**/*.swift"
s.dependency "SnapKit", "~> 5.7" s.dependency "SnapKit", "~> 5.7"
s.frameworks = "Vision", "CoreImage" s.frameworks = "Vision", "CoreImage", "PDFKit"
s.requires_arc = true s.requires_arc = true
end end

View File

@ -4,6 +4,32 @@
主工程的下载、解密、缓存、链接、标注和持久化仍由现有 PDF Feature 管理;后续通过适配层逐步迁移,避免把业务问题和手势问题混在一起。 主工程的下载、解密、缓存、链接、标注和持久化仍由现有 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()` 在任意测试宿主中 push `RDPDFReaderDebugViewController()`

View File

@ -45,6 +45,8 @@ extension RDPDFReaderView: UICollectionViewDataSource, UICollectionViewDelegateF
private func updateCurrentPageAfterScroll(_ scrollView: UIScrollView) { private func updateCurrentPageAfterScroll(_ scrollView: UIScrollView) {
guard currentDisplayType != .pageCurl else { return } guard currentDisplayType != .pageCurl else { return }
//
pendingWidthFitJumpPage = nil
if currentDisplayType == .verticalScroll { if currentDisplayType == .verticalScroll {
// cell cell // cell cell
// / cell // / cell

View 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
}
}

View File

@ -23,6 +23,8 @@ public protocol RDPDFReaderPageInteractable: AnyObject {
var readerHasActiveTextSelection: Bool { get } var readerHasActiveTextSelection: Bool { get }
func readerSetInternalGesturesEnabled(_ enabled: Bool) func readerSetInternalGesturesEnabled(_ enabled: Bool)
func readerClearTextSelection() func readerClearTextSelection()
///
func readerFinishActiveDrawingStroke()
func readerBeginExternalPinch(at point: CGPoint) func readerBeginExternalPinch(at point: CGPoint)
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint)
func readerEndExternalPinch() func readerEndExternalPinch()
@ -39,6 +41,7 @@ public extension RDPDFReaderPageInteractable {
var readerHasActiveTextSelection: Bool { false } var readerHasActiveTextSelection: Bool { false }
func readerSetInternalGesturesEnabled(_ enabled: Bool) {} func readerSetInternalGesturesEnabled(_ enabled: Bool) {}
func readerClearTextSelection() {} func readerClearTextSelection() {}
func readerFinishActiveDrawingStroke() {}
func readerBeginExternalPinch(at point: CGPoint) {} func readerBeginExternalPinch(at point: CGPoint) {}
func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {} func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {}
func readerEndExternalPinch() {} func readerEndExternalPinch() {}
@ -137,6 +140,9 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
/// UICollectionViewFlowLayout itemSize /// UICollectionViewFlowLayout itemSize
/// cell /// cell
private var appliedCollectionItemSize = CGSize.zero private var appliedCollectionItemSize = CGSize.zero
///
///
var pendingWidthFitJumpPage: Int?
private var reusableTypes: [String: UIView.Type] = [:] private var reusableTypes: [String: UIView.Type] = [:]
public override init(frame: CGRect) { public override init(frame: CGRect) {
@ -263,8 +269,19 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
} }
public func transitionToPage(pageNum: Int, animated: Bool = false) { 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 let displayedPage = usesLandscapeSpread ? spreadResolver.pair(for: page, totalPages: pageCount()).left : page
if usesWidthFitVerticalScroll { pendingWidthFitJumpPage = displayedPage }
if currentDisplayType == .pageCurl { if currentDisplayType == .pageCurl {
transitionCurl(to: displayedPage, animated: animated) transitionCurl(to: displayedPage, animated: animated)
} else { } else {
@ -295,12 +312,35 @@ public final class RDPDFReaderView: UIView, UIGestureRecognizerDelegate {
/// cell "" /// cell ""
/// 宿 /// 宿
public func invalidateWidthFitLayoutIfNeeded() { public func invalidateWidthFitLayoutIfNeeded(updatedPage: Int? = nil) {
guard usesWidthFitVerticalScroll else { return } guard usesWidthFitVerticalScroll else { return }
collectionView.collectionViewLayout.invalidateLayout() 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 setPagingEnabled(_ enabled: Bool) { isPagingEnabled = enabled; updatePagingState() }
/// /
public func setDrawingToolActive(_ active: Bool) { zoomOverlayView.isDrawingToolActive = active }
public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } } public func hideToolViewIfNeeded() { if isToolViewVisible { toggleToolbars() } }
public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() } public func refreshCurrentPageIfNeeded() { pageContentView(pageNum: currentPage)?.setNeedsLayout() }
@ -528,6 +568,11 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
var onZoomStateChanged: ((Bool) -> Void)? var onZoomStateChanged: ((Bool) -> Void)?
private(set) var isPresented = false private(set) var isPresented = false
/// /
var isDrawingToolActive = false {
didSet { scrollView.panGestureRecognizer.minimumNumberOfTouches = isDrawingToolActive ? 2 : 1 }
}
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .white backgroundColor = .white
@ -536,8 +581,13 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
scrollView.minimumZoomScale = 0.65 scrollView.minimumZoomScale = 0.65
scrollView.maximumZoomScale = 3 scrollView.maximumZoomScale = 3
scrollView.bouncesZoom = true scrollView.bouncesZoom = true
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.showsHorizontalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false
//
// canCancelContentTouches
//
scrollView.delaysContentTouches = false
// Overlay UIScrollView // Overlay UIScrollView
scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:))) scrollView.pinchGestureRecognizer?.addTarget(self, action: #selector(handleOverlayPinch(_:)))
addSubview(scrollView) addSubview(scrollView)
@ -558,6 +608,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) { func present(leftPage: UIView, rightPage: UIView?, bookSize: CGSize, backgroundColor: UIColor) {
self.backgroundColor = backgroundColor self.backgroundColor = backgroundColor
// Overlay
layoutIfNeeded()
restoreLeasedPages() restoreLeasedPages()
let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height)) let safeSize = CGSize(width: max(1, bookSize.width), height: max(1, bookSize.height))
canvasView.frame = CGRect(origin: .zero, size: safeSize) canvasView.frame = CGRect(origin: .zero, size: safeSize)
@ -570,12 +622,14 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
$0.readerSetInternalGesturesEnabled(false) $0.readerSetInternalGesturesEnabled(false)
} }
scrollView.setZoomScale(1, animated: 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 // present 便 false
reportedZoomed = true reportedZoomed = true
isPresented = true isPresented = true
isHidden = false isHidden = false
updateInsets()
} }
func dismiss() { func dismiss() {
@ -677,6 +731,8 @@ final class RDPDFReaderZoomOverlayView: UIView, UIScrollViewDelegate, UIGestureR
page.autoresizingMask = [] page.autoresizingMask = []
canvasView.addSubview(page) canvasView.addSubview(page)
page.frame = frame page.frame = frame
page.setNeedsLayout()
page.layoutIfNeeded()
} }
private func restoreLeasedPages() { private func restoreLeasedPages() {
@ -736,6 +792,8 @@ final class RDPDFReaderPageSpreadView: UIView, RDPDFReaderPageInteractable {
return nil return nil
} }
var pageViews: [UIView] { [leftPage, rightPage].compactMap { $0 } }
var readerContentTapHandler: ((CGPoint) -> Void)? var readerContentTapHandler: ((CGPoint) -> Void)?
var readerZoomStateChangedHandler: ((Bool) -> Void)? var readerZoomStateChangedHandler: ((Bool) -> Void)?
var readerSelectionStateChangedHandler: ((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 readerSetInternalGesturesEnabled(_ enabled: Bool) { pages.forEach { $0.readerSetInternalGesturesEnabled(enabled) } }
func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } } func readerClearTextSelection() { pages.forEach { $0.readerClearTextSelection() } }
func readerFinishActiveDrawingStroke() { pages.forEach { $0.readerFinishActiveDrawingStroke() } }
func readerBeginExternalPinch(at point: CGPoint) { activePage(at: point)?.readerBeginExternalPinch(at: point) } 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 readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { activePage(at: point)?.readerUpdateExternalPinch(scale: scale, at: point) }
func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } } func readerEndExternalPinch() { pages.forEach { $0.readerEndExternalPinch() } }

View File

@ -238,7 +238,8 @@ public struct RDPDFReaderHighlight: Equatable, Codable {
} }
} }
/// SDK /// SDK
/// SDK 线
public protocol RDPDFReaderPageProvider: AnyObject { public protocol RDPDFReaderPageProvider: AnyObject {
func readerBookDescriptor() -> RDPDFReaderBookDescriptor func readerBookDescriptor() -> RDPDFReaderBookDescriptor
func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void) func readerPage(at index: Int, completion: @escaping (RDPDFReaderPageDescriptor) -> Void)
@ -267,6 +268,12 @@ public protocol RDPDFReaderOutlineProviding: AnyObject {
func readerOutlineItems() -> [RDPDFReaderOutlineItem] func readerOutlineItems() -> [RDPDFReaderOutlineItem]
} }
/// SDK
/// 线 `RDPDFReaderOutlineProviding`
public protocol RDPDFReaderAsyncOutlineProviding: AnyObject {
func readerOutlineItems(completion: @escaping ([RDPDFReaderOutlineItem]) -> Void)
}
/// 宿SDK /// 宿SDK
public enum RDPDFReaderHostAction: Equatable { public enum RDPDFReaderHostAction: Equatable {
case back case back

View File

@ -8,21 +8,36 @@ public struct RDPDFReaderDrawingPath: Codable, Equatable {
public let tool: RDPDFReaderDrawingTool public let tool: RDPDFReaderDrawingTool
/// ///
public let layerID: UUID? public let layerID: UUID?
///
/// PDF
/// UIKit
public let referencePageSize: CGSize?
public var points: [CGPoint] public var points: [CGPoint]
public let createdAt: Date 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.id = id
self.page = page self.page = page
self.color = color self.color = color
self.lineWidth = lineWidth self.lineWidth = lineWidth
self.tool = tool self.tool = tool
self.layerID = layerID self.layerID = layerID
self.referencePageSize = referencePageSize
self.points = points self.points = points
self.createdAt = createdAt 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 { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) 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) lineWidth = try container.decode(CGFloat.self, forKey: .lineWidth)
tool = try container.decode(RDPDFReaderDrawingTool.self, forKey: .tool) tool = try container.decode(RDPDFReaderDrawingTool.self, forKey: .tool)
layerID = try container.decodeIfPresent(UUID.self, forKey: .layerID) 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)) createdAt = Date(timeIntervalSince1970: try container.decode(TimeInterval.self, forKey: .createdAt))
let rawPoints = try container.decode([[CGFloat]].self, forKey: .points) 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 } 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(lineWidth, forKey: .lineWidth)
try container.encode(tool, forKey: .tool) try container.encode(tool, forKey: .tool)
try container.encodeIfPresent(layerID, forKey: .layerID) 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(points.map { [$0.x, $0.y] }, forKey: .points)
try container.encode(createdAt.timeIntervalSince1970, forKey: .createdAt) try container.encode(createdAt.timeIntervalSince1970, forKey: .createdAt)
} }
@ -115,6 +141,8 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
super.init(frame: frame) super.init(frame: frame)
isOpaque = false isOpaque = false
backgroundColor = .clear backgroundColor = .clear
// referencePageSize
contentMode = .redraw
contentScaleFactor = UIScreen.main.scale contentScaleFactor = UIScreen.main.scale
isMultipleTouchEnabled = true isMultipleTouchEnabled = true
} }
@ -128,7 +156,17 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1") let defaultLayer = RDPDFReaderDrawingLayer(name: "图层 1")
layers = [defaultLayer] layers = [defaultLayer]
paths = paths.map { path in 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 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 loadPaths(_ paths: [RDPDFReaderDrawingPath]) { load(document: .init(pageNo: currentPage, paths: paths)) }
public func currentPaths() -> [RDPDFReaderDrawingPath] { 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 drawingDocument() -> RDPDFReaderDrawingDocument { .init(pageNo: currentPage, paths: paths, layers: layers) }
public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers } public func drawingLayers() -> [RDPDFReaderDrawingLayer] { layers }
public func selectedDrawingLayerID() -> UUID? { activeLayerID } public func selectedDrawingLayerID() -> UUID? { activeLayerID }
@ -299,23 +343,25 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) { private func draw(path: RDPDFReaderDrawingPath, in context: CGContext) {
guard !path.points.isEmpty else { return } 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.setBlendMode(path.tool == .eraser ? .clear : .normal)
context.setStrokeColor(UIColor(hexString: path.color).cgColor) context.setStrokeColor(UIColor(hexString: path.color).cgColor)
context.setFillColor(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.setLineCap(.round)
context.setLineJoin(.round) context.setLineJoin(.round)
context.setAlpha(path.tool == .highlighter ? 0.3 : 1) context.setAlpha(path.tool == .highlighter ? 0.3 : 1)
if path.points.count == 1 { if renderedPoints.count == 1 {
let radius = (path.tool == .eraser ? eraserLineWidth(for: path) : path.lineWidth) / 2 let radius = (path.tool == .eraser ? eraserLineWidth(for: path, renderedLineWidth: renderedLineWidth) : renderedLineWidth) / 2
let point = path.points[0] let point = renderedPoints[0]
context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2)) context.fillEllipse(in: CGRect(x: point.x - radius, y: point.y - radius, width: radius * 2, height: radius * 2))
context.setAlpha(1) context.setAlpha(1)
context.setBlendMode(.normal) context.setBlendMode(.normal)
return return
} }
context.beginPath() context.beginPath()
addSmoothedCurve(for: path.points, to: context) addSmoothedCurve(for: renderedPoints, to: context)
context.strokePath() context.strokePath()
context.setAlpha(1) context.setAlpha(1)
context.setBlendMode(.normal) context.setBlendMode(.normal)
@ -339,13 +385,35 @@ public final class RDPDFReaderDrawingCanvasView: UIView {
lineWidth: tool == .highlighter ? 8 : lineWidth, lineWidth: tool == .highlighter ? 8 : lineWidth,
tool: tool, tool: tool,
layerID: activeLayerID, layerID: activeLayerID,
referencePageSize: bounds.size,
points: [clampedPoint(point)] points: [clampedPoint(point)]
) )
} }
private func eraserLineWidth(for path: RDPDFReaderDrawingPath) -> CGFloat { private func renderedPoint(_ point: CGPoint, for path: RDPDFReaderDrawingPath) -> CGPoint {
// SheetMusic UIKit guard let referenceSize = path.referencePageSize,
max(path.lineWidth * 2, 12) 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))
} }
/// 使 CatmullRom /// 使 CatmullRom

View File

@ -346,10 +346,13 @@ public final class RDPDFReaderImageTextLayerView: UIView, UIGestureRecognizerDel
} }
public override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { 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 } guard isSelectionEnabled, bounds.width > 0, bounds.height > 0 else { return false }
if gestureRecognizer === longPressGestureRecognizer {
return true
}
if gestureRecognizer === selectionHandlePanGestureRecognizer { if gestureRecognizer === selectionHandlePanGestureRecognizer {
guard let selection = selectedSelection, selection.source != .region else { return false } guard let selection = selectedSelection, selection.source != .region else { return false }
return selectionHandle(at: gestureRecognizer.location(in: self)) != nil return selectionHandle(at: gestureRecognizer.location(in: self)) != nil

View File

@ -20,6 +20,11 @@ public final class RDPDFReaderImageTextRecognizer {
label: "com.readoor.pdf-reader.image-text-recognizer", label: "com.readoor.pdf-reader.image-text-recognizer",
qos: .userInitiated qos: .userInitiated
) )
private let requestLock = NSLock()
private var pendingRequests: [UUID: DispatchWorkItem] = [:]
/// `perform` `DispatchWorkItem.cancel()`
/// VNRequest `cancel()`
private var activeVisionRequests: [UUID: VNRecognizeTextRequest] = [:]
public init( public init(
recognitionLevel: VNRequestTextRecognitionLevel = .accurate, recognitionLevel: VNRequestTextRecognitionLevel = .accurate,
@ -32,10 +37,12 @@ public final class RDPDFReaderImageTextRecognizer {
} }
/// 0...1 /// 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 { guard let cgImage = Self.makeCGImage(from: image) else {
deliver([], to: completion) deliver([], to: completion)
return return requestID
} }
let configuration = Configuration( let configuration = Configuration(
@ -45,22 +52,50 @@ public final class RDPDFReaderImageTextRecognizer {
) )
let orientation = CGImagePropertyOrientation(orientation: image.imageOrientation) 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 request = VNRecognizeTextRequest { request, _ in
let observations = request.results as? [VNRecognizedTextObservation] ?? [] let observations = request.results as? [VNRecognizedTextObservation] ?? []
let runs = Self.makeTextRuns(from: observations) let runs = Self.makeTextRuns(from: observations)
guard self.removeRequest(requestID) else { return }
self.deliver(runs, to: completion) self.deliver(runs, to: completion)
} }
request.recognitionLevel = configuration.recognitionLevel request.recognitionLevel = configuration.recognitionLevel
request.recognitionLanguages = configuration.recognitionLanguages request.recognitionLanguages = configuration.recognitionLanguages
request.usesLanguageCorrection = configuration.usesLanguageCorrection request.usesLanguageCorrection = configuration.usesLanguageCorrection
guard self.registerActiveVisionRequest(request, for: requestID) else { return }
var performFailed = false
do { do {
try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request]) try VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
} catch { } catch {
performFailed = true
}
self.unregisterActiveVisionRequest(requestID)
if performFailed {
guard self.removeRequest(requestID) else { return }
self.deliver([], to: completion) 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 使线 /// Swift Concurrency 使线
@ -171,6 +206,30 @@ public final class RDPDFReaderImageTextRecognizer {
completion(runs) 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 { 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
}
}

View File

@ -94,6 +94,7 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
public func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) } public func readerSetInternalGesturesEnabled(_ enabled: Bool) { zoomView.setInternalGesturesEnabled(enabled) }
public func readerClearTextSelection() { textLayer.clearSelection() } 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 readerBeginExternalPinch(at point: CGPoint) { zoomView.beginExternalPinch(at: convert(point, to: zoomView)) }
public func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) { public func readerUpdateExternalPinch(scale: CGFloat, at point: CGPoint) {
zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView)) zoomView.updateExternalPinch(scale: scale, at: convert(point, to: zoomView))
@ -135,6 +136,8 @@ public final class RDPDFReaderPageView: UIView, RDPDFReaderPageInteractable, UIG
) { ) {
textLayer.pageIndex = pageIndex textLayer.pageIndex = pageIndex
textLayer.textSource = textSource textLayer.textSource = textSource
// EPUB
textLayer.allowsRegionSelection = textSource == .region
textLayer.textRuns = textRuns textLayer.textRuns = textRuns
textLayer.annotations = annotations textLayer.annotations = annotations
updateAccessibilityViewport() updateAccessibilityViewport()

View File

@ -1,8 +1,8 @@
import UIKit import UIKit
import SnapKit import SnapKit
/// PDF 宿OCR /// PDF 宿 PDF SDK 使
/// SDK SDK PDFKit /// PDFKit OCR
public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate { public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataSource, RDPDFReaderDelegate, RDPDFReaderPageViewDelegate {
public struct Configuration { public struct Configuration {
@ -13,9 +13,22 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public var initialThemeIdentifier: Int = 0 public var initialThemeIdentifier: Int = 0
public var enablesOCR = true public var enablesOCR = true
public var recognitionLanguages: [String] = [] 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 使 /// OCR 使
public var missingTextSource: RDPDFReaderAnnotationSource = .region 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 var pageImageTransform: ((UIImage, RDPDFReaderThemeOption) -> UIImage)?
public init() {} public init() {}
@ -29,14 +42,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private let readerView = RDPDFReaderView() private let readerView = RDPDFReaderView()
private let recognizer: RDPDFReaderImageTextRecognizer private let recognizer: RDPDFReaderImageTextRecognizer
private let ocrDiskCache: RDPDFReaderTextRunDiskCache?
private var book: RDPDFReaderBookDescriptor private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:] private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:] private var ocrRuns: [Int: [RDPDFReaderTextRun]] = [:]
private var recognizingPages = Set<Int>() private var recognizingPages = Set<Int>()
///
private var ocrRequestTokens: [Int: UUID] = [:]
private var bookmarks = Set<Int>() private var bookmarks = Set<Int>()
private weak var topToolbar: RDPDFReaderKitTopToolView? private weak var topToolbar: RDPDFReaderKitTopToolView?
private weak var bottomToolbar: RDPDFReaderKitBottomToolView? private weak var bottomToolbar: RDPDFReaderKitBottomToolView?
private var navigationLoadingIndicator: UIActivityIndicatorView?
private let drawingToolbar = RDPDFReaderDrawingToolbar() private let drawingToolbar = RDPDFReaderDrawingToolbar()
private var isDrawingMode = false private var isDrawingMode = false
/// `nil` /// `nil`
@ -66,10 +83,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
currentTheme = configuration.themes.first { $0.identifier == configuration.initialThemeIdentifier } ?? configuration.themes[0] currentTheme = configuration.themes.first { $0.identifier == configuration.initialThemeIdentifier } ?? configuration.themes[0]
preferredDisplayType = configuration.displayType preferredDisplayType = configuration.displayType
recognizer = RDPDFReaderImageTextRecognizer(recognitionLanguages: configuration.recognitionLanguages) 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) super.init(nibName: nil, bundle: nil)
title = book.title 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 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
public override func viewDidLoad() { public override func viewDidLoad() {
@ -100,6 +152,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
bookmarks = Set(persistence?.loadBookmarks(for: book.identifier).map(\.pageIndex) ?? []) 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) { public func switchDisplayType(_ type: RDPDFReaderView.DisplayType) {
preferredDisplayType = type preferredDisplayType = type
applyDisplayTypeForCurrentInterface() applyDisplayTypeForCurrentInterface()
@ -170,6 +234,21 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageNum(readerView: RDPDFReaderView, pageNum: Int) { public func pageNum(readerView: RDPDFReaderView, pageNum: Int) {
guard pageNum >= 0 else { return } 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)" title = "PDF · \(pageNum + 1) / \(book.totalPages)"
topToolbar?.setTitle(title ?? book.title) topToolbar?.setTitle(title ?? book.title)
topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum)) topToolbar?.setBookmarkSelected(bookmarks.contains(pageNum))
@ -177,6 +256,20 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
delegate?.pdfReaderViewController(self, didChangePage: pageNum) 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) { private func configure(_ page: RDPDFReaderPageView, at index: Int) {
let descriptor = pageDescriptors[index] let descriptor = pageDescriptors[index]
page.image = descriptor.flatMap { renderedImage($0.image) } page.image = descriptor.flatMap { renderedImage($0.image) }
@ -203,11 +296,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
} }
guard descriptor == nil else { startOCRIfNeeded(index); return } guard descriptor == nil else { startOCRIfNeeded(index); return }
pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in pageProvider.readerPage(at: index) { [weak self, weak page] descriptor in
guard let self, descriptor.index == index else { return } DispatchQueue.main.async { [weak self, weak page] in
self.pageDescriptors[index] = descriptor guard let self, descriptor.index == index else { return }
// cell self.pageDescriptors[index] = descriptor
self.readerView.invalidateWidthFitLayoutIfNeeded() // cell
if let page { self.configure(page, at: index) } self.readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
if let page { self.configure(page, at: index) }
}
} }
} }
@ -220,12 +315,45 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil, guard configuration.enablesOCR, pageDescriptors[index]?.textRuns == nil, ocrRuns[index] == nil,
!recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return } !recognizingPages.contains(index), let image = pageDescriptors[index]?.image else { return }
recognizingPages.insert(index) recognizingPages.insert(index)
recognizer.recognizeTextRuns(in: image) { [weak self] runs in let token = UUID()
guard let self else { return } ocrRequestTokens[index] = token
self.recognizingPages.remove(index) ocrDiskCache?.load(pageIndex: index) { [weak self] cachedRuns in
self.ocrRuns[index] = runs guard let self, self.ocrRequestTokens[index] == token else { return }
self.refreshVisiblePage(index) 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) { private func refreshVisiblePage(_ index: Int) {
@ -233,6 +361,18 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
configure(page, at: index) 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] { private func annotations(for index: Int) -> [RDPDFReaderAnnotation] {
do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] } do { return try annotationPersistence?.loadAnnotations().filter { $0.pageIndex == index } ?? [] }
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error); return [] }
@ -248,12 +388,52 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
} }
private func showNavigation() { 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 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) 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() { private func showSettings() {
let panel = RDPDFReaderSettingsPanelViewController( let panel = RDPDFReaderSettingsPanelViewController(
displayType: readerView.currentDisplayType, displayType: readerView.currentDisplayType,
@ -319,6 +499,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
/// collectionView /// collectionView
private func updateDrawingInteractionState() { private func updateDrawingInteractionState() {
readerView.setPagingEnabled(!isDrawingMode || currentDrawingTool == nil) readerView.setPagingEnabled(!isDrawingMode || currentDrawingTool == nil)
readerView.setDrawingToolActive(isDrawingMode && currentDrawingTool != nil)
configureVisibleDrawingPages() configureVisibleDrawingPages()
} }
@ -438,7 +619,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
} }
public func pageView(_ pageView: RDPDFReaderPageView, didChangeSelection selection: RDPDFReaderImageTextSelection?) {} 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, 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, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) } public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
@ -475,10 +658,13 @@ public protocol RDPDFReaderViewControllerDelegate: AnyObject {
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController)
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int)
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error)
/// 宿
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String)
} }
public extension RDPDFReaderViewControllerDelegate { public extension RDPDFReaderViewControllerDelegate {
func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {} func pdfReaderViewControllerDidRequestClose(_ controller: RDPDFReaderViewController) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {} func pdfReaderViewController(_ controller: RDPDFReaderViewController, didChangePage pageIndex: Int) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {} func pdfReaderViewController(_ controller: RDPDFReaderViewController, didFailAnnotationPersistence error: Error) {}
func pdfReaderViewController(_ controller: RDPDFReaderViewController, didCopyText text: String) {}
} }