Release v0.3
This commit is contained in:
84
Sources/IHatePDFsCore/AnnotationColorPreference.swift
Normal file
84
Sources/IHatePDFsCore/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())))
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,16 @@ import PDFKit
|
||||
|
||||
public enum AcademicAnnotationPalette {
|
||||
public static let comment = NSColor(
|
||||
calibratedRed: 0.88,
|
||||
green: 0.72,
|
||||
blue: 0.46,
|
||||
alpha: 0.10
|
||||
calibratedRed: 0.98,
|
||||
green: 0.64,
|
||||
blue: 0.16,
|
||||
alpha: 0.30
|
||||
)
|
||||
public static let highlight = NSColor(
|
||||
calibratedRed: 0.88,
|
||||
green: 0.72,
|
||||
blue: 0.46,
|
||||
alpha: 0.24
|
||||
calibratedRed: 1.0,
|
||||
green: 0.78,
|
||||
blue: 0.0,
|
||||
alpha: 0.52
|
||||
)
|
||||
public static let underline = NSColor(
|
||||
calibratedRed: 0.48,
|
||||
@@ -58,10 +58,13 @@ public enum MarkupAnnotationStyle {
|
||||
}
|
||||
}
|
||||
|
||||
var color: NSColor {
|
||||
func color(
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment
|
||||
) -> NSColor {
|
||||
switch self {
|
||||
case .comment: return AcademicAnnotationPalette.comment
|
||||
case .highlight: return AcademicAnnotationPalette.highlight
|
||||
case .comment: return commentColor
|
||||
case .highlight: return highlightColor
|
||||
case .underline: return AcademicAnnotationPalette.underline
|
||||
}
|
||||
}
|
||||
@@ -95,6 +98,8 @@ public enum AnnotationFactory {
|
||||
style: MarkupAnnotationStyle,
|
||||
comment: String,
|
||||
author: String,
|
||||
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
|
||||
commentColor: NSColor = AcademicAnnotationPalette.comment,
|
||||
date: Date = Date()
|
||||
) -> [AnnotationInsertion] {
|
||||
let lineSelections = selection.selectionsByLine()
|
||||
@@ -120,7 +125,7 @@ public enum AnnotationFactory {
|
||||
}
|
||||
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
|
||||
annotation.markupType = style.markupType
|
||||
annotation.color = style.color
|
||||
annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
|
||||
annotation.quadrilateralPoints = group.rects.flatMap { rect in
|
||||
quadPoints(for: rect, relativeTo: unionRect)
|
||||
}
|
||||
@@ -266,7 +271,9 @@ public enum AnnotationFactory {
|
||||
date: Date
|
||||
) {
|
||||
AnnotationKeys.setCommentText(comment, for: annotation)
|
||||
annotation.contents = comment
|
||||
annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? nil
|
||||
: comment
|
||||
annotation.userName = author
|
||||
annotation.modificationDate = date
|
||||
annotation.shouldDisplay = true
|
||||
@@ -357,6 +364,51 @@ public enum AnnotationFactory {
|
||||
@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
|
||||
|
||||
46
Sources/IHatePDFsCore/AnnotationHitTesting.swift
Normal file
46
Sources/IHatePDFsCore/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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,11 +157,16 @@ public enum AnnotationKeys {
|
||||
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
||||
|
||||
public static func commentText(for annotation: PDFAnnotation) -> String {
|
||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String {
|
||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
|
||||
!value.isEmpty {
|
||||
return value
|
||||
}
|
||||
|
||||
return annotation.contents ?? ""
|
||||
if let contents = annotation.contents, !contents.isEmpty {
|
||||
return contents
|
||||
}
|
||||
|
||||
return annotation.popup?.contents ?? ""
|
||||
}
|
||||
|
||||
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
|
||||
@@ -287,54 +292,42 @@ public enum AnnotationKeys {
|
||||
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 }
|
||||
|
||||
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)
|
||||
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 = AnnotationKeys.parentID(for: annotation, document: document)
|
||||
|
||||
result.append(
|
||||
AnnotationSnapshot(
|
||||
id: id,
|
||||
pageIndex: pageIndex,
|
||||
pageLabel: pageLabel,
|
||||
annotationIndex: annotationIndex,
|
||||
kind: kind,
|
||||
author: author,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: annotation.modificationDate,
|
||||
status: status,
|
||||
contents: contents,
|
||||
bounds: annotation.bounds,
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
parentID: parentID
|
||||
)
|
||||
)
|
||||
}
|
||||
result.append(contentsOf: snapshots(
|
||||
in: document,
|
||||
page: page,
|
||||
pageIndex: pageIndex,
|
||||
namedAnnotationIDs: &namedAnnotationIDs
|
||||
))
|
||||
}
|
||||
|
||||
return result.sorted { left, right in
|
||||
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
|
||||
}
|
||||
@@ -344,4 +337,112 @@ public enum AnnotationReader {
|
||||
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)
|
||||
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,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
19
Sources/IHatePDFsCore/PDFFileSelection.swift
Normal file
19
Sources/IHatePDFsCore/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
|
||||
}
|
||||
}
|
||||
16
Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift
Normal file
16
Sources/IHatePDFsCore/ReturnKeyCommitPolicy.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
public enum ReturnKeyCommitPolicy {
|
||||
public static func shouldCommit(
|
||||
keyCode: UInt16,
|
||||
shift: Bool,
|
||||
option: Bool,
|
||||
command: Bool,
|
||||
control: Bool,
|
||||
isEditableMultilineText: Bool
|
||||
) -> Bool {
|
||||
guard isEditableMultilineText else { return false }
|
||||
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||
return !shift && !option && !command && !control
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user