Release v0.3

This commit is contained in:
Akshay Kolli
2026-06-24 17:51:26 -07:00
parent 3d112c677a
commit 085d7a16dc
33 changed files with 2828 additions and 428 deletions

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

View File

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

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

View File

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

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

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