v0.1 Comments and basic functionality work

This commit is contained in:
Akshay Kolli
2026-06-11 18:12:13 -07:00
commit a75582584a
33 changed files with 5045 additions and 0 deletions

View File

@@ -0,0 +1,361 @@
import AppKit
import Foundation
import PDFKit
public enum AcademicAnnotationPalette {
public static let comment = NSColor(
calibratedRed: 0.88,
green: 0.72,
blue: 0.46,
alpha: 0.10
)
public static let highlight = NSColor(
calibratedRed: 0.88,
green: 0.72,
blue: 0.46,
alpha: 0.24
)
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
}
}
var color: NSColor {
switch self {
case .comment: return AcademicAnnotationPalette.comment
case .highlight: return AcademicAnnotationPalette.highlight
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,
date: Date = Date()
) -> [AnnotationInsertion] {
let lineSelections = selection.selectionsByLine()
var groups: [(page: PDFPage, rects: [CGRect])] = []
for lineSelection in lineSelections {
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)
} else {
groups.append((page: page, rects: [rect]))
}
}
}
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
annotation.quadrilateralPoints = group.rects.flatMap { rect in
quadPoints(for: rect, relativeTo: unionRect)
}
standardize(annotation, comment: comment, author: author, date: date)
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)
let popup = makePopupIfNeeded(for: annotation, on: page, open: false)
return AnnotationInsertion(page: page, annotation: annotation, popup: popup)
}
public static func updateComment(
for annotation: PDFAnnotation,
on page: PDFPage,
text: String,
author: String,
date: Date = Date()
) -> PDFAnnotation? {
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 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
) {
annotation.contents = 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 }
guard let contents = annotation.contents,
!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.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
}
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 desired = CGRect(
x: annotationBounds.maxX + 10,
y: max(annotationBounds.minY - 96, pageBounds.minY + 12),
width: 240,
height: 120
)
return clampedRect(
desired: desired,
on: page,
fallbackSize: CGSize(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)
}
}

View File

@@ -0,0 +1,319 @@
import AppKit
import Foundation
import PDFKit
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
case comment
case highlight
case underline
case note
case freeText
case reply
case other
public var id: String { rawValue }
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, Equatable {
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 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,
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.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 isReply: Bool {
parentID != nil
}
public static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool {
lhs.id == rhs.id
&& lhs.pageIndex == rhs.pageIndex
&& lhs.pageLabel == rhs.pageLabel
&& lhs.annotationIndex == rhs.annotationIndex
&& lhs.kind == rhs.kind
&& lhs.author == rhs.author
&& lhs.createdAt == rhs.createdAt
&& lhs.modifiedAt == rhs.modifiedAt
&& lhs.status == rhs.status
&& lhs.contents == rhs.contents
&& lhs.bounds == rhs.bounds
&& lhs.parentID == rhs.parentID
}
}
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 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 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)
}
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] = []
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)
guard kind != .other || annotation.contents?.isEmpty == false 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: annotation.contents ?? "",
bounds: annotation.bounds,
annotation: annotation,
page: page,
parentID: parentID
)
)
}
}
return result.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
}
}
}