Clean up repository structure and release docs
This commit is contained in:
84
sources/core/AnnotationColorPreference.swift
Normal file
84
sources/core/AnnotationColorPreference.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
public enum AnnotationColorPreference {
|
||||
public static func color(
|
||||
from storageValue: String?,
|
||||
fallback: NSColor,
|
||||
minimumAlpha: CGFloat = 0
|
||||
) -> NSColor {
|
||||
guard let storageValue,
|
||||
let color = color(from: storageValue)
|
||||
else {
|
||||
return normalized(fallback, fallback: fallback, minimumAlpha: minimumAlpha)
|
||||
}
|
||||
|
||||
return normalized(color, fallback: fallback, minimumAlpha: minimumAlpha)
|
||||
}
|
||||
|
||||
public static func storageString(for color: NSColor, fallback: String = "#FFD11F85") -> String {
|
||||
guard let rgb = color.usingColorSpace(.deviceRGB) else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
return String(
|
||||
format: "#%02X%02X%02X%02X",
|
||||
byte(red),
|
||||
byte(green),
|
||||
byte(blue),
|
||||
byte(alpha)
|
||||
)
|
||||
}
|
||||
|
||||
private static func color(from storageValue: String) -> NSColor? {
|
||||
var raw = storageValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw.hasPrefix("#") {
|
||||
raw.removeFirst()
|
||||
}
|
||||
|
||||
guard raw.count == 8,
|
||||
let value = UInt32(raw, radix: 16)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let red = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let blue = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let alpha = CGFloat(value & 0xFF) / 255
|
||||
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
|
||||
private static func normalized(
|
||||
_ color: NSColor,
|
||||
fallback: NSColor,
|
||||
minimumAlpha: CGFloat
|
||||
) -> NSColor {
|
||||
guard let rgb = color.usingColorSpace(.deviceRGB) else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
|
||||
return NSColor(
|
||||
deviceRed: red,
|
||||
green: green,
|
||||
blue: blue,
|
||||
alpha: max(alpha, minimumAlpha)
|
||||
)
|
||||
}
|
||||
|
||||
private static func byte(_ value: CGFloat) -> Int {
|
||||
max(0, min(255, Int((value * 255).rounded())))
|
||||
}
|
||||
}
|
||||
560
sources/core/AnnotationFactory.swift
Normal file
560
sources/core/AnnotationFactory.swift
Normal file
@@ -0,0 +1,560 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationPalette {
|
||||
public static let comment = NSColor(
|
||||
calibratedRed: 0.98,
|
||||
green: 0.64,
|
||||
blue: 0.16,
|
||||
alpha: 0.30
|
||||
)
|
||||
public static let highlight = NSColor(
|
||||
calibratedRed: 1.0,
|
||||
green: 0.78,
|
||||
blue: 0.0,
|
||||
alpha: 0.52
|
||||
)
|
||||
public static let underline = NSColor(
|
||||
calibratedRed: 0.48,
|
||||
green: 0.53,
|
||||
blue: 0.62,
|
||||
alpha: 0.56
|
||||
)
|
||||
public static let note = NSColor(
|
||||
calibratedRed: 0.64,
|
||||
green: 0.59,
|
||||
blue: 0.49,
|
||||
alpha: 0.9
|
||||
)
|
||||
public static let reply = NSColor(
|
||||
calibratedRed: 0.52,
|
||||
green: 0.58,
|
||||
blue: 0.60,
|
||||
alpha: 0.88
|
||||
)
|
||||
public static let freeTextFill = NSColor(
|
||||
calibratedRed: 0.91,
|
||||
green: 0.86,
|
||||
blue: 0.75,
|
||||
alpha: 0.32
|
||||
)
|
||||
public static let freeTextInk = NSColor(
|
||||
calibratedWhite: 0.22,
|
||||
alpha: 1
|
||||
)
|
||||
}
|
||||
|
||||
public enum MarkupAnnotationStyle {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
|
||||
var subtype: PDFAnnotationSubtype {
|
||||
switch self {
|
||||
case .comment: return .highlight
|
||||
case .highlight: return .highlight
|
||||
case .underline: return .underline
|
||||
}
|
||||
}
|
||||
|
||||
func color(
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment
|
||||
) -> NSColor {
|
||||
switch self {
|
||||
case .comment: return commentColor
|
||||
case .highlight: return highlightColor
|
||||
case .underline: return AcademicAnnotationPalette.underline
|
||||
}
|
||||
}
|
||||
|
||||
var markupType: PDFMarkupType {
|
||||
switch self {
|
||||
case .comment: return .highlight
|
||||
case .highlight: return .highlight
|
||||
case .underline: return .underline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnotationInsertion {
|
||||
public let page: PDFPage
|
||||
public let annotation: PDFAnnotation
|
||||
public let popup: PDFAnnotation?
|
||||
|
||||
public init(page: PDFPage, annotation: PDFAnnotation, popup: PDFAnnotation?) {
|
||||
self.page = page
|
||||
self.annotation = annotation
|
||||
self.popup = popup
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationFactory {
|
||||
public static let defaultAuthor = NSFullUserName().isEmpty ? NSUserName() : NSFullUserName()
|
||||
|
||||
public static func markupInsertions(
|
||||
from selection: PDFSelection,
|
||||
style: MarkupAnnotationStyle,
|
||||
comment: String,
|
||||
author: String,
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment,
|
||||
date: Date = Date()
|
||||
) -> [AnnotationInsertion] {
|
||||
let lineSelections = selection.selectionsByLine()
|
||||
var groups: [(page: PDFPage, rects: [CGRect], text: [String])] = []
|
||||
|
||||
for lineSelection in lineSelections {
|
||||
let lineText = lineSelection.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
for page in lineSelection.pages {
|
||||
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
|
||||
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
|
||||
|
||||
if let index = groups.firstIndex(where: { $0.page === page }) {
|
||||
groups[index].rects.append(rect)
|
||||
if !lineText.isEmpty {
|
||||
groups[index].text.append(lineText)
|
||||
}
|
||||
} else {
|
||||
groups.append((page: page, rects: [rect], text: lineText.isEmpty ? [] : [lineText]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return groups.compactMap { group in
|
||||
guard let firstRect = group.rects.first else { return nil }
|
||||
let unionRect = group.rects.dropFirst().reduce(firstRect) { partial, rect in
|
||||
partial.union(rect)
|
||||
}
|
||||
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
||||
annotation.markupType = style.markupType
|
||||
annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
|
||||
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
||||
quadPoints(for: rect, relativeTo: unionRect)
|
||||
}
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
if style == .highlight {
|
||||
let highlightText = group.text.joined(separator: " ")
|
||||
if !highlightText.isEmpty {
|
||||
_ = annotation.setValue(highlightText, forAnnotationKey: AnnotationKeys.appHighlightText)
|
||||
}
|
||||
}
|
||||
if style == .comment {
|
||||
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
|
||||
}
|
||||
let popup = makePopupIfNeeded(for: annotation, on: group.page, open: false)
|
||||
return AnnotationInsertion(page: group.page, annotation: annotation, popup: popup)
|
||||
}
|
||||
}
|
||||
|
||||
public static func noteInsertion(
|
||||
on page: PDFPage,
|
||||
near point: CGPoint,
|
||||
comment: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(x: point.x, y: point.y, width: 28, height: 28),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 28, height: 28)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||
annotation.color = AcademicAnnotationPalette.note
|
||||
annotation.iconType = .note
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
let popup = makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: popup)
|
||||
}
|
||||
|
||||
public static func freeTextInsertion(
|
||||
on page: PDFPage,
|
||||
near point: CGPoint,
|
||||
text: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(x: point.x - 120, y: point.y - 40, width: 240, height: 80),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 240, height: 80)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .freeText, withProperties: nil)
|
||||
annotation.font = NSFont.systemFont(ofSize: 13)
|
||||
annotation.fontColor = AcademicAnnotationPalette.freeTextInk
|
||||
annotation.alignment = .left
|
||||
annotation.color = AcademicAnnotationPalette.freeTextFill
|
||||
|
||||
let border = PDFBorder()
|
||||
border.lineWidth = 0.75
|
||||
annotation.border = border
|
||||
|
||||
standardize(annotation, comment: text, author: author, date: date)
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: nil)
|
||||
}
|
||||
|
||||
public static func replyInsertion(
|
||||
to parent: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
comment: String,
|
||||
author: String,
|
||||
parentID: String? = nil,
|
||||
date: Date = Date()
|
||||
) -> AnnotationInsertion {
|
||||
let parentBounds = parent.bounds
|
||||
let targetPoint = CGPoint(
|
||||
x: parentBounds.maxX + 16,
|
||||
y: max(parentBounds.minY, parentBounds.midY - 12)
|
||||
)
|
||||
let bounds = clampedRect(
|
||||
desired: CGRect(origin: targetPoint, size: CGSize(width: 24, height: 24)),
|
||||
on: page,
|
||||
fallbackSize: CGSize(width: 24, height: 24)
|
||||
)
|
||||
let annotation = PDFAnnotation(bounds: bounds, forType: .text, withProperties: nil)
|
||||
annotation.color = AcademicAnnotationPalette.reply
|
||||
annotation.iconType = .comment
|
||||
standardize(annotation, comment: comment, author: author, date: date)
|
||||
let parentIdentifier = parentID
|
||||
?? parent.value(forAnnotationKey: .name) as? String
|
||||
?? UUID().uuidString
|
||||
_ = annotation.setValue(parentIdentifier, forAnnotationKey: AnnotationKeys.inReplyTo)
|
||||
_ = annotation.setValue("R", forAnnotationKey: AnnotationKeys.replyType)
|
||||
annotation.shouldDisplay = false
|
||||
annotation.shouldPrint = false
|
||||
return AnnotationInsertion(page: page, annotation: annotation, popup: nil)
|
||||
}
|
||||
|
||||
public static func updateComment(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
text: String,
|
||||
author: String,
|
||||
date: Date = Date()
|
||||
) -> PDFAnnotation? {
|
||||
AnnotationKeys.setCommentText(text, for: annotation)
|
||||
annotation.contents = text
|
||||
annotation.userName = author
|
||||
annotation.modificationDate = date
|
||||
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.creationDate) == nil {
|
||||
_ = annotation.setValue(
|
||||
AnnotationKeys.pdfDateString(from: date),
|
||||
forAnnotationKey: AnnotationKeys.creationDate
|
||||
)
|
||||
}
|
||||
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let popup = annotation.popup {
|
||||
page.removeAnnotation(popup)
|
||||
annotation.popup = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if AnnotationKeys.isReply(annotation) {
|
||||
hideReplyMarker(annotation, on: page)
|
||||
return nil
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.contents = text
|
||||
popup.userName = author
|
||||
popup.modificationDate = date
|
||||
popup.isOpen = false
|
||||
return nil
|
||||
}
|
||||
|
||||
return makePopupIfNeeded(for: annotation, on: page, open: false)
|
||||
}
|
||||
|
||||
public static func standardize(
|
||||
_ annotation: PDFAnnotation,
|
||||
comment: String,
|
||||
author: String,
|
||||
date: Date
|
||||
) {
|
||||
AnnotationKeys.setCommentText(comment, for: annotation)
|
||||
annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil
|
||||
: comment
|
||||
annotation.userName = author
|
||||
annotation.modificationDate = date
|
||||
annotation.shouldDisplay = true
|
||||
annotation.shouldPrint = true
|
||||
_ = annotation.setValue(UUID().uuidString, forAnnotationKey: .name)
|
||||
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
_ = annotation.setValue(
|
||||
AnnotationKeys.pdfDateString(from: date),
|
||||
forAnnotationKey: AnnotationKeys.creationDate
|
||||
)
|
||||
_ = annotation.setValue("Unmarked", forAnnotationKey: AnnotationKeys.state)
|
||||
_ = annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
|
||||
}
|
||||
|
||||
public static func makePopupIfNeeded(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
open: Bool
|
||||
) -> PDFAnnotation? {
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { return nil }
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else { return nil }
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
guard !contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.contents = contents
|
||||
popup.userName = annotation.userName
|
||||
popup.modificationDate = annotation.modificationDate
|
||||
popup.isOpen = open
|
||||
popup.bounds = popupRect(for: annotation.bounds, on: page)
|
||||
popup.shouldDisplay = true
|
||||
popup.shouldPrint = true
|
||||
return popup.page == nil ? popup : nil
|
||||
}
|
||||
|
||||
let popupBounds = popupRect(for: annotation.bounds, on: page)
|
||||
let popup = PDFAnnotation(bounds: popupBounds, forType: .popup, withProperties: nil)
|
||||
popup.contents = contents
|
||||
popup.userName = annotation.userName
|
||||
popup.modificationDate = annotation.modificationDate
|
||||
popup.isOpen = open
|
||||
popup.shouldDisplay = true
|
||||
popup.shouldPrint = true
|
||||
annotation.popup = popup
|
||||
return popup
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func normalizePopupPlacement(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
guard let popup = annotation.popup else { return false }
|
||||
|
||||
let bounds = popupRect(for: annotation.bounds, on: page)
|
||||
guard popup.bounds != bounds else { return false }
|
||||
popup.bounds = bounds
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func setPopupMarkerVisibility(
|
||||
for annotation: PDFAnnotation,
|
||||
on page: PDFPage,
|
||||
isVisible: Bool
|
||||
) -> Bool {
|
||||
guard let popup = annotation.popup else { return false }
|
||||
|
||||
let oldBounds = popup.bounds
|
||||
let oldShouldDisplay = popup.shouldDisplay
|
||||
let oldShouldPrint = popup.shouldPrint
|
||||
let oldIsOpen = popup.isOpen
|
||||
|
||||
popup.bounds = popupRect(for: annotation.bounds, on: page)
|
||||
popup.shouldDisplay = isVisible
|
||||
popup.shouldPrint = isVisible
|
||||
popup.isOpen = false
|
||||
|
||||
return oldBounds != popup.bounds
|
||||
|| oldShouldDisplay != popup.shouldDisplay
|
||||
|| oldShouldPrint != popup.shouldPrint
|
||||
|| oldIsOpen != popup.isOpen
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
return restoreCommentText(contents, forExportIn: annotation)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func prepareForPreviewCompatibleExport(
|
||||
_ annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
var didChange = restoreCommentText(contents, forExportIn: annotation)
|
||||
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else {
|
||||
return didChange
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
if popup.page != nil {
|
||||
page.removeAnnotation(popup)
|
||||
}
|
||||
annotation.popup = nil
|
||||
didChange = true
|
||||
}
|
||||
|
||||
let linkedPopups = page.annotations.filter { candidate in
|
||||
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
|
||||
return parentAnnotation(for: candidate) === annotation
|
||||
}
|
||||
|
||||
for popup in linkedPopups {
|
||||
page.removeAnnotation(popup)
|
||||
didChange = true
|
||||
}
|
||||
|
||||
if restoreCommentText(contents, forExportIn: annotation) {
|
||||
didChange = true
|
||||
}
|
||||
|
||||
return didChange
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func restoreCommentText(
|
||||
_ contents: String,
|
||||
forExportIn annotation: PDFAnnotation
|
||||
) -> Bool {
|
||||
let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil
|
||||
: contents
|
||||
let oldContents = annotation.contents
|
||||
|
||||
annotation.contents = exportedContents
|
||||
if !contents.isEmpty {
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
}
|
||||
|
||||
return oldContents != annotation.contents
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public static func detachPopupForViewer(
|
||||
from annotation: PDFAnnotation,
|
||||
on page: PDFPage
|
||||
) -> Bool {
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let userName = annotation.userName
|
||||
let modificationDate = annotation.modificationDate
|
||||
let creationDate = annotation.value(forAnnotationKey: AnnotationKeys.creationDate)
|
||||
let textLabel = annotation.value(forAnnotationKey: .textLabel)
|
||||
let date = annotation.value(forAnnotationKey: .date)
|
||||
let shouldSuppressNativeContents = !AnnotationKeys.isReply(annotation)
|
||||
&& !AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|
||||
let oldContents = annotation.contents
|
||||
var didChange = false
|
||||
|
||||
if !contents.isEmpty || annotation.value(forAnnotationKey: AnnotationKeys.appCommentText) == nil {
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
}
|
||||
|
||||
if let popup = annotation.popup {
|
||||
popup.isOpen = false
|
||||
popup.shouldDisplay = false
|
||||
popup.shouldPrint = false
|
||||
if popup.page != nil {
|
||||
page.removeAnnotation(popup)
|
||||
}
|
||||
annotation.popup = nil
|
||||
didChange = true
|
||||
}
|
||||
|
||||
annotation.contents = shouldSuppressNativeContents ? nil : contents
|
||||
annotation.userName = userName
|
||||
annotation.modificationDate = modificationDate
|
||||
if let creationDate {
|
||||
_ = annotation.setValue(creationDate, forAnnotationKey: AnnotationKeys.creationDate)
|
||||
}
|
||||
if let textLabel {
|
||||
_ = annotation.setValue(textLabel, forAnnotationKey: .textLabel)
|
||||
}
|
||||
if let date {
|
||||
_ = annotation.setValue(date, forAnnotationKey: .date)
|
||||
}
|
||||
|
||||
return didChange || oldContents != annotation.contents
|
||||
}
|
||||
|
||||
public static func hideReplyMarker(_ annotation: PDFAnnotation, on page: PDFPage) {
|
||||
guard AnnotationKeys.isReply(annotation) else { return }
|
||||
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let userName = annotation.userName
|
||||
let modificationDate = annotation.modificationDate
|
||||
|
||||
if let popup = annotation.popup {
|
||||
page.removeAnnotation(popup)
|
||||
annotation.popup = nil
|
||||
}
|
||||
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
annotation.bounds = CGRect(
|
||||
x: pageBounds.maxX + 32,
|
||||
y: pageBounds.maxY + 32,
|
||||
width: 24,
|
||||
height: 24
|
||||
)
|
||||
annotation.shouldDisplay = false
|
||||
annotation.shouldPrint = false
|
||||
AnnotationKeys.setCommentText(contents, for: annotation)
|
||||
annotation.contents = contents
|
||||
annotation.userName = userName
|
||||
annotation.modificationDate = modificationDate
|
||||
}
|
||||
|
||||
public static func parentAnnotation(for annotation: PDFAnnotation) -> PDFAnnotation {
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .popup),
|
||||
let parent = annotation.value(forAnnotationKey: .parent) as? PDFAnnotation {
|
||||
return parent
|
||||
}
|
||||
return annotation
|
||||
}
|
||||
|
||||
private static func quadPoints(for rect: CGRect, relativeTo bounds: CGRect) -> [NSValue] {
|
||||
let minX = rect.minX - bounds.minX
|
||||
let maxX = rect.maxX - bounds.minX
|
||||
let minY = rect.minY - bounds.minY
|
||||
let maxY = rect.maxY - bounds.minY
|
||||
|
||||
return [
|
||||
NSValue(point: CGPoint(x: minX, y: maxY)),
|
||||
NSValue(point: CGPoint(x: maxX, y: maxY)),
|
||||
NSValue(point: CGPoint(x: minX, y: minY)),
|
||||
NSValue(point: CGPoint(x: maxX, y: minY))
|
||||
]
|
||||
}
|
||||
|
||||
private static func popupRect(for annotationBounds: CGRect, on page: PDFPage) -> CGRect {
|
||||
let pageBounds = page.bounds(for: .cropBox)
|
||||
let indicatorInset: CGFloat = 28
|
||||
let verticalInset: CGFloat = 12
|
||||
let y = min(
|
||||
max(annotationBounds.maxY - indicatorInset, pageBounds.minY + verticalInset),
|
||||
pageBounds.maxY - indicatorInset - verticalInset
|
||||
)
|
||||
|
||||
return CGRect(
|
||||
x: pageBounds.maxX - indicatorInset,
|
||||
y: y,
|
||||
width: 240,
|
||||
height: 120
|
||||
)
|
||||
}
|
||||
|
||||
private static func clampedRect(
|
||||
desired: CGRect,
|
||||
on page: PDFPage,
|
||||
fallbackSize: CGSize
|
||||
) -> CGRect {
|
||||
let pageBounds = page.bounds(for: .cropBox).insetBy(dx: 12, dy: 12)
|
||||
let width = min(desired.width > 0 ? desired.width : fallbackSize.width, pageBounds.width)
|
||||
let height = min(desired.height > 0 ? desired.height : fallbackSize.height, pageBounds.height)
|
||||
let x = min(max(desired.minX, pageBounds.minX), pageBounds.maxX - width)
|
||||
let y = min(max(desired.minY, pageBounds.minY), pageBounds.maxY - height)
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
46
sources/core/AnnotationHitTesting.swift
Normal file
46
sources/core/AnnotationHitTesting.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AnnotationHitTesting {
|
||||
public static func containsTextMarkupPoint(
|
||||
_ point: CGPoint,
|
||||
in annotation: PDFAnnotation,
|
||||
tolerance: CGFloat = 3
|
||||
) -> Bool {
|
||||
guard AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|
||||
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|
||||
else {
|
||||
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
|
||||
}
|
||||
|
||||
let quadPoints = annotation.quadrilateralPoints ?? []
|
||||
guard !quadPoints.isEmpty else {
|
||||
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
|
||||
}
|
||||
|
||||
var index = 0
|
||||
while index + 3 < quadPoints.count {
|
||||
let points = quadPoints[index..<(index + 4)].map { value in
|
||||
let relativePoint = value.pointValue
|
||||
return CGPoint(
|
||||
x: annotation.bounds.minX + relativePoint.x,
|
||||
y: annotation.bounds.minY + relativePoint.y
|
||||
)
|
||||
}
|
||||
if boundingRect(for: points).insetBy(dx: -tolerance, dy: -tolerance).contains(point) {
|
||||
return true
|
||||
}
|
||||
index += 4
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func boundingRect(for points: [CGPoint]) -> CGRect {
|
||||
guard let first = points.first else { return .null }
|
||||
|
||||
return points.dropFirst().reduce(CGRect(origin: first, size: .zero)) { rect, point in
|
||||
rect.union(CGRect(origin: point, size: .zero))
|
||||
}
|
||||
}
|
||||
}
|
||||
451
sources/core/AnnotationModels.swift
Normal file
451
sources/core/AnnotationModels.swift
Normal file
@@ -0,0 +1,451 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationKind: String, CaseIterable {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
case note
|
||||
case freeText
|
||||
case reply
|
||||
case other
|
||||
|
||||
public init(annotation: PDFAnnotation) {
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
|
||||
self = .comment
|
||||
return
|
||||
}
|
||||
|
||||
if AnnotationKeys.isReply(annotation) {
|
||||
self = .reply
|
||||
return
|
||||
}
|
||||
|
||||
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
|
||||
self = .highlight
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .underline) {
|
||||
self = .underline
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .text) {
|
||||
self = .note
|
||||
} else if AnnotationKeys.annotation(annotation, hasSubtype: .freeText) {
|
||||
self = .freeText
|
||||
} else {
|
||||
self = .other
|
||||
}
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .comment: return "Comment"
|
||||
case .highlight: return "Highlight"
|
||||
case .underline: return "Underline"
|
||||
case .note: return "Note"
|
||||
case .freeText: return "Free Text"
|
||||
case .reply: return "Reply"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
public var symbolName: String {
|
||||
switch self {
|
||||
case .comment: return "text.bubble"
|
||||
case .highlight: return "highlighter"
|
||||
case .underline: return "underline"
|
||||
case .note: return "note.text"
|
||||
case .freeText: return "textformat"
|
||||
case .reply: return "arrowshape.turn.up.left"
|
||||
case .other: return "ellipsis"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AnnotationSnapshot: Identifiable {
|
||||
public let id: String
|
||||
public let pageIndex: Int
|
||||
public let pageLabel: String
|
||||
public let annotationIndex: Int
|
||||
public let kind: AcademicAnnotationKind
|
||||
public let author: String
|
||||
public let createdAt: Date?
|
||||
public let modifiedAt: Date?
|
||||
public let status: String
|
||||
public let contents: String
|
||||
public let highlightText: String
|
||||
public let bounds: CGRect
|
||||
public let annotation: PDFAnnotation
|
||||
public let page: PDFPage
|
||||
public let parentID: String?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
pageIndex: Int,
|
||||
pageLabel: String,
|
||||
annotationIndex: Int,
|
||||
kind: AcademicAnnotationKind,
|
||||
author: String,
|
||||
createdAt: Date?,
|
||||
modifiedAt: Date?,
|
||||
status: String,
|
||||
contents: String,
|
||||
highlightText: String,
|
||||
bounds: CGRect,
|
||||
annotation: PDFAnnotation,
|
||||
page: PDFPage,
|
||||
parentID: String?
|
||||
) {
|
||||
self.id = id
|
||||
self.pageIndex = pageIndex
|
||||
self.pageLabel = pageLabel
|
||||
self.annotationIndex = annotationIndex
|
||||
self.kind = kind
|
||||
self.author = author
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
self.status = status
|
||||
self.contents = contents
|
||||
self.highlightText = highlightText
|
||||
self.bounds = bounds
|
||||
self.annotation = annotation
|
||||
self.page = page
|
||||
self.parentID = parentID
|
||||
}
|
||||
|
||||
public var firstLine: String {
|
||||
let trimmed = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let first = trimmed
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.first
|
||||
.map(String.init)
|
||||
else {
|
||||
return "No comment"
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
public var hasComment: Bool {
|
||||
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var highlightExcerpt: String {
|
||||
let stored = highlightText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
|
||||
let fallback = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return "Highlight on page \(pageLabel)"
|
||||
}
|
||||
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationKeys {
|
||||
public static let inReplyTo = PDFAnnotationKey(rawValue: "IRT")
|
||||
public static let replyType = PDFAnnotationKey(rawValue: "RT")
|
||||
public static let creationDate = PDFAnnotationKey(rawValue: "CreationDate")
|
||||
public static let state = PDFAnnotationKey(rawValue: "State")
|
||||
public static let stateModel = PDFAnnotationKey(rawValue: "StateModel")
|
||||
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
|
||||
public static let appKindComment = "Comment"
|
||||
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
||||
public static let appHighlightText = PDFAnnotationKey(rawValue: "IHatePDFsHighlightText")
|
||||
|
||||
public static func commentText(for annotation: PDFAnnotation) -> String {
|
||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
|
||||
!value.isEmpty {
|
||||
return value
|
||||
}
|
||||
|
||||
if let contents = annotation.contents, !contents.isEmpty {
|
||||
return contents
|
||||
}
|
||||
|
||||
return annotation.popup?.contents ?? ""
|
||||
}
|
||||
|
||||
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
|
||||
_ = annotation.setValue(text, forAnnotationKey: appCommentText)
|
||||
}
|
||||
|
||||
public static func stableID(
|
||||
for annotation: PDFAnnotation,
|
||||
pageIndex: Int,
|
||||
annotationIndex: Int
|
||||
) -> String {
|
||||
if let name = annotation.value(forAnnotationKey: .name) as? String, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
let type = annotation.type ?? "Unknown"
|
||||
let rect = annotation.bounds
|
||||
return [
|
||||
"page-\(pageIndex + 1)",
|
||||
"annotation-\(annotationIndex)",
|
||||
type,
|
||||
String(format: "%.2f-%.2f-%.2f-%.2f", rect.minX, rect.minY, rect.width, rect.height)
|
||||
].joined(separator: "-")
|
||||
}
|
||||
|
||||
public static func parentID(
|
||||
for annotation: PDFAnnotation,
|
||||
document: PDFDocument?
|
||||
) -> String? {
|
||||
if let parentID = annotation.value(forAnnotationKey: inReplyTo) as? String,
|
||||
!parentID.isEmpty {
|
||||
return stableIDForAnnotation(named: parentID, in: document) ?? parentID
|
||||
}
|
||||
|
||||
guard let parent = annotation.value(forAnnotationKey: inReplyTo) as? PDFAnnotation else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let page = parent.page,
|
||||
let document,
|
||||
document.index(for: page) != NSNotFound
|
||||
else {
|
||||
return parent.value(forAnnotationKey: .name) as? String
|
||||
}
|
||||
|
||||
let pageIndex = document.index(for: page)
|
||||
let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||||
return stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
|
||||
private static func stableIDForAnnotation(named name: String, in document: PDFDocument?) -> String? {
|
||||
guard let document else { return nil }
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
for (annotationIndex, candidate) in page.annotations.enumerated() {
|
||||
guard candidate.value(forAnnotationKey: .name) as? String == name else { continue }
|
||||
return stableID(for: candidate, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func isReply(_ annotation: PDFAnnotation) -> Bool {
|
||||
annotation.value(forAnnotationKey: inReplyTo) is PDFAnnotation
|
||||
|| annotation.value(forAnnotationKey: inReplyTo) is String
|
||||
}
|
||||
|
||||
public static func annotation(_ annotation: PDFAnnotation, hasSubtype subtype: PDFAnnotationSubtype) -> Bool {
|
||||
guard let type = annotation.type else { return false }
|
||||
let raw = subtype.rawValue
|
||||
let normalized = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw
|
||||
return type == raw || type == normalized
|
||||
}
|
||||
|
||||
public static func pdfDateString(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "'D:'yyyyMMddHHmmss'Z00''00'''"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
public static func dateValue(for key: PDFAnnotationKey, in annotation: PDFAnnotation) -> Date? {
|
||||
if let date = annotation.value(forAnnotationKey: key) as? Date {
|
||||
return date
|
||||
}
|
||||
|
||||
guard let value = annotation.value(forAnnotationKey: key) as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return parsePDFDate(value)
|
||||
}
|
||||
|
||||
private static func parsePDFDate(_ value: String) -> Date? {
|
||||
let normalized = value
|
||||
.replacingOccurrences(of: "Z00'00'", with: "Z")
|
||||
.replacingOccurrences(of: "Z00\\'00\\'", with: "Z")
|
||||
let formats = [
|
||||
"'D:'yyyyMMddHHmmss'Z'",
|
||||
"'D:'yyyyMMddHHmmss",
|
||||
"yyyy-MM-dd'T'HH:mm:ssZ"
|
||||
]
|
||||
|
||||
for format in formats {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = format
|
||||
if let date = formatter.date(from: normalized) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum AnnotationReader {
|
||||
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
var namedAnnotationIDs: [String: String]?
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
result.append(contentsOf: snapshots(
|
||||
in: document,
|
||||
page: page,
|
||||
pageIndex: pageIndex,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
))
|
||||
}
|
||||
|
||||
return sorted(result)
|
||||
}
|
||||
|
||||
public static func snapshots(in document: PDFDocument, pages: [PDFPage]) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
var seenPageIndexes = Set<Int>()
|
||||
var namedAnnotationIDs: [String: String]?
|
||||
|
||||
for page in pages {
|
||||
let pageIndex = document.index(for: page)
|
||||
guard pageIndex != NSNotFound, seenPageIndexes.insert(pageIndex).inserted else { continue }
|
||||
result.append(contentsOf: snapshots(
|
||||
in: document,
|
||||
page: page,
|
||||
pageIndex: pageIndex,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
))
|
||||
}
|
||||
|
||||
return sorted(result)
|
||||
}
|
||||
|
||||
public static func sorted(_ snapshots: [AnnotationSnapshot]) -> [AnnotationSnapshot] {
|
||||
snapshots.sorted { left, right in
|
||||
if left.pageIndex != right.pageIndex {
|
||||
return left.pageIndex < right.pageIndex
|
||||
}
|
||||
if left.bounds.maxY != right.bounds.maxY {
|
||||
return left.bounds.maxY > right.bounds.maxY
|
||||
}
|
||||
return left.bounds.minX < right.bounds.minX
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshots(
|
||||
in document: PDFDocument,
|
||||
page: PDFPage,
|
||||
pageIndex: Int,
|
||||
namedAnnotationIDs: inout [String: String]?
|
||||
) -> [AnnotationSnapshot] {
|
||||
var result: [AnnotationSnapshot] = []
|
||||
|
||||
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
||||
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
|
||||
|
||||
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let highlightText = annotation.value(forAnnotationKey: AnnotationKeys.appHighlightText) as? String ?? ""
|
||||
guard kind != .other || !contents.isEmpty else { continue }
|
||||
|
||||
let id = AnnotationKeys.stableID(
|
||||
for: annotation,
|
||||
pageIndex: pageIndex,
|
||||
annotationIndex: annotationIndex
|
||||
)
|
||||
let pageLabel = page.label ?? "\(pageIndex + 1)"
|
||||
let author = annotation.userName
|
||||
?? annotation.value(forAnnotationKey: .textLabel) as? String
|
||||
?? "Unknown"
|
||||
let createdAt = AnnotationKeys.dateValue(for: AnnotationKeys.creationDate, in: annotation)
|
||||
?? annotation.modificationDate
|
||||
let status = annotation.value(forAnnotationKey: AnnotationKeys.state) as? String
|
||||
?? "Unmarked"
|
||||
let parentID = parentID(
|
||||
for: annotation,
|
||||
document: document,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
)
|
||||
|
||||
result.append(
|
||||
AnnotationSnapshot(
|
||||
id: id,
|
||||
pageIndex: pageIndex,
|
||||
pageLabel: pageLabel,
|
||||
annotationIndex: annotationIndex,
|
||||
kind: kind,
|
||||
author: author,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: annotation.modificationDate,
|
||||
status: status,
|
||||
contents: contents,
|
||||
highlightText: highlightText,
|
||||
bounds: annotation.bounds,
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
parentID: parentID
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func parentID(
|
||||
for annotation: PDFAnnotation,
|
||||
document: PDFDocument,
|
||||
namedAnnotationIDs: inout [String: String]?
|
||||
) -> String? {
|
||||
if let parentID = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String,
|
||||
!parentID.isEmpty {
|
||||
if namedAnnotationIDs == nil {
|
||||
namedAnnotationIDs = makeNamedAnnotationIDs(in: document)
|
||||
}
|
||||
return namedAnnotationIDs?[parentID]
|
||||
}
|
||||
|
||||
guard let parent = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? PDFAnnotation else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let page = parent.page,
|
||||
document.index(for: page) != NSNotFound
|
||||
else {
|
||||
return parent.value(forAnnotationKey: .name) as? String
|
||||
}
|
||||
|
||||
let pageIndex = document.index(for: page)
|
||||
let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0
|
||||
return AnnotationKeys.stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
|
||||
}
|
||||
|
||||
private static func makeNamedAnnotationIDs(in document: PDFDocument) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
|
||||
for pageIndex in 0..<document.pageCount {
|
||||
guard let page = document.page(at: pageIndex) else { continue }
|
||||
for (annotationIndex, annotation) in page.annotations.enumerated() {
|
||||
guard let name = annotation.value(forAnnotationKey: .name) as? String,
|
||||
!name.isEmpty
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
result[name] = AnnotationKeys.stableID(
|
||||
for: annotation,
|
||||
pageIndex: pageIndex,
|
||||
annotationIndex: annotationIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
61
sources/core/PDFDocumentBookmarks.swift
Normal file
61
sources/core/PDFDocumentBookmarks.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
public struct PDFDocumentBookmark {
|
||||
public var id: String
|
||||
public var pageIndex: Int
|
||||
public var pageLabel: String
|
||||
public var title: String
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
id: String = UUID().uuidString,
|
||||
pageIndex: Int,
|
||||
pageLabel: String,
|
||||
title: String,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.pageIndex = pageIndex
|
||||
self.pageLabel = pageLabel
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum PDFDocumentBookmarks {
|
||||
public static func sorted(_ bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
|
||||
preferredBookmark(in: bookmarks).map { [$0] } ?? []
|
||||
}
|
||||
|
||||
public static func upsert(
|
||||
_ bookmark: PDFDocumentBookmark,
|
||||
in _: [PDFDocumentBookmark]
|
||||
) -> [PDFDocumentBookmark] {
|
||||
[bookmark]
|
||||
}
|
||||
|
||||
public static func removing(id: String, from bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
|
||||
sorted(bookmarks.filter { $0.id != id })
|
||||
}
|
||||
|
||||
public static func bookmark(on pageIndex: Int, in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
|
||||
sorted(bookmarks).first { $0.pageIndex == pageIndex }
|
||||
}
|
||||
|
||||
public static func clamped(
|
||||
_ bookmarks: [PDFDocumentBookmark],
|
||||
pageCount: Int
|
||||
) -> [PDFDocumentBookmark] {
|
||||
guard pageCount > 0 else { return [] }
|
||||
return sorted(bookmarks.filter { (0..<pageCount).contains($0.pageIndex) })
|
||||
}
|
||||
|
||||
private static func preferredBookmark(in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
|
||||
bookmarks.max {
|
||||
if $0.createdAt != $1.createdAt {
|
||||
return $0.createdAt < $1.createdAt
|
||||
}
|
||||
return $0.pageIndex < $1.pageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
19
sources/core/PDFFileSelection.swift
Normal file
19
sources/core/PDFFileSelection.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
public enum PDFFileSelection {
|
||||
public static func isPDFFileURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
|
||||
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey, .isDirectoryKey])
|
||||
if resourceValues?.isDirectory == true {
|
||||
return false
|
||||
}
|
||||
|
||||
if let contentType = resourceValues?.contentType {
|
||||
return contentType.conforms(to: .pdf)
|
||||
}
|
||||
|
||||
return url.pathExtension.localizedCaseInsensitiveCompare("pdf") == .orderedSame
|
||||
}
|
||||
}
|
||||
82
sources/core/PDFRecentDocuments.swift
Normal file
82
sources/core/PDFRecentDocuments.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public struct PDFRecentDocumentProgress {
|
||||
public var key: String
|
||||
public var pageIndex: Int
|
||||
public var openedAt: Date
|
||||
|
||||
public init(key: String, pageIndex: Int, openedAt: Date) {
|
||||
self.key = key
|
||||
self.pageIndex = pageIndex
|
||||
self.openedAt = openedAt
|
||||
}
|
||||
}
|
||||
|
||||
public enum PDFRecentDocuments {
|
||||
public static func filteredPDFs(
|
||||
from urls: [URL],
|
||||
currentURL: URL? = nil,
|
||||
limit: Int,
|
||||
fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) }
|
||||
) -> [URL] {
|
||||
guard limit > 0 else { return [] }
|
||||
|
||||
var result: [URL] = []
|
||||
var seen = Set<URL>()
|
||||
let current = currentURL.map(normalized)
|
||||
|
||||
for url in urls {
|
||||
let normalizedURL = normalized(url)
|
||||
guard normalizedURL != current,
|
||||
seen.insert(normalizedURL).inserted,
|
||||
PDFFileSelection.isPDFFileURL(normalizedURL),
|
||||
fileExists(normalizedURL)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
result.append(normalizedURL)
|
||||
if result.count == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public static func documentKey(for url: URL) -> String {
|
||||
normalized(url).path
|
||||
}
|
||||
|
||||
public static func progress(
|
||||
for url: URL,
|
||||
in records: [String: PDFRecentDocumentProgress]
|
||||
) -> PDFRecentDocumentProgress? {
|
||||
records[documentKey(for: url)]
|
||||
}
|
||||
|
||||
public static func updatedProgress(
|
||||
_ records: [String: PDFRecentDocumentProgress],
|
||||
url: URL,
|
||||
pageIndex: Int,
|
||||
openedAt: Date
|
||||
) -> [String: PDFRecentDocumentProgress] {
|
||||
let key = documentKey(for: url)
|
||||
var copy = records
|
||||
copy[key] = PDFRecentDocumentProgress(
|
||||
key: key,
|
||||
pageIndex: max(0, pageIndex),
|
||||
openedAt: openedAt
|
||||
)
|
||||
return copy
|
||||
}
|
||||
|
||||
public static func clampedPageIndex(_ pageIndex: Int?, pageCount: Int) -> Int {
|
||||
guard pageCount > 0, let pageIndex else { return 0 }
|
||||
return min(max(0, pageIndex), pageCount - 1)
|
||||
}
|
||||
|
||||
static func normalized(_ url: URL) -> URL {
|
||||
url.standardizedFileURL
|
||||
}
|
||||
}
|
||||
20
sources/core/ReturnKeyCommitPolicy.swift
Normal file
20
sources/core/ReturnKeyCommitPolicy.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReturnKeyCommitPolicy {
|
||||
public static func shouldCommit(
|
||||
keyCode: UInt16,
|
||||
shift: Bool,
|
||||
option: Bool,
|
||||
command: Bool,
|
||||
control: Bool,
|
||||
isEditableMultilineText: Bool,
|
||||
commandReturnOnly: Bool = false
|
||||
) -> Bool {
|
||||
guard isEditableMultilineText else { return false }
|
||||
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||
if commandReturnOnly {
|
||||
return command && !shift && !option && !control
|
||||
}
|
||||
return !shift && !option && !command && !control
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user