Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Page thumbnails in PDF reader sidebar #815

Merged
merged 8 commits into from
Dec 14, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions Zotero.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -77,8 +77,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/PSPDFKit/PSPDFKit-SP",
"state" : {
"revision" : "324c9a623e879e7a1ff8aa8e1968739619ae538d",
"version" : "13.0.1"
"revision" : "fcff39b2b7741662286dc4323ea255a0ea53fcd3",
"version" : "13.1.0"
}
},
{
66 changes: 40 additions & 26 deletions Zotero/Controllers/AnnotationPreviewController.swift
Original file line number Diff line number Diff line change
@@ -38,7 +38,7 @@ final class AnnotationPreviewController: NSObject {
let observable: PublishSubject<AnnotationPreviewUpdate>
private let previewSize: CGSize
private let queue: DispatchQueue
private let fileStorage: FileStorage
private unowned let fileStorage: FileStorage

private var subscribers: [SubscriberKey: (SingleEvent<UIImage>) -> Void]

@@ -76,7 +76,17 @@ extension AnnotationPreviewController {
self.subscribers[subscriberKey] = subscriber
}

self.enqueue(key: key, parentKey: parentKey, libraryId: libraryId, document: document, pageIndex: page, rect: rect, imageSize: imageSize, imageScale: imageScale, type: .temporary(subscriberKey: subscriberKey))
self.enqueue(
key: key,
parentKey: parentKey,
libraryId: libraryId,
document: document,
pageIndex: page,
rect: rect,
imageSize: imageSize,
imageScale: imageScale,
type: .temporary(subscriberKey: subscriberKey)
)

return Disposables.create()
}
@@ -92,13 +102,20 @@ extension AnnotationPreviewController {

// Cache and report original color
let rect = annotation.previewBoundingBox
self.enqueue(key: annotation.previewId, parentKey: parentKey, libraryId: libraryId, document: document, pageIndex: annotation.pageIndex,
rect: rect, imageSize: previewSize, imageScale: 0.0, includeAnnotation: (annotation is PSPDFKit.InkAnnotation), invertColors: false, isDark: isDark, type: .cachedAndReported)
// // If in dark mode, only cache light mode version, which is required for backend upload
// if isDark {
// self.enqueue(key: key, parentKey: parentKey, document: document, pageIndex: annotation.pageIndex, rect: rect,
// invertColors: true, imageSize: previewSize, imageScale: 0.0, isDark: !isDark, type: .cachedOnly)
// }
self.enqueue(
key: annotation.previewId,
parentKey: parentKey,
libraryId: libraryId,
document: document,
pageIndex: annotation.pageIndex,
rect: rect,
imageSize: previewSize,
imageScale: 0.0,
includeAnnotation: (annotation is PSPDFKit.InkAnnotation),
invertColors: false,
isDark: isDark,
type: .cachedAndReported
)
}

/// Deletes cached preview for given annotation.
@@ -170,30 +187,27 @@ extension AnnotationPreviewController {
/// - parameter isDark: `true` if rendered image is in dark mode, `false` otherwise.
/// - parameter type: Type of preview image. If `temporary`, requested image is temporary and is returned as `Single<UIImage>`. Otherwise image is
/// cached locally and reported through `PublishSubject`.
private func enqueue(key: String, parentKey: String, libraryId: LibraryIdentifier, document: Document, pageIndex: PageIndex, rect: CGRect, imageSize: CGSize, imageScale: CGFloat, includeAnnotation: Bool = false, invertColors: Bool = false, isDark: Bool = false, type: PreviewType) {
/*
Workaround for PSPDFKit issue.

The way these render options work is that they are applied on top of the original document page colors.

So if the appearance mode of a PDFViewController is set to night mode and enabling invertRenderColor at that point for a render request of a document displayed in that PDFViewController
will not result into reverting the rendering back to light mode. You will have to not enable invertRenderColor option when
PDFViewController.appearanceModeManager.appearanceMode is set to night.

However, even while setting invertRenderColor to false with the appearance mode set to night results in the rendering to be inverted. This should not be the case. So create a dummy document
just for rendering and invert only when inverting from light to dark mode
*/
// let newDoc = Document(url: document.fileURL!)

private func enqueue(
key: String,
parentKey: String,
libraryId: LibraryIdentifier,
document: Document,
pageIndex: PageIndex,
rect: CGRect,
imageSize: CGSize,
imageScale: CGFloat,
includeAnnotation: Bool = false,
invertColors: Bool = false,
isDark: Bool = false,
type: PreviewType
) {
var skipAnnotations = document.annotations(at: pageIndex)
if includeAnnotation {
skipAnnotations = skipAnnotations.filter({ $0.previewId != key })
}

let options = RenderOptions()
options.skipAnnotationArray = skipAnnotations
// Color inversion disabled because of PSPDFKit rendering issues. It's not needed now, but just in case this is needed later let's keep it here.
// options.invertRenderColor = !invertColors && isDark

let request = MutableRenderRequest(document: document)
request.pageIndex = pageIndex
2 changes: 1 addition & 1 deletion Zotero/Controllers/Architecture/ViewModel.swift
Original file line number Diff line number Diff line change
@@ -97,7 +97,7 @@ extension BackgroundDbProcessingActionHandler {
}

final class ViewModel<Handler: ViewModelActionHandler>: ObservableObject {
private let handler: Handler
let handler: Handler
private let disposeBag: DisposeBag

let objectWillChange: ObservableObjectPublisher
6 changes: 6 additions & 0 deletions Zotero/Controllers/AttachmentFileCleanupController.swift
Original file line number Diff line number Diff line change
@@ -132,6 +132,8 @@ final class AttachmentFileCleanupController {
// Annotations are not guaranteed to exist and they can be removed even if the parent PDF was not deleted due to upload state.
// These are generated on device, so they'll just be recreated.
try? self.fileStorage.remove(Files.annotationPreviews)
// Remove page thumbnails
try? self.fileStorage.remove(Files.pageThumbnails)
// When removing all local files clear cache as well.
try? self.fileStorage.remove(Files.cache)

@@ -169,6 +171,8 @@ final class AttachmentFileCleanupController {
// Annotations are not guaranteed to exist and they can be removed even if the parent PDF was not deleted due to upload state.
// These are generated on device, so they'll just be recreated.
try? self.fileStorage.remove(Files.annotationPreviews(for: libraryId))
// Cleanup page thumbnails
try? self.fileStorage.remove(Files.pageThumbnails(for: libraryId))

if let keys = deletedIndividually[libraryId], !keys.isEmpty {
return .allForItems(keys, libraryId)
@@ -319,5 +323,7 @@ final class AttachmentFileCleanupController {
try self.fileStorage.remove(Files.attachmentDirectory(in: libraryId, key: key))
// Annotations are not guaranteed to exist.
try? self.fileStorage.remove(Files.annotationPreviews(for: key, libraryId: libraryId))
// Cleanup page thumbnails
try? self.fileStorage.remove(Files.pageThumbnails(for: key, libraryId: libraryId))
}
}
4 changes: 4 additions & 0 deletions Zotero/Controllers/Controllers.swift
Original file line number Diff line number Diff line change
@@ -26,6 +26,7 @@ final class Controllers {
let bundledDataStorage: DbStorage
let translatorsAndStylesController: TranslatorsAndStylesController
let annotationPreviewController: AnnotationPreviewController
let pdfThumbnailController: PDFThumbnailController
let urlDetector: UrlDetector
let dateParser: DateParser
let htmlAttributedStringConverter: HtmlAttributedStringConverter
@@ -92,6 +93,7 @@ final class Controllers {
self.debugLogging = debugLogging
self.translatorsAndStylesController = translatorsAndStylesController
self.annotationPreviewController = AnnotationPreviewController(previewSize: previewSize, fileStorage: fileStorage)
self.pdfThumbnailController = PDFThumbnailController(fileStorage: fileStorage)
self.urlDetector = urlDetector
self.dateParser = DateParser()
self.htmlAttributedStringConverter = HtmlAttributedStringConverter()
@@ -280,6 +282,8 @@ final class Controllers {
try? self.fileStorage.remove(Files.jsonCache)
// Remove annotation preview cache
try? self.fileStorage.remove(Files.annotationPreviews)
// Remove attachment page thumbnails
try? self.fileStorage.remove(Files.pageThumbnails)
// Remove interrupted upload files
try? self.fileStorage.remove(Files.uploads)
// Remove downloaded files
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// PdfDocumentExporter.swift
// PDFDocumentExporter.swift
// Zotero
//
// Created by Michal Rentka on 25.01.2021.
@@ -10,7 +10,7 @@ import UIKit

import PSPDFKit

struct PdfDocumentExporter {
struct PDFDocumentExporter {
enum Error: Swift.Error {
case filenameMissing
case fileError(Swift.Error)
154 changes: 154 additions & 0 deletions Zotero/Controllers/PDFThumbnailController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//
// PDFThumbnailController.swift
// Zotero
//
// Created by Michal Rentka on 30.11.2023.
// Copyright © 2023 Corporation for Digital Scholarship. All rights reserved.
//

import Foundation

import CocoaLumberjackSwift
import PSPDFKit
import RxSwift

final class PDFThumbnailController: NSObject {
enum Error: Swift.Error {
case imageNotAvailable
}

struct SubscriberKey: Hashable {
let key: String
let libraryId: LibraryIdentifier
let page: UInt
let size: CGSize
let isDark: Bool
}

private let queue: DispatchQueue
private unowned let fileStorage: FileStorage

private var subscribers: [SubscriberKey: (SingleEvent<UIImage>) -> Void]

init(fileStorage: FileStorage) {
self.fileStorage = fileStorage
self.subscribers = [:]
self.queue = DispatchQueue(label: "org.zotero.PdfThumbnailController.queue", qos: .userInitiated)
super.init()
}
}

// MARK: - PSPDFKit

extension PDFThumbnailController {
/// Start rendering process of multiple thumbnails per document.
/// - parameter pages: Page indices which should be rendered.
/// - parameter
func cache(pages: [UInt], key: String, libraryId: LibraryIdentifier, document: Document, imageSize: CGSize, isDark: Bool) -> Observable<()> {
let observables = pages.map({
cache(page: $0, key: key, libraryId: libraryId, document: document, imageSize: imageSize, isDark: isDark).flatMap({ _ in return Single.just(()) }).asObservable()
})
return Observable.merge(observables)
}

func cache(page: UInt, key: String, libraryId: LibraryIdentifier, document: Document, imageSize: CGSize, isDark: Bool) -> Single<UIImage> {
return Single.create { [weak self] subscriber -> Disposable in
guard let self else { return Disposables.create() }
let subscriberKey = SubscriberKey(key: key, libraryId: libraryId, page: page, size: imageSize, isDark: isDark)
self.queue.async(flags: .barrier) { [weak self] in
self?.subscribers[subscriberKey] = subscriber
}
self.enqueue(subscriberKey: subscriberKey, document: document, imageSize: imageSize)
return Disposables.create()
}
}

/// Deletes cached thumbnails for given PDF document.
/// - parameter key: Attachment item key.
/// - parameter libraryId: Library identifier of item.
func deleteAll(forKey key: String, libraryId: LibraryIdentifier) {
self.queue.async(flags: .barrier) { [weak self] in
try? self?.fileStorage.remove(Files.pageThumbnails(for: key, libraryId: libraryId))
}
}

/// Checks whether thumbnail is available for given page in document.
/// - parameter page: Page index..
/// - parameter key: Key of PDF item.
/// - parameter libraryId: Library identifier of item.
/// - parameter isDark: `true` if dark mode is on, `false` otherwise.
/// - returns: `true` if thumbnail is available, `false` otherwise.
func hasThumbnail(page: UInt, key: String, libraryId: LibraryIdentifier, isDark: Bool) -> Bool {
return fileStorage.has(Files.pageThumbnail(pageIndex: page, key: key, libraryId: libraryId, isDark: isDark))
}

/// Loads thumbnail from cached file
/// - parameter page: Page index.
/// - parameter key: Key of PDF item.
/// - parameter libraryId: Library identifier of item.
/// - parameter isDark: `true` if dark mode is on, `false` otherwise.
/// - returns: UIImage of given page thumbnail.
func thumbnail(page: UInt, key: String, libraryId: LibraryIdentifier, isDark: Bool) -> UIImage? {
do {
let data = try fileStorage.read(Files.pageThumbnail(pageIndex: page, key: key, libraryId: libraryId, isDark: isDark))
return try UIImage(imageData: data)
} catch let error {
DDLogError("PdfThumbnailController: can't load thumbnail - \(error)")
return nil
}
}

/// Creates and enqueues a render request for PSPDFKit rendering engine.
/// - parameter subscriberKey: Subscriber key identifying this request.
/// - parameter document: Document to render.
/// - parameter imageSize: Size of rendered image.
private func enqueue(subscriberKey: SubscriberKey, document: Document, imageSize: CGSize) {
let request = MutableRenderRequest(document: document)
request.pageIndex = subscriberKey.page
request.imageSize = imageSize
request.options = RenderOptions()

do {
let task = try RenderTask(request: request)
task.priority = .userInitiated
task.completionHandler = { [weak self] image, error in
let result: Result<UIImage, Swift.Error> = image.flatMap({ .success($0) }) ?? .failure(error ?? Error.imageNotAvailable)
self?.queue.async(flags: .barrier) {
self?.completeRequest(with: result, subscriberKey: subscriberKey)
}
}
PSPDFKit.SDK.shared.renderManager.renderQueue.schedule(task)
} catch let error {
DDLogError("PdfThumbnailController: can't create task - \(error)")
}
}

private func completeRequest(with result: Result<UIImage, Swift.Error>, subscriberKey: SubscriberKey) {
switch result {
case .success(let image):
perform(event: .success(image), subscriberKey: subscriberKey)
cache(image: image, page: subscriberKey.page, key: subscriberKey.key, libraryId: subscriberKey.libraryId, isDark: subscriberKey.isDark)

case .failure(let error):
DDLogError("PdfThumbnailController: could not generate image - \(error)")
perform(event: .failure(error), subscriberKey: subscriberKey)
}

func perform(event: SingleEvent<UIImage>, subscriberKey: SubscriberKey) {
self.subscribers[subscriberKey]?(event)
self.subscribers[subscriberKey] = nil
}

func cache(image: UIImage, page: UInt, key: String, libraryId: LibraryIdentifier, isDark: Bool) {
guard let data = image.pngData() else {
DDLogError("PdfThumbnailController: can't create data from image")
return
}
do {
try self.fileStorage.write(data, to: Files.pageThumbnail(pageIndex: page, key: key, libraryId: libraryId, isDark: isDark), options: .atomicWrite)
} catch let error {
DDLogError("PdfThumbnailController: can't store preview - \(error)")
}
}
}
}
2 changes: 1 addition & 1 deletion Zotero/Models/DeletableObject.swift
Original file line number Diff line number Diff line change
@@ -114,7 +114,7 @@ extension RItem: Deletable {
NotificationCenter.default.post(name: .attachmentDeleted, object: Files.attachmentDirectory(in: libraryId, key: self.key))

if contentType == "application/pdf" {
// This is a PDF file, remove all annotations.
// This is a PDF file, remove all annotations and thumbnails.
NotificationCenter.default.post(name: .attachmentDeleted, object: Files.annotationPreviews(for: self.key, libraryId: libraryId))
}
}
24 changes: 22 additions & 2 deletions Zotero/Models/Files.swift
Original file line number Diff line number Diff line change
@@ -122,11 +122,31 @@ struct Files {
return FileData(rootPath: Files.cachesRootPath, relativeComponents: ["Zotero", "sharing", key], name: name, ext: ext)
}

static func pageThumbnail(pageIndex: UInt, key: String, libraryId: LibraryIdentifier, isDark: Bool) -> File {
return FileData(rootPath: Files.appGroupPath, relativeComponents: ["thumbnails", libraryId.folderName, key], name: "\(pageIndex)" + (isDark ? "_dark" : ""), contentType: "png")
}

static func pageThumbnails(for key: String, libraryId: LibraryIdentifier) -> File {
return FileData.directory(rootPath: Files.appGroupPath, relativeComponents: ["thumbnails", libraryId.folderName, key])
}

static func pageThumbnails(for libraryId: LibraryIdentifier) -> File {
return FileData.directory(rootPath: Files.appGroupPath, relativeComponents: ["thumbnails", libraryId.folderName])
}

static var pageThumbnails: File {
return FileData.directory(rootPath: Files.appGroupPath, relativeComponents: ["thumbnails"])
}

// MARK: - Annotations

static func annotationPreview(annotationKey: String, pdfKey: String, libraryId: LibraryIdentifier, isDark: Bool) -> File {
return FileData(rootPath: Files.appGroupPath, relativeComponents: ["annotations", libraryId.folderName, pdfKey],
name: annotationKey + (isDark ? "_dark" : ""), ext: "png")
return FileData(
rootPath: Files.appGroupPath,
relativeComponents: ["annotations", libraryId.folderName, pdfKey],
name: annotationKey + (isDark ? "_dark" : ""),
ext: "png"
)
}

static func annotationPreviews(for pdfKey: String, libraryId: LibraryIdentifier) -> File {
2 changes: 1 addition & 1 deletion Zotero/Scenes/Detail/PDF/Models/PDFExportState.swift
Original file line number Diff line number Diff line change
@@ -11,5 +11,5 @@ import Foundation
enum PDFExportState {
case preparing
case exported(File)
case failed(PdfDocumentExporter.Error)
case failed(PDFDocumentExporter.Error)
}
Loading