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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_Store
.build/
DerivedData/
dist/
*.xcuserstate
*.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 I Hate PDFs contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

24
Package.swift Normal file
View File

@@ -0,0 +1,24 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "IHatePDFs",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "IHatePDFs", targets: ["IHatePDFs"]),
.library(name: "IHatePDFsCore", targets: ["IHatePDFsCore"])
],
targets: [
.target(name: "IHatePDFsCore"),
.executableTarget(
name: "IHatePDFs",
dependencies: ["IHatePDFsCore"]
),
.testTarget(
name: "IHatePDFsCoreTests",
dependencies: ["IHatePDFsCore"]
)
]
)

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# I Hate PDFs
I Hate PDFs is an open-source macOS PDF reader for anyone who hates adobe. I think adobe is .
## Status
This app is entirely vibe coded, but will somehow still be better than adobe acrobate soon.
Minimum supported macOS version: macOS 13 Ventura.
Supported Mac architectures: Apple Silicon and Intel, subject to the local Swift/Xcode toolchain used to build.
## Features
- Open local `.pdf` files from disk.
- Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation.
- Search selectable text PDFs from a compact toolbar control.
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested.
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size.
- Toggle a compact page thumbnail/sidebar inspector.
- Create selection-bound comments from highlighted PDF text.
- Create highlight annotations with anchored optional comments.
- Create underline annotations with optional comments.
- Create free-text annotations directly on the page.
- Click annotations in the PDF to reopen and edit the comment in place.
- Save annotations directly into the original PDF after an overwrite warning.
- Save As a new annotated copy.
- Share the annotated PDF through the native macOS share picker.
- Review annotations in a compact list with page number, type, author, date, and first comment line.
- Use an Acrobat-style comments sidebar with total count, page grouping, collapsible groups, an add-comment affordance, comment search, collapsed type/author/status filters, full text, replies, edit/delete, and click-to-navigate.
## Build From Source
Requirements:
- macOS 13 or newer
- Xcode 15 or newer with command line tools
- Swift Package Manager
Build and run the debug executable:
```sh
swift run IHatePDFs
```
Run tests:
```sh
swift test
```
Build a release `.app` bundle:
```sh
scripts/build-app.sh
```
Release app builds default to a universal `arm64` + `x86_64` executable. To build only the current architecture during development, run:
```sh
ARCHS="" scripts/build-app.sh
```
Create a downloadable `.dmg`:
```sh
scripts/make-dmg.sh
```
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs.dmg`.
## Installation
Download `IHatePDFs.dmg`, open it, and move `I Hate PDFs.app` into `/Applications`. For local development builds that are not notarized, macOS may require opening the app from Finder with Control-click, Open the first time.
## Development
The project is a Swift Package with two targets:
- `IHatePDFsCore`: PDF annotation models and factory helpers.
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
Useful checks:
```sh
swift test
swift build -c release
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
```
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, and popup annotation dictionaries.
Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. The macOS design review is documented in `docs/DESIGN_REVIEW.md`.
## Screenshots
Screenshots live in `docs/screenshots`.
Current repository screenshots:
- `docs/screenshots/no-document.png`
- `docs/screenshots/default-reading.png`
- `docs/screenshots/highlight-comment-popover.png`
- `docs/screenshots/main-window.png`
- `docs/screenshots/comments-sidebar.png`
- `docs/screenshots/dark-mode-reading.png`
![No document open](docs/screenshots/no-document.png)
![Default reading mode](docs/screenshots/default-reading.png)
![Highlight comment popover](docs/screenshots/highlight-comment-popover.png)
![Comments sidebar](docs/screenshots/comments-sidebar.png)
![Dark mode reading](docs/screenshots/dark-mode-reading.png)
## License
MIT. See `LICENSE`.

33
ROADMAP.md Normal file
View File

@@ -0,0 +1,33 @@
# Roadmap
## Version 0.1
- Native macOS SwiftUI/PDFKit app.
- Local PDF opening.
- Reading controls: scrolling, zoom, fit width, fit page, page navigation, search.
- Focused default reading mode with optional page thumbnail sidebar.
- Highlight, underline, selection-bound comment, and free-text annotations.
- Anchored comment popovers from newly created selected-text comments, highlights, underlines, free text, and clicked annotations.
- Annotation list sidebar.
- Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation.
- Save, Save As, and native macOS sharing with standard PDF annotation writing.
- `.app` and `.dmg` build scripts.
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
## Version 0.2
- More explicit visual selection handles for the active annotation.
- Better undo/redo integration for annotation edits.
- Optional author identity preferences.
- More granular sidebar and inspector layout memory for complex multi-window workflows.
- Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`.
- Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers.
- Import/export verification fixtures for existing annotated PDFs.
## Later
- Optional OCR for scanned readings.
- Optional citation metadata display.
- Optional AI summaries or question prompts.
- iPad companion app.
- LMS integrations.

View File

@@ -0,0 +1,982 @@
import AppKit
import Foundation
import IHatePDFsCore
import PDFKit
import SwiftUI
enum SidebarMode: String, CaseIterable, Identifiable {
case pages
case annotations
var id: String { rawValue }
}
enum CommentFilter: String, CaseIterable, Identifiable {
case all
case withComments
case withoutComments
var id: String { rawValue }
var title: String {
switch self {
case .all: return "All"
case .withComments: return "With Comments"
case .withoutComments: return "No Comment"
}
}
}
enum AnnotationPlacementTool: Equatable {
case freeText
}
private enum AppDefaults {
static let documentSidebarStates = "IHatePDFs.documentSidebarStates.v1"
static func sidebarPreference(for key: String) -> SidebarPreference? {
guard let data = UserDefaults.standard.data(forKey: documentSidebarStates),
let states = try? JSONDecoder().decode([String: SidebarPreference].self, from: data)
else {
return nil
}
return states[key]
}
static func setSidebarPreference(_ preference: SidebarPreference, for key: String) {
let existingData = UserDefaults.standard.data(forKey: documentSidebarStates)
var states = existingData
.flatMap { try? JSONDecoder().decode([String: SidebarPreference].self, from: $0) }
?? [:]
states[key] = preference
guard let data = try? JSONEncoder().encode(states) else { return }
UserDefaults.standard.set(data, forKey: documentSidebarStates)
}
}
private enum SidebarWidthBucket: String {
case compact
case regular
case wide
init(width: CGFloat) {
if width < 960 {
self = .compact
} else if width < 1280 {
self = .regular
} else {
self = .wide
}
}
}
private struct SidebarPreference: Codable, Equatable {
var showLeftSidebar: Bool
var showCommentsSidebar: Bool
static let defaultReading = SidebarPreference(showLeftSidebar: false, showCommentsSidebar: false)
}
struct AnnotationEditorContext: Identifiable {
let id = UUID()
let title: String
let annotations: [PDFAnnotation]
let pages: [PDFPage]
let isNewAnnotation: Bool
let allowsDelete: Bool
let initialText: String
let initialAuthor: String
var primaryAnnotation: PDFAnnotation? { annotations.first }
var primaryPage: PDFPage? { pages.first }
}
@MainActor
final class AppState: NSObject, ObservableObject {
@Published var document: PDFDocument?
@Published var documentURL: URL?
@Published var pdfView: PDFView?
@Published var annotations: [AnnotationSnapshot] = []
@Published var selectedAnnotationID: String?
@Published var activeEditor: AnnotationEditorContext?
@Published var placementTool: AnnotationPlacementTool?
@Published var showLeftSidebar = false {
didSet {
persistSidebarPreferenceIfNeeded()
}
}
@Published var showCommentsSidebar = false {
didSet {
persistSidebarPreferenceIfNeeded()
}
}
@Published var sidebarMode: SidebarMode = .pages
@Published var searchText = ""
@Published var showToolbarSearch = false
@Published var searchResults: [PDFSelection] = []
@Published var currentSearchIndex = 0
@Published var pageText = "1"
@Published var currentPageIndex = 0
@Published var commentSearchText = ""
@Published var commentFilter: CommentFilter = .all
@Published var selectedKindFilter: AcademicAnnotationKind?
@Published var selectedAuthorFilter = "All Authors"
@Published var selectedStatusFilter = ReviewState.allStatuses
@Published var collapsedPageIndexes: Set<Int> = []
@Published var statusMessage = "Open a PDF to begin."
private var pageObserver: NSObjectProtocol?
private var selectionObserver: NSObjectProtocol?
private var sidebarWidthBucket: SidebarWidthBucket = .regular
private var isApplyingSidebarPreference = false
private var hoveredAnnotationID: String?
var displayTitle: String {
documentURL?.lastPathComponent ?? "I Hate PDFs"
}
var pageCount: Int {
document?.pageCount ?? 0
}
var authors: [String] {
let values = Set(annotations.map(\.author).filter { !$0.isEmpty })
return ["All Authors"] + values.sorted()
}
var statuses: [String] {
let values = Set(annotations.map { ReviewState.label(for: $0.status) }.filter { !$0.isEmpty })
let preferred = [ReviewState.notReviewed, ReviewState.reviewed].filter(values.contains)
let custom = values.subtracting(preferred).sorted()
return [ReviewState.allStatuses] + preferred + custom
}
var filteredAnnotations: [AnnotationSnapshot] {
annotations.filter { item in
switch commentFilter {
case .all:
break
case .withComments:
guard item.hasComment else { return false }
case .withoutComments:
guard !item.hasComment else { return false }
}
if let selectedKindFilter, item.kind != selectedKindFilter {
return false
}
if selectedAuthorFilter != "All Authors", item.author != selectedAuthorFilter {
return false
}
if !ReviewState.matches(item.status, filter: selectedStatusFilter) {
return false
}
let query = commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
if !query.isEmpty {
let haystack = [
item.contents,
item.author,
item.kind.displayName,
item.pageLabel
].joined(separator: " ")
guard haystack.localizedCaseInsensitiveContains(query) else { return false }
}
return true
}
}
var topLevelComments: [AnnotationSnapshot] {
filteredAnnotations.filter { !$0.isReply }
}
var repliesByParent: [String: [AnnotationSnapshot]] {
Dictionary(grouping: filteredAnnotations.filter(\.isReply), by: \.parentID!)
}
func attachPDFView(_ view: PDFView) {
if pdfView === view { return }
pdfView = view
configure(view)
view.document = document
pageObserver = NotificationCenter.default.addObserver(
forName: .PDFViewPageChanged,
object: view,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.updateCurrentPageState() }
}
selectionObserver = NotificationCenter.default.addObserver(
forName: .PDFViewSelectionChanged,
object: view,
queue: .main
) { [weak self] _ in
Task { @MainActor in
guard self?.placementTool == nil else { return }
self?.statusMessage = "Selection ready for annotation."
}
}
}
func updateWindowWidth(_ width: CGFloat) {
guard width.isFinite, width > 0 else { return }
let bucket = SidebarWidthBucket(width: width)
guard bucket != sidebarWidthBucket else { return }
sidebarWidthBucket = bucket
applySidebarPreferenceForCurrentDocument()
}
func openDocument() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.pdf]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.title = "Open PDF"
guard panel.runModal() == .OK, let url = panel.url else { return }
loadDocument(from: url)
}
func loadDocument(from url: URL) {
guard let pdf = PDFDocument(url: url) else {
showAlert(title: "Unable to Open PDF", message: "The selected file could not be opened as a PDF.")
return
}
document = pdf
documentURL = url
applySidebarPreferenceForCurrentDocument()
pdfView?.document = pdf
pdfView?.goToFirstPage(nil)
pageText = "1"
currentPageIndex = 0
searchText = ""
showToolbarSearch = false
searchResults = []
selectedAnnotationID = nil
activeEditor = nil
placementTool = nil
refreshAnnotations()
statusMessage = "Opened \(url.lastPathComponent)."
}
func closeDocument() {
persistSidebarPreferenceIfNeeded()
document = nil
documentURL = nil
annotations = []
selectedAnnotationID = nil
activeEditor = nil
placementTool = nil
searchResults = []
searchText = ""
showToolbarSearch = false
pageText = "1"
currentPageIndex = 0
pdfView?.document = nil
applySidebarPreference(.defaultReading)
statusMessage = "Open a PDF to begin."
}
func saveDocument() {
guard let document else { return }
if let url = documentURL {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Overwrite Original PDF?"
alert.informativeText = "Annotations will be written directly into \(url.lastPathComponent). Use Save As to create a separate annotated copy."
alert.addButton(withTitle: "Save")
alert.addButton(withTitle: "Cancel")
guard alert.runModal() == .alertFirstButtonReturn else { return }
write(document, to: url)
} else {
saveDocumentAs()
}
}
func saveDocumentAs() {
guard let document else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [.pdf]
panel.canCreateDirectories = true
panel.title = "Save Annotated PDF"
panel.nameFieldStringValue = suggestedAnnotatedFilename()
guard panel.runModal() == .OK, let url = panel.url else { return }
write(document, to: url)
documentURL = url
persistSidebarPreferenceIfNeeded()
}
func shareDocument() {
guard let document else { return }
guard let url = documentURL else {
saveDocumentAs()
return
}
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = "Share Annotated PDF?"
alert.informativeText = "Save annotations to \(url.lastPathComponent) before sharing so recipients see the latest comments."
alert.addButton(withTitle: "Save and Share")
alert.addButton(withTitle: "Share Existing File")
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
guard document.write(to: url) else {
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
return
}
refreshAnnotations()
case .alertSecondButtonReturn:
break
default:
return
}
presentSharePicker(for: url)
statusMessage = "Ready to share \(url.lastPathComponent)."
}
func addHighlight() {
addMarkup(style: .highlight, title: "Highlight Comment")
}
func addUnderline() {
addMarkup(style: .underline, title: "Underline Comment")
}
func addComment() {
addMarkup(style: .comment, title: "Comment")
}
func addFreeText() {
guard document != nil else {
statusMessage = "Open a PDF before adding free text."
return
}
activeEditor = nil
placementTool = .freeText
statusMessage = "Click on the page to place free text."
}
func placePendingAnnotation(on page: PDFPage, near point: CGPoint) {
guard let placementTool else { return }
let insertion: AnnotationInsertion
let title: String
switch placementTool {
case .freeText:
insertion = AnnotationFactory.freeTextInsertion(
on: page,
near: point,
text: "",
author: AnnotationFactory.defaultAuthor
)
title = "Free Text"
}
self.placementTool = nil
add(insertion)
refreshAnnotations()
openEditor(
title: title,
annotations: [insertion.annotation],
pages: [page],
isNew: true
)
}
func addReply(to item: AnnotationSnapshot) {
let insertion = AnnotationFactory.replyInsertion(
to: item.annotation,
on: item.page,
comment: "",
author: AnnotationFactory.defaultAuthor,
parentID: item.id
)
add(insertion)
refreshAnnotations()
openEditor(
title: "Reply",
annotations: [insertion.annotation],
pages: [item.page],
isNew: true
)
}
func edit(_ item: AnnotationSnapshot) {
openEditor(
title: item.kind == .freeText ? "Edit Free Text" : "Edit Comment",
annotations: [item.annotation],
pages: [item.page],
isNew: false
)
}
func delete(_ item: AnnotationSnapshot) {
let targets = annotations.filter { candidate in
candidate.id == item.id || candidate.parentID == item.id
}
let targetIDs = Set(targets.map(\.id))
for target in targets {
removeAnnotation(target.annotation, from: target.page)
}
if selectedAnnotationID.map(targetIDs.contains) == true {
selectedAnnotationID = nil
}
if hoveredAnnotationID.map(targetIDs.contains) == true {
hoveredAnnotationID = nil
}
activeEditor = nil
refreshAnnotations()
statusMessage = targets.count > 1 ? "Comment thread deleted." : "Comment deleted."
}
func toggleReviewed(_ item: AnnotationSnapshot) {
let isReviewed = ReviewState.isReviewed(item.status)
let nextState = isReviewed ? "Unmarked" : "Marked"
let date = Date()
item.annotation.modificationDate = date
_ = item.annotation.setValue(date, forAnnotationKey: .date)
_ = item.annotation.setValue(nextState, forAnnotationKey: AnnotationKeys.state)
_ = item.annotation.setValue("Marked", forAnnotationKey: AnnotationKeys.stateModel)
if let popup = item.annotation.popup {
popup.modificationDate = date
}
pdfView?.annotationsChanged(on: item.page)
refreshAnnotations()
statusMessage = isReviewed ? "Marked as not reviewed." : "Marked as reviewed."
}
func saveEditor(
_ context: AnnotationEditorContext,
text: String,
author: String
) {
updateAnnotations(in: context, text: text, author: author)
refreshAnnotations()
activeEditor = nil
statusMessage = "Comment saved."
}
func updateEditorDraft(
_ context: AnnotationEditorContext,
text: String,
author: String
) {
guard activeEditor?.id == context.id else { return }
updateAnnotations(in: context, text: text, author: author)
refreshAnnotations()
}
func deleteAnnotations(in context: AnnotationEditorContext) {
for (index, annotation) in context.annotations.enumerated() {
guard index < context.pages.count else { continue }
removeAnnotation(annotation, from: context.pages[index])
}
activeEditor = nil
selectedAnnotationID = nil
refreshAnnotations()
statusMessage = "Annotation deleted."
}
func select(_ item: AnnotationSnapshot) {
clearHoveredAnnotation()
clearHighlightedAnnotation()
selectedAnnotationID = item.id
item.annotation.isHighlighted = true
pdfView?.go(to: item.bounds.insetBy(dx: -24, dy: -24), on: item.page)
pdfView?.annotationsChanged(on: item.page)
statusMessage = "\(item.kind.displayName) on page \(item.pageLabel)."
}
func setCommentHover(_ item: AnnotationSnapshot, isHovered: Bool) {
if isHovered {
clearHoveredAnnotation(except: item.id)
hoveredAnnotationID = item.id
item.annotation.isHighlighted = true
pdfView?.annotationsChanged(on: item.page)
return
}
guard hoveredAnnotationID == item.id else { return }
hoveredAnnotationID = nil
guard selectedAnnotationID != item.id else { return }
item.annotation.isHighlighted = false
pdfView?.annotationsChanged(on: item.page)
}
func openAnnotationFromPDF(_ annotation: PDFAnnotation, page: PDFPage) {
let parent = AnnotationFactory.parentAnnotation(for: annotation)
let targetPage = parent.page ?? page
let pageIndex = document?.index(for: targetPage) ?? 0
let annotationIndex = targetPage.annotations.firstIndex(where: { $0 === parent }) ?? 0
let id = AnnotationKeys.stableID(
for: parent,
pageIndex: pageIndex,
annotationIndex: annotationIndex
)
if let item = annotations.first(where: { $0.id == id }) {
select(item)
edit(item)
} else {
openEditor(
title: "Edit Comment",
annotations: [parent],
pages: [targetPage],
isNew: false
)
}
}
func refreshAnnotations() {
guard let document else {
annotations = []
return
}
annotations = AnnotationReader.snapshots(in: document)
}
func runSearch() {
guard let document else { return }
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
searchResults = []
pdfView?.highlightedSelections = nil
statusMessage = "Search cleared."
return
}
let results = document.findString(query, withOptions: [.caseInsensitive, .diacriticInsensitive])
for result in results {
result.color = NSColor.findHighlightColor.withAlphaComponent(0.45)
}
searchResults = results
pdfView?.highlightedSelections = results
currentSearchIndex = 0
goToSearchResult(at: currentSearchIndex)
statusMessage = results.isEmpty ? "No matches for \(query)." : "\(results.count) search matches."
}
func showSearch() {
guard document != nil else { return }
showToolbarSearch = true
statusMessage = "Search ready."
DispatchQueue.main.async { [weak self] in
self?.focusToolbarSearchField()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
self?.focusToolbarSearchField()
}
}
func hideSearch() {
showToolbarSearch = false
if searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
searchResults = []
pdfView?.highlightedSelections = nil
}
}
func nextSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
goToSearchResult(at: currentSearchIndex)
}
func previousSearchResult() {
guard !searchResults.isEmpty else { return }
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
goToSearchResult(at: currentSearchIndex)
}
func zoomIn() {
pdfView?.autoScales = false
pdfView?.zoomIn(nil)
}
func zoomOut() {
pdfView?.autoScales = false
pdfView?.zoomOut(nil)
}
func fitWidth() {
pdfView?.displayMode = .singlePageContinuous
pdfView?.autoScales = true
statusMessage = "Fit to width."
}
func fitPage() {
pdfView?.displayMode = .singlePage
pdfView?.autoScales = true
statusMessage = "Fit to page."
}
func twoPageContinuous() {
pdfView?.displayMode = .twoUpContinuous
pdfView?.displayDirection = .vertical
pdfView?.displaysAsBook = false
pdfView?.autoScales = true
statusMessage = "Two pages continuous."
}
func goToPageFromField() {
guard let document,
let target = Int(pageText.trimmingCharacters(in: .whitespacesAndNewlines)),
target >= 1,
target <= document.pageCount,
let page = document.page(at: target - 1)
else {
updateCurrentPageState()
return
}
navigate(to: page, pageIndex: target - 1)
}
func goToPreviousPage() {
guard let document, let currentPage = pdfView?.currentPage else { return }
let index = document.index(for: currentPage)
guard index != NSNotFound, index > 0, let page = document.page(at: index - 1) else {
updateCurrentPageState()
return
}
navigate(to: page, pageIndex: index - 1)
}
func goToNextPage() {
guard let document, let currentPage = pdfView?.currentPage else { return }
let index = document.index(for: currentPage)
guard index != NSNotFound,
index + 1 < document.pageCount,
let page = document.page(at: index + 1)
else {
updateCurrentPageState()
return
}
navigate(to: page, pageIndex: index + 1)
}
func toggleFullScreen() {
NSApp.keyWindow?.toggleFullScreen(nil)
}
func minimizeWindow() {
NSApp.keyWindow?.miniaturize(nil)
}
private func addMarkup(style: MarkupAnnotationStyle, title: String) {
guard let selection = pdfView?.currentSelection, !selection.pages.isEmpty else {
statusMessage = style == .comment
? "Select text before adding a comment."
: "Select text before adding a markup annotation."
return
}
let insertions = AnnotationFactory.markupInsertions(
from: selection,
style: style,
comment: "",
author: AnnotationFactory.defaultAuthor
)
guard !insertions.isEmpty else {
statusMessage = "No selectable text was found in the selection."
return
}
for insertion in insertions {
add(insertion)
}
pdfView?.clearSelection()
refreshAnnotations()
openEditor(
title: title,
annotations: insertions.map(\.annotation),
pages: insertions.map(\.page),
isNew: true
)
}
private func add(_ insertion: AnnotationInsertion) {
insertion.page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
insertion.page.addAnnotation(popup)
}
pdfView?.annotationsChanged(on: insertion.page)
}
private func updateAnnotations(
in context: AnnotationEditorContext,
text: String,
author: String
) {
let trimmedAuthor = author.trimmingCharacters(in: .whitespacesAndNewlines)
let authorValue = trimmedAuthor.isEmpty ? AnnotationFactory.defaultAuthor : trimmedAuthor
for (index, annotation) in context.annotations.enumerated() {
guard index < context.pages.count else { continue }
let page = context.pages[index]
let popup = AnnotationFactory.updateComment(
for: annotation,
on: page,
text: text,
author: authorValue
)
if let popup {
page.addAnnotation(popup)
}
pdfView?.annotationsChanged(on: page)
}
}
private func removeAnnotation(_ annotation: PDFAnnotation, from page: PDFPage) {
let linkedPopups = page.annotations.filter { candidate in
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
return candidate === annotation.popup || AnnotationFactory.parentAnnotation(for: candidate) === annotation
}
for popup in linkedPopups {
page.removeAnnotation(popup)
}
if let popup = annotation.popup, popup.page != nil {
page.removeAnnotation(popup)
}
page.removeAnnotation(annotation)
pdfView?.annotationsChanged(on: page)
}
private func openEditor(
title: String,
annotations: [PDFAnnotation],
pages: [PDFPage],
isNew: Bool
) {
closeNativePopups(on: pages)
let first = annotations.first
activeEditor = AnnotationEditorContext(
title: title,
annotations: annotations,
pages: pages,
isNewAnnotation: isNew,
allowsDelete: true,
initialText: first?.contents ?? "",
initialAuthor: first?.userName ?? AnnotationFactory.defaultAuthor
)
}
private func closeNativePopups(on pages: [PDFPage]) {
for page in pages {
for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
annotation.isOpen = false
}
annotation.popup?.isOpen = false
}
pdfView?.annotationsChanged(on: page)
}
}
private func configure(_ view: PDFView) {
view.displayMode = .singlePageContinuous
view.displayDirection = .vertical
view.displaysPageBreaks = true
view.pageBreakMargins = NSEdgeInsets(top: 10, left: 12, bottom: 10, right: 12)
view.displayBox = .cropBox
view.autoScales = true
view.minScaleFactor = 0.25
view.maxScaleFactor = 6
view.interpolationQuality = .high
view.backgroundColor = NSColor.controlBackgroundColor
view.acceptsDraggedFiles = true
view.pageShadowsEnabled = true
}
private func write(_ document: PDFDocument, to url: URL) {
guard document.write(to: url) else {
showAlert(title: "Save Failed", message: "The PDF could not be written to \(url.path).")
return
}
refreshAnnotations()
statusMessage = "Saved \(url.lastPathComponent)."
}
private func presentSharePicker(for url: URL) {
guard let contentView = NSApp.keyWindow?.contentView else { return }
let anchor = NSRect(
x: contentView.bounds.maxX - 24,
y: contentView.bounds.maxY - 24,
width: 1,
height: 1
)
let picker = NSSharingServicePicker(items: [url])
picker.show(relativeTo: anchor, of: contentView, preferredEdge: .minY)
}
private func suggestedAnnotatedFilename() -> String {
guard let url = documentURL else { return "Annotated.pdf" }
let base = url.deletingPathExtension().lastPathComponent
return "\(base)-annotated.pdf"
}
private func goToSearchResult(at index: Int) {
guard searchResults.indices.contains(index), let pdfView else { return }
let selection = searchResults[index]
pdfView.setCurrentSelection(selection, animate: true)
pdfView.go(to: selection)
}
private func navigate(to page: PDFPage, pageIndex: Int) {
guard let pdfView else { return }
let bounds = page.bounds(for: pdfView.displayBox)
let topSlice = NSRect(
x: bounds.minX,
y: bounds.maxY - 1,
width: bounds.width,
height: 1
)
pdfView.go(to: topSlice, on: page)
pdfView.setNeedsDisplay(pdfView.bounds)
currentPageIndex = pageIndex
pageText = "\(pageIndex + 1)"
}
private func updateCurrentPageState() {
guard let document, let currentPage = pdfView?.currentPage else { return }
let index = document.index(for: currentPage)
guard index != NSNotFound else { return }
currentPageIndex = index
pageText = "\(index + 1)"
}
private func clearHighlightedAnnotation() {
guard let selectedAnnotationID,
let previous = annotations.first(where: { $0.id == selectedAnnotationID })
else {
return
}
previous.annotation.isHighlighted = false
pdfView?.annotationsChanged(on: previous.page)
}
private func clearHoveredAnnotation(except keptID: String? = nil) {
guard let hoveredAnnotationID,
hoveredAnnotationID != keptID,
hoveredAnnotationID != selectedAnnotationID,
let previous = annotations.first(where: { $0.id == hoveredAnnotationID })
else {
return
}
previous.annotation.isHighlighted = false
pdfView?.annotationsChanged(on: previous.page)
self.hoveredAnnotationID = nil
}
private func showAlert(title: String, message: String) {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func focusToolbarSearchField() {
guard let window = NSApp.keyWindow,
let root = window.contentView?.superview,
let field = findSearchField(in: root)
else {
return
}
window.makeFirstResponder(field)
field.selectText(nil)
}
private func findSearchField(in view: NSView) -> NSTextField? {
if let field = view as? NSTextField,
field.placeholderString == "Search" {
return field
}
for subview in view.subviews {
if let field = findSearchField(in: subview) {
return field
}
}
return nil
}
private func applySidebarPreferenceForCurrentDocument() {
guard let documentURL else {
applySidebarPreference(.defaultReading)
return
}
let preference = AppDefaults.sidebarPreference(
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
) ?? .defaultReading
applySidebarPreference(preference)
}
private func applySidebarPreference(_ preference: SidebarPreference) {
isApplyingSidebarPreference = true
showLeftSidebar = preference.showLeftSidebar
showCommentsSidebar = preference.showCommentsSidebar
isApplyingSidebarPreference = false
}
private func persistSidebarPreferenceIfNeeded() {
guard !isApplyingSidebarPreference,
let documentURL
else {
return
}
let preference = SidebarPreference(
showLeftSidebar: showLeftSidebar,
showCommentsSidebar: showCommentsSidebar
)
AppDefaults.setSidebarPreference(
preference,
for: sidebarPreferenceKey(for: documentURL, bucket: sidebarWidthBucket)
)
}
private func sidebarPreferenceKey(for url: URL, bucket: SidebarWidthBucket) -> String {
let documentKey = url.isFileURL ? url.standardizedFileURL.path : url.absoluteString
return "\(documentKey)#\(bucket.rawValue)"
}
}

View File

@@ -0,0 +1,160 @@
import IHatePDFsCore
import SwiftUI
@MainActor
final class CommentPopoverModel: ObservableObject {
let context: AnnotationEditorContext
@Published var text: String
@Published var author: String
private weak var appState: AppState?
private var didFinish = false
init(context: AnnotationEditorContext, appState: AppState) {
self.context = context
self.appState = appState
self.text = context.initialText
self.author = context.initialAuthor
}
func commit() {
guard !didFinish else { return }
didFinish = true
appState?.saveEditor(context, text: text, author: author)
}
func delete() {
guard !didFinish else { return }
didFinish = true
appState?.deleteAnnotations(in: context)
}
func updateDraft() {
guard !didFinish else { return }
appState?.updateEditorDraft(context, text: text, author: author)
}
}
struct CommentEditorView: View {
@ObservedObject var model: CommentPopoverModel
@Environment(\.colorScheme) private var colorScheme
@FocusState private var isCommentFocused: Bool
private let editorHorizontalInset: CGFloat = 9
private let editorVerticalInset: CGFloat = 7
var body: some View {
VStack(alignment: .leading, spacing: 10) {
header
commentField
footer
}
.padding(12)
.frame(width: 340)
.background(.regularMaterial)
.onAppear {
DispatchQueue.main.async {
isCommentFocused = true
}
}
.onChange(of: model.text) { _ in
model.updateDraft()
}
.onChange(of: model.author) { _ in
model.updateDraft()
}
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: symbolName)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.frame(width: 16)
Text(title)
.font(.headline)
.lineLimit(1)
Spacer()
Button {
model.commit()
} label: {
Label("Done", systemImage: "checkmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.return, modifiers: [.command])
.help("Done")
}
}
private var commentField: some View {
ZStack(alignment: .topLeading) {
TextEditor(text: $model.text)
.font(.body)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.scrollContentBackground(.hidden)
.focused($isCommentFocused)
.padding(.horizontal, editorHorizontalInset)
.padding(.vertical, editorVerticalInset)
if model.text.isEmpty {
Text("Add comment")
.font(.body)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.padding(.leading, editorHorizontalInset + 7)
.padding(.top, editorVerticalInset)
.allowsHitTesting(false)
}
}
.frame(minHeight: 118)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
private var footer: some View {
HStack(spacing: 8) {
TextField("Author", text: $model.author)
.textFieldStyle(.plain)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.padding(.horizontal, 7)
.frame(height: 28)
.background(InterfacePalette.fieldFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
.frame(width: 190)
Spacer()
if model.context.allowsDelete {
Button(role: .destructive) {
model.delete()
} label: {
Label("Delete Annotation", systemImage: "trash")
}
.labelStyle(.iconOnly)
.help("Delete Annotation")
}
}
}
private var title: String {
model.context.title.replacingOccurrences(of: " Comment", with: "")
}
private var symbolName: String {
guard let annotation = model.context.primaryAnnotation else {
return "text.bubble"
}
let kind = AcademicAnnotationKind(annotation: annotation)
return kind.symbolName
}
}

View File

@@ -0,0 +1,156 @@
import AppKit
import SwiftUI
@main
struct IHatePDFsApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(appState)
.onOpenURL { url in
appState.loadDocument(from: url)
}
}
.windowStyle(.titleBar)
.commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
appState.openDocument()
}
.keyboardShortcut("o")
Button("Save") {
appState.saveDocument()
}
.keyboardShortcut("s")
.disabled(appState.document == nil)
Button("Save As...") {
appState.saveDocumentAs()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(appState.document == nil)
Button("Share...") {
appState.shareDocument()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(appState.document == nil)
Divider()
Button("Close PDF") {
appState.closeDocument()
}
.keyboardShortcut("w")
.disabled(appState.document == nil)
}
CommandGroup(after: .textEditing) {
Button("Find in PDF") {
appState.showSearch()
}
.keyboardShortcut("f")
.disabled(appState.document == nil)
Button("Find Next") {
appState.nextSearchResult()
}
.keyboardShortcut("g")
.disabled(appState.searchResults.isEmpty)
Button("Find Previous") {
appState.previousSearchResult()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
.disabled(appState.searchResults.isEmpty)
}
CommandMenu("View") {
Button("Toggle Page Sidebar") {
appState.showLeftSidebar.toggle()
}
.keyboardShortcut("0", modifiers: [.command, .option])
.disabled(appState.document == nil)
Button("Toggle Comments Sidebar") {
appState.showCommentsSidebar.toggle()
}
.keyboardShortcut("1", modifiers: [.command, .option])
.disabled(appState.document == nil)
Divider()
Button("Zoom In") {
appState.zoomIn()
}
.keyboardShortcut("+")
.disabled(appState.document == nil)
Button("Zoom Out") {
appState.zoomOut()
}
.keyboardShortcut("-")
.disabled(appState.document == nil)
Button("Fit to Width") {
appState.fitWidth()
}
.keyboardShortcut("9", modifiers: [.command])
.disabled(appState.document == nil)
Button("Fit to Page") {
appState.fitPage()
}
.keyboardShortcut("8", modifiers: [.command])
.disabled(appState.document == nil)
Button("Two Pages Continuous") {
appState.twoPageContinuous()
}
.keyboardShortcut("7", modifiers: [.command])
.disabled(appState.document == nil)
}
CommandMenu("Annotate") {
Button("Highlight Selection") {
appState.addHighlight()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(appState.document == nil)
Button("Underline Selection") {
appState.addUnderline()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
.disabled(appState.document == nil)
Button("Comment on Selection") {
appState.addComment()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(appState.document == nil)
Button("Add Free Text") {
appState.addFreeText()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
.disabled(appState.document == nil)
}
CommandGroup(after: .windowArrangement) {
Button("Minimize") {
appState.minimizeWindow()
}
.keyboardShortcut("m", modifiers: [.command])
Button("Toggle Full Screen") {
appState.toggleFullScreen()
}
.keyboardShortcut("f", modifiers: [.command, .control])
}
}
}
}

View File

@@ -0,0 +1,53 @@
import AppKit
import SwiftUI
enum InterfacePalette {
static func primaryText(for scheme: ColorScheme) -> Color {
Color(nsColor: .labelColor).opacity(scheme == .dark ? 0.88 : 0.86)
}
static func secondaryText(for scheme: ColorScheme) -> Color {
Color(nsColor: .secondaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.88)
}
static func quietText(for scheme: ColorScheme) -> Color {
Color(nsColor: .tertiaryLabelColor).opacity(scheme == .dark ? 0.92 : 0.9)
}
static func actionText(for scheme: ColorScheme) -> Color {
Color(nsColor: .controlAccentColor).opacity(scheme == .dark ? 0.78 : 0.72)
}
static func subtleFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.045 : 0.026))
}
static func fieldFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.032))
}
static func hairline(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.12 : 0.095))
}
static func connector(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.14 : 0.11))
}
static func markerFill(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.055 : 0.035))
}
static func markerStroke(for scheme: ColorScheme) -> Color {
Color(nsColor: overlayBase(for: scheme).withAlphaComponent(scheme == .dark ? 0.16 : 0.13))
}
static func selectedRowFill(for scheme: ColorScheme) -> Color {
Color(nsColor: .unemphasizedSelectedContentBackgroundColor)
.opacity(scheme == .dark ? 0.38 : 0.48)
}
private static func overlayBase(for scheme: ColorScheme) -> NSColor {
scheme == .dark ? .white : .black
}
}

View File

@@ -0,0 +1,303 @@
import IHatePDFsCore
import SwiftUI
struct MainView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
GeometryReader { proxy in
content
.onAppear {
appState.updateWindowWidth(proxy.size.width)
}
.onChange(of: proxy.size.width) { width in
appState.updateWindowWidth(width)
}
}
.navigationTitle(appState.displayTitle)
.frame(minWidth: 820, minHeight: 620)
.toolbar {
ReaderToolbar()
}
}
private var content: some View {
VStack(spacing: 0) {
if appState.document == nil {
EmptyDocumentView()
} else {
HSplitView {
if appState.showLeftSidebar {
LeftSidebarView()
.frame(minWidth: 170, idealWidth: 210, maxWidth: 280)
}
PDFReaderView()
.frame(minWidth: 420)
if appState.showCommentsSidebar {
CommentsReviewSidebar()
.frame(minWidth: 260, idealWidth: 310, maxWidth: 400)
}
}
}
StatusBarView()
}
}
}
private struct PDFReaderView: View {
var body: some View {
PDFKitRepresentedView()
.background(Color(nsColor: .windowBackgroundColor))
}
}
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 16) {
Image(systemName: "doc.richtext")
.font(.system(size: 48, weight: .regular))
.foregroundStyle(.secondary)
Text("Open a PDF")
.font(.title2)
Text("Use standard PDF annotations for selected-text comments, highlights, underlines, and free text.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
Button {
appState.openDocument()
} label: {
Label("Open PDF", systemImage: "folder")
}
.keyboardShortcut("o")
.controlSize(.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
}
private struct StatusBarView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
HStack(spacing: 12) {
Text(appState.statusMessage)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if appState.document != nil {
Text("\(appState.annotations.count) annotations")
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.frame(height: 26)
.background(.bar)
}
}
private struct ReaderToolbar: ToolbarContent {
@EnvironmentObject private var appState: AppState
@FocusState private var searchFocused: Bool
var body: some ToolbarContent {
ToolbarItemGroup(placement: .navigation) {
Button {
appState.openDocument()
} label: {
Label("Open", systemImage: "folder")
}
.help("Open PDF")
Button {
appState.showLeftSidebar.toggle()
} label: {
Label("Pages", systemImage: "sidebar.left")
}
.disabled(appState.document == nil)
.help("Toggle Page Sidebar")
}
ToolbarItemGroup(placement: .principal) {
Button {
appState.goToPreviousPage()
} label: {
Label("Previous Page", systemImage: "chevron.up")
}
.disabled(appState.document == nil)
.help("Previous Page")
TextField("Page", text: $appState.pageText)
.textFieldStyle(.roundedBorder)
.frame(width: 52)
.onSubmit {
appState.goToPageFromField()
}
.disabled(appState.document == nil)
Text("/ \(max(appState.pageCount, 1))")
.foregroundStyle(.secondary)
Button {
appState.goToNextPage()
} label: {
Label("Next Page", systemImage: "chevron.down")
}
.disabled(appState.document == nil)
.help("Next Page")
}
ToolbarItemGroup {
if appState.showToolbarSearch {
TextField("Search", text: $appState.searchText)
.textFieldStyle(.roundedBorder)
.frame(width: 150)
.focused($searchFocused)
.onSubmit {
appState.runSearch()
}
.onAppear {
DispatchQueue.main.async {
searchFocused = true
}
}
.disabled(appState.document == nil)
Button {
appState.previousSearchResult()
} label: {
Label("Previous Match", systemImage: "chevron.left")
}
.disabled(appState.searchResults.isEmpty)
.help("Previous Search Match")
Button {
appState.nextSearchResult()
} label: {
Label("Next Match", systemImage: "chevron.right")
}
.disabled(appState.searchResults.isEmpty)
.help("Next Search Match")
Button {
appState.hideSearch()
} label: {
Label("Close Search", systemImage: "xmark")
}
.disabled(appState.document == nil)
.help("Close Search")
} else {
Button {
appState.showSearch()
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.disabled(appState.document == nil)
.help("Search")
}
}
ToolbarItemGroup {
Button {
appState.addHighlight()
} label: {
Label("Highlight", systemImage: "highlighter")
}
.disabled(appState.document == nil)
.help("Highlight Selection")
Button {
appState.addUnderline()
} label: {
Label("Underline", systemImage: "underline")
}
.disabled(appState.document == nil)
.help("Underline Selection")
Button {
appState.addComment()
} label: {
Label("Comment", systemImage: "text.bubble")
}
.accessibilityLabel("Comment on Selection")
.help("Comment on Selection")
.disabled(appState.document == nil)
}
ToolbarItemGroup {
Button {
appState.zoomOut()
} label: {
Label("Zoom Out", systemImage: "minus.magnifyingglass")
}
.disabled(appState.document == nil)
.help("Zoom Out")
Button {
appState.zoomIn()
} label: {
Label("Zoom In", systemImage: "plus.magnifyingglass")
}
.disabled(appState.document == nil)
.help("Zoom In")
Button {
appState.fitWidth()
} label: {
Label("Fit Width", systemImage: "arrow.left.and.right")
}
.disabled(appState.document == nil)
.help("Fit to Width")
Button {
appState.fitPage()
} label: {
Label("Fit Page", systemImage: "arrow.up.left.and.down.right.magnifyingglass")
}
.disabled(appState.document == nil)
.help("Fit Page")
}
ToolbarItemGroup {
Button {
appState.saveDocument()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.disabled(appState.document == nil)
.help("Save PDF")
Button {
appState.shareDocument()
} label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.disabled(appState.document == nil)
.help("Share PDF")
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
appState.showCommentsSidebar.toggle()
} label: {
Label("Comments Sidebar", systemImage: "sidebar.right")
}
.disabled(appState.document == nil)
.help(appState.showCommentsSidebar ? "Hide Comments Sidebar" : "Show Comments Sidebar")
.accessibilityLabel("Toggle Comments Sidebar")
}
}
}

View File

@@ -0,0 +1,365 @@
import AppKit
import IHatePDFsCore
import PDFKit
import SwiftUI
final class AcademicPDFView: PDFView {
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onSelectionComment: (() -> Void)?
var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)?
var placementTool: AnnotationPlacementTool? {
didSet {
guard oldValue != placementTool else { return }
window?.invalidateCursorRects(for: self)
}
}
override var acceptsFirstResponder: Bool { true }
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
if let page = page(for: point, nearest: false) {
let pagePoint = convert(point, to: page)
if placementTool != nil {
onPlacementClick?(page, pagePoint)
return
}
if let annotation = editableAnnotation(on: page, at: pagePoint) {
closeNativePopups(on: page)
onAnnotationClick?(annotation, page)
return
}
}
super.mouseDown(with: event)
window?.makeFirstResponder(self)
}
override func rightMouseDown(with event: NSEvent) {
guard hasCommentableSelection else {
super.rightMouseDown(with: event)
return
}
let menu = commentMenu(from: super.menu(for: event))
NSMenu.popUpContextMenu(menu, with: event, for: self)
}
override func keyDown(with event: NSEvent) {
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event)
return
}
switch event.keyCode {
case 123, 126:
onPreviousPageKey?()
case 124, 125:
onNextPageKey?()
default:
super.keyDown(with: event)
}
}
override func resetCursorRects() {
super.resetCursorRects()
if placementTool != nil {
addCursorRect(bounds, cursor: .crosshair)
}
}
override func menu(for event: NSEvent) -> NSMenu? {
commentMenu(from: super.menu(for: event))
}
private var hasCommentableSelection: Bool {
guard let selection = currentSelection,
!selection.pages.isEmpty,
selection.string?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
else {
return false
}
return true
}
private func commentMenu(from baseMenu: NSMenu?) -> NSMenu {
let menu = baseMenu ?? NSMenu()
guard hasCommentableSelection else { return menu }
guard !menu.items.contains(where: { $0.action == #selector(commentOnSelectionFromMenu(_:)) }) else {
return menu
}
let item = NSMenuItem(
title: "Comment",
action: #selector(commentOnSelectionFromMenu(_:)),
keyEquivalent: ""
)
item.target = self
menu.insertItem(item, at: 0)
if menu.items.count > 1 {
menu.insertItem(.separator(), at: 1)
}
return menu
}
@objc private func commentOnSelectionFromMenu(_ sender: Any?) {
onSelectionComment?()
}
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
if let direct = page.annotation(at: point),
let editable = editableParent(for: direct) {
return editable
}
for annotation in page.annotations.reversed() {
guard let editable = editableParent(for: annotation) else { continue }
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
return editable
}
if let popup = editable.popup,
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
return editable
}
if isTextMarkup(editable),
editable.bounds.insetBy(dx: -24, dy: -24).contains(point) {
return editable
}
}
return nil
}
private func editableParent(for annotation: PDFAnnotation) -> PDFAnnotation? {
let parent = AnnotationFactory.parentAnnotation(for: annotation)
return isEditableAcademicAnnotation(parent) ? parent : nil
}
private func isTextMarkup(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
}
private func closeNativePopups(on page: PDFPage) {
for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
annotation.isOpen = false
}
annotation.popup?.isOpen = false
}
annotationsChanged(on: page)
}
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|| AnnotationKeys.annotation(annotation, hasSubtype: .text)
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
|| AnnotationKeys.annotation(annotation, hasSubtype: .popup)
}
}
struct PDFKitRepresentedView: NSViewRepresentable {
@EnvironmentObject private var appState: AppState
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> AcademicPDFView {
let view = AcademicPDFView()
view.onAnnotationClick = { annotation, page in
Task { @MainActor in
appState.openAnnotationFromPDF(annotation, page: page)
}
}
view.onPlacementClick = { page, point in
Task { @MainActor in
appState.placePendingAnnotation(on: page, near: point)
}
}
view.onSelectionComment = {
Task { @MainActor in
appState.addComment()
}
}
view.onPreviousPageKey = {
Task { @MainActor in
appState.goToPreviousPage()
}
}
view.onNextPageKey = {
Task { @MainActor in
appState.goToNextPage()
}
}
appState.attachPDFView(view)
return view
}
func updateNSView(_ view: AcademicPDFView, context: Context) {
if view.document !== appState.document {
view.document = appState.document
}
view.placementTool = appState.placementTool
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
context.coordinator.sync(editor: appState.activeEditor, in: view, appState: appState)
}
@MainActor
final class Coordinator: NSObject, NSPopoverDelegate {
private var popover: NSPopover?
private var model: CommentPopoverModel?
private var editorID: UUID?
private var isClosing = false
private weak var appState: AppState?
func sync(
editor context: AnnotationEditorContext?,
in view: AcademicPDFView,
appState: AppState
) {
self.appState = appState
guard let context else {
if !isClosing {
dismissCurrent(commit: false)
}
return
}
if editorID == context.id, popover?.isShown == true {
return
}
dismissCurrent(commit: true)
show(context, in: view, appState: appState)
}
private func show(
_ context: AnnotationEditorContext,
in view: AcademicPDFView,
appState: AppState
) {
guard view.window != nil else { return }
let model = CommentPopoverModel(context: context, appState: appState)
let controller = NSHostingController(rootView: CommentEditorView(model: model))
let popover = NSPopover()
popover.behavior = .semitransient
popover.animates = true
popover.contentSize = NSSize(width: 340, height: 258)
popover.contentViewController = controller
popover.delegate = self
self.model = model
self.popover = popover
self.editorID = context.id
self.isClosing = false
let anchor = anchorRect(for: context, in: view)
popover.show(
relativeTo: anchor,
of: view,
preferredEdge: preferredEdge(for: anchor, in: view)
)
}
private func dismissCurrent(commit: Bool) {
guard let popover else {
cleanup()
return
}
if commit {
model?.commit()
}
if popover.isShown {
popover.performClose(nil)
} else {
cleanup()
}
}
func popoverWillClose(_ notification: Notification) {
isClosing = true
model?.commit()
}
func popoverDidClose(_ notification: Notification) {
let closedEditorID = editorID
let currentAppState = appState
cleanup()
if currentAppState?.activeEditor?.id == closedEditorID {
currentAppState?.activeEditor = nil
}
}
private func cleanup() {
popover?.delegate = nil
popover = nil
model = nil
editorID = nil
isClosing = false
}
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
guard let annotation = context.primaryAnnotation,
let page = context.primaryPage ?? annotation.page
else {
return centeredAnchor(in: view)
}
let rect = view.convert(annotation.bounds, from: page).insetBy(dx: -4, dy: -4)
guard rect.width.isFinite,
rect.height.isFinite,
rect.width > 0,
rect.height > 0
else {
return centeredAnchor(in: view)
}
return rect.intersection(view.bounds).isNull ? centeredAnchor(in: view) : rect
}
private func centeredAnchor(in view: AcademicPDFView) -> NSRect {
NSRect(x: view.bounds.midX - 1, y: view.bounds.midY - 1, width: 2, height: 2)
}
private func preferredEdge(for anchor: NSRect, in view: AcademicPDFView) -> NSRectEdge {
anchor.midX > view.bounds.midX ? .minX : .maxX
}
}
}
struct PDFThumbnailRepresentedView: NSViewRepresentable {
@EnvironmentObject private var appState: AppState
func makeNSView(context: Context) -> PDFThumbnailView {
let view = PDFThumbnailView()
view.thumbnailSize = CGSize(width: 88, height: 116)
view.backgroundColor = .clear
view.maximumNumberOfColumns = 1
view.labelFont = NSFont.systemFont(ofSize: 11)
view.allowsDragging = false
view.pdfView = appState.pdfView
return view
}
func updateNSView(_ view: PDFThumbnailView, context: Context) {
view.pdfView = appState.pdfView
}
}

View File

@@ -0,0 +1,30 @@
enum ReviewState {
static let allStatuses = "All Statuses"
static let reviewed = "Reviewed"
static let notReviewed = "Not reviewed"
static func isReviewed(_ status: String) -> Bool {
status.localizedCaseInsensitiveCompare("Marked") == .orderedSame
|| status.localizedCaseInsensitiveCompare(reviewed) == .orderedSame
}
static func label(for status: String) -> String {
if isReviewed(status) { return reviewed }
return status.localizedCaseInsensitiveCompare("Unmarked") == .orderedSame
? notReviewed
: status
}
static func matches(_ status: String, filter: String) -> Bool {
switch filter {
case allStatuses:
return true
case reviewed:
return isReviewed(status)
case notReviewed:
return !isReviewed(status)
default:
return status == filter || label(for: status) == filter
}
}
}

View File

@@ -0,0 +1,593 @@
import IHatePDFsCore
import SwiftUI
struct LeftSidebarView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
VStack(spacing: 0) {
Picker("Sidebar", selection: $appState.sidebarMode) {
Text("Pages").tag(SidebarMode.pages)
Text("Annotations").tag(SidebarMode.annotations)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(8)
Divider()
switch appState.sidebarMode {
case .pages:
PDFThumbnailRepresentedView()
.padding(.vertical, 6)
case .annotations:
AnnotationListView()
}
}
.background(.bar)
}
}
private struct AnnotationListView: View {
@EnvironmentObject private var appState: AppState
var body: some View {
List(appState.annotations, selection: $appState.selectedAnnotationID) { item in
Button {
appState.select(item)
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.kind.symbolName)
.frame(width: 18)
.foregroundStyle(iconColor(for: item.kind))
.help(item.kind.displayName)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(item.kind.displayName)
.font(.caption.weight(.semibold))
Spacer()
Text("p. \(item.pageLabel)")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(item.firstLine)
.font(.caption)
.foregroundStyle(item.hasComment ? .primary : .secondary)
.lineLimit(2)
Text(item.author)
.font(.caption2)
.foregroundStyle(.secondary)
Text(dateString(item.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
.buttonStyle(.plain)
}
.listStyle(.sidebar)
}
private func iconColor(for kind: AcademicAnnotationKind) -> Color {
switch kind {
case .comment, .highlight, .note:
return Color(nsColor: .secondaryLabelColor)
case .underline, .reply:
return Color(nsColor: .tertiaryLabelColor)
case .freeText:
return Color(nsColor: .labelColor)
case .other:
return Color(nsColor: .tertiaryLabelColor)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
struct CommentsReviewSidebar: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@State private var showsSearch = false
@State private var showsFilters = false
@State private var showsAdvancedFilters = false
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
return grouped
.map { (pageIndex: $0.key, items: $0.value) }
.sorted { $0.pageIndex < $1.pageIndex }
}
var body: some View {
VStack(spacing: 0) {
header
Divider()
quickComment
if showsSearch || showsFilters {
Divider()
filters
}
Divider()
commentList
}
.background(.bar)
}
private var header: some View {
HStack(spacing: 9) {
Image(systemName: "text.bubble.fill")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Comments")
Text("Comments")
.font(.headline)
.lineLimit(1)
Text("\(appState.annotations.count)")
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
showsSearch.toggle()
} label: {
Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass")
}
.labelStyle(.iconOnly)
.help("Search Comments")
Button {
showsFilters.toggle()
} label: {
Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
}
.labelStyle(.iconOnly)
.help("Filter Comments")
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
}
private var quickComment: some View {
Button {
appState.addComment()
} label: {
HStack(spacing: 8) {
Image(systemName: "text.bubble")
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Comment on selected text")
Text("On selected text")
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
}
.font(.callout)
.padding(.horizontal, 10)
.frame(height: 36)
.background(InterfacePalette.subtleFill(for: colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.help("Select text, then add a comment")
}
private var filters: some View {
VStack(spacing: 8) {
if showsSearch {
TextField("Search comments", text: $appState.commentSearchText)
.textFieldStyle(.roundedBorder)
}
if showsFilters {
Picker("Comment filter", selection: $appState.commentFilter) {
ForEach(CommentFilter.allCases) { filter in
Text(filter.title).tag(filter)
}
}
.pickerStyle(.segmented)
.labelsHidden()
DisclosureGroup("More Filters", isExpanded: $showsAdvancedFilters) {
VStack(spacing: 8) {
Picker("Type", selection: Binding(
get: { appState.selectedKindFilter },
set: { appState.selectedKindFilter = $0 }
)) {
Text("All Types").tag(Optional<AcademicAnnotationKind>.none)
ForEach(AcademicAnnotationKind.allCases.filter { $0 != .other }) { kind in
Text(kind.displayName).tag(Optional(kind))
}
}
Picker("Author", selection: $appState.selectedAuthorFilter) {
ForEach(appState.authors, id: \.self) { author in
Text(author).tag(author)
}
}
Picker("Status", selection: $appState.selectedStatusFilter) {
ForEach(appState.statuses, id: \.self) { status in
Text(status).tag(status)
}
}
}
.labelsHidden()
.padding(.top, 4)
}
.font(.caption)
}
}
.padding(10)
}
private var commentList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(groupedComments, id: \.pageIndex) { group in
PageCommentGroup(
pageIndex: group.pageIndex,
items: group.items,
repliesByParent: appState.repliesByParent,
showsPageHeader: appState.pageCount > 1
)
}
}
.padding(.vertical, 4)
}
}
}
private struct PageCommentGroup: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let pageIndex: Int
let items: [AnnotationSnapshot]
let repliesByParent: [String: [AnnotationSnapshot]]
let showsPageHeader: Bool
private var isCollapsed: Bool {
showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if showsPageHeader {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
}
} label: {
HStack {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2.weight(.semibold))
.frame(width: 12)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Text("Page \(pageIndex + 1)")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
Spacer()
Text("\(items.count)")
.font(.caption.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.padding(.horizontal, 10)
.padding(.top, 7)
.padding(.bottom, 5)
}
.buttonStyle(.plain)
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
}
if !isCollapsed {
ForEach(items) { item in
let replies = repliesByParent[item.id] ?? []
CommentRow(item: item, replies: replies)
.id(([item.sidebarRenderID] + replies.map(\.sidebarRenderID)).joined(separator: "|"))
}
}
}
}
}
private struct CommentRow: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
let replies: [AnnotationSnapshot]
var body: some View {
ZStack(alignment: .topLeading) {
if !replies.isEmpty {
Rectangle()
.fill(InterfacePalette.connector(for: colorScheme))
.frame(width: 1)
.padding(.leading, 14)
.padding(.top, 30)
.padding(.bottom, 20)
}
VStack(alignment: .leading, spacing: 0) {
parentComment
ForEach(replies) { reply in
ReplyRow(item: reply)
.id(reply.sidebarRenderID)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.overlay(alignment: .bottom) {
Rectangle()
.fill(InterfacePalette.hairline(for: colorScheme))
.frame(height: 1)
}
}
private var parentComment: some View {
HStack(alignment: .top, spacing: 9) {
CommentMarker(symbolName: item.kind.symbolName, size: 28, font: .caption)
.padding(.top, 1)
.help(item.kind == .reply ? "Reply" : "Comment Thread")
VStack(alignment: .leading, spacing: 6) {
Button {
appState.select(item)
} label: {
commentSummary
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.accessibilityAction {
appState.select(item)
}
metadataRow(for: item)
HStack(spacing: 12) {
Button("Edit") {
appState.edit(item)
}
Button("Reply") {
appState.addReply(to: item)
}
Button("Delete", role: .destructive) {
appState.delete(item)
}
}
.font(.caption.weight(.medium))
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
}
}
.padding(.vertical, 2)
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
.onHover { isHovered in
appState.setCommentHover(item, isHovered: isHovered)
}
}
private var commentSummary: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(item.author)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
Spacer()
Text(dateString(item.modifiedAt ?? item.createdAt))
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
}
Text(item.contents.isEmpty ? "No text" : item.contents)
.font(.callout)
.foregroundStyle(item.contents.isEmpty ? InterfacePalette.quietText(for: colorScheme) : InterfacePalette.primaryText(for: colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
}
private func metadataRow(for item: AnnotationSnapshot) -> some View {
HStack {
ReviewStatusChip(item: item)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}
private extension AnnotationSnapshot {
var sidebarRenderID: String {
[
id,
author,
contents,
status,
String(modifiedAt?.timeIntervalSinceReferenceDate ?? 0),
String(describing: bounds.minX),
String(describing: bounds.minY)
].joined(separator: "|")
}
}
private struct CommentMarker: View {
@Environment(\.colorScheme) private var colorScheme
let symbolName: String
let size: CGFloat
let font: Font
var body: some View {
ZStack {
Circle()
.fill(InterfacePalette.markerFill(for: colorScheme))
Circle()
.stroke(InterfacePalette.markerStroke(for: colorScheme), lineWidth: 0.75)
Image(systemName: symbolName)
.font(font)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.frame(width: size, height: size)
.background(.bar)
.clipShape(Circle())
}
}
private struct ReviewStatusChip: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
var body: some View {
Button {
appState.toggleReviewed(item)
} label: {
HStack(spacing: 4) {
if isReviewed {
Image(systemName: "checkmark")
.font(.caption2.weight(.bold))
}
Text(label)
.font(.caption2)
}
.foregroundStyle(foreground)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(background)
.clipShape(Capsule())
}
.buttonStyle(.plain)
.help(isReviewed ? "Mark as not reviewed" : "Mark as reviewed")
}
private var isReviewed: Bool {
ReviewState.isReviewed(item.status)
}
private var label: String {
ReviewState.label(for: item.status)
}
private var foreground: Color {
isReviewed
? InterfacePalette.actionText(for: colorScheme)
: InterfacePalette.quietText(for: colorScheme)
}
private var background: Color {
if isReviewed {
return Color(nsColor: .controlAccentColor).opacity(colorScheme == .dark ? 0.16 : 0.11)
}
return InterfacePalette.subtleFill(for: colorScheme)
}
}
private struct ReplyRow: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let item: AnnotationSnapshot
var body: some View {
HStack(alignment: .top, spacing: 8) {
CommentMarker(symbolName: "text.bubble", size: 22, font: .caption2)
.frame(width: 28, alignment: .center)
.padding(.top, 7)
.help("Reply")
VStack(alignment: .leading, spacing: 4) {
Button {
appState.select(item)
} label: {
replySummary
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.accessibilityAddTraits(.isButton)
.accessibilityAction {
appState.select(item)
}
replyMetadataRow
HStack(spacing: 12) {
Button("Edit") {
appState.edit(item)
}
Button("Delete", role: .destructive) {
appState.delete(item)
}
}
.font(.caption.weight(.medium))
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.actionText(for: colorScheme))
}
}
.padding(.top, 8)
.padding(.bottom, 2)
.background(item.id == appState.selectedAnnotationID ? InterfacePalette.selectedRowFill(for: colorScheme) : Color.clear)
.onHover { isHovered in
appState.setCommentHover(item, isHovered: isHovered)
}
}
private var replySummary: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(item.author)
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
Spacer()
Text(dateString(item.modifiedAt ?? item.createdAt))
.font(.caption2)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
}
Text(item.contents.isEmpty ? "No reply text" : item.contents)
.font(.caption)
.foregroundStyle(item.contents.isEmpty ? InterfacePalette.quietText(for: colorScheme) : InterfacePalette.primaryText(for: colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
}
private var replyMetadataRow: some View {
HStack {
ReviewStatusChip(item: item)
}
}
private func dateString(_ date: Date?) -> String {
guard let date else { return "No date" }
return date.formatted(date: .abbreviated, time: .shortened)
}
}

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

View File

@@ -0,0 +1,313 @@
import XCTest
import AppKit
import PDFKit
@testable import IHatePDFsCore
final class AnnotationFactoryTests: XCTestCase {
func testHighlightSelectionRoundTripsThroughPDFSave() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 0, length: 29)))
let insertions = AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "Use this passage in lecture.",
author: "Professor"
)
XCTAssertEqual(insertions.count, 1)
for insertion in insertions {
insertion.page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
insertion.page.addAnnotation(popup)
}
}
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("pdf")
defer { try? FileManager.default.removeItem(at: outputURL) }
XCTAssertTrue(document.write(to: outputURL))
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
let reopenedPage = try XCTUnwrap(reopened.page(at: 0))
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
XCTAssertTrue(
reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .highlight)
&& $0.contents == "Use this passage in lecture."
}
)
}
func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
let insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .comment,
comment: "Explain this phrase.",
author: "Professor"
).first
)
page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
page.addAnnotation(popup)
}
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
AnnotationKeys.annotation($0, hasSubtype: .highlight)
&& $0.contents == "Explain this phrase."
})
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
XCTAssertEqual(
reopenedComment.value(forAnnotationKey: AnnotationKeys.appKind) as? String,
AnnotationKeys.appKindComment
)
}
func testSelectionBoundCommentCreatedEmptyGetsPopupWhenTextIsSaved() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
let insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .comment,
comment: "",
author: "Professor"
).first
)
XCTAssertNil(insertion.popup)
page.addAnnotation(insertion.annotation)
let popup = try XCTUnwrap(AnnotationFactory.updateComment(
for: insertion.annotation,
on: page,
text: "Explain this phrase.",
author: "Professor"
))
page.addAnnotation(popup)
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
let reopenedComment = try XCTUnwrap(reopenedPage.annotations.first {
AnnotationKeys.annotation($0, hasSubtype: .highlight)
&& $0.contents == "Explain this phrase."
})
XCTAssertEqual(AcademicAnnotationKind(annotation: reopenedComment), .comment)
XCTAssertTrue(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .popup)
&& $0.contents == "Explain this phrase."
})
}
func testAddingAnnotationPreservesPriorAnnotation() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
let prior = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 420, y: 700),
comment: "Existing note from another reader.",
author: "Colleague"
)
page.addAnnotation(prior.annotation)
if let popup = prior.popup {
page.addAnnotation(popup)
}
let selection = try XCTUnwrap(page.selection(for: NSRange(location: 8, length: 10)))
let highlight = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "New professor comment.",
author: "Professor"
).first
)
page.addAnnotation(highlight.annotation)
if let popup = highlight.popup {
page.addAnnotation(popup)
}
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
XCTAssertEqual(reopenedPage.string, "This is selectable academic text.")
XCTAssertTrue(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .text)
&& $0.contents == "Existing note from another reader."
})
XCTAssertTrue(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .highlight)
&& $0.contents == "New professor comment."
})
}
func testScannedImagePDFCanReceiveStandardTextAnnotation() throws {
let document = try makeImageOnlyDocument()
let page = try XCTUnwrap(document.page(at: 0))
let insertion = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 300, y: 500),
comment: "Comment on scanned reading.",
author: "Professor"
)
page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
page.addAnnotation(popup)
}
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
XCTAssertNil(reopenedPage.string)
XCTAssertTrue(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .text)
&& $0.contents == "Comment on scanned reading."
})
}
func testFiveHundredPagePDFCanBeSavedWithAnnotation() throws {
let document = PDFDocument()
for index in 0..<501 {
document.insert(PDFPage(), at: index)
}
XCTAssertEqual(document.pageCount, 501)
let page = try XCTUnwrap(document.page(at: 500))
let insertion = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 120, y: 120),
comment: "End of long reading.",
author: "Professor"
)
page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
page.addAnnotation(popup)
}
let reopened = try saveAndReopen(document)
XCTAssertEqual(reopened.pageCount, 501)
let reopenedPage = try XCTUnwrap(reopened.page(at: 500))
XCTAssertTrue(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .text)
&& $0.contents == "End of long reading."
})
}
func testTextAnnotationUsesStandardKeys() throws {
let page = PDFPage()
let insertion = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 100, y: 100),
comment: "Discuss this claim in class.",
author: "Professor"
)
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .text))
XCTAssertEqual(insertion.annotation.contents, "Discuss this claim in class.")
XCTAssertEqual(insertion.annotation.userName, "Professor")
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: .name))
XCTAssertNotNil(insertion.annotation.value(forAnnotationKey: AnnotationKeys.creationDate))
XCTAssertEqual(insertion.annotation.value(forAnnotationKey: AnnotationKeys.state) as? String, "Unmarked")
XCTAssertNotNil(insertion.popup)
XCTAssertTrue(insertion.popup.map { AnnotationKeys.annotation($0, hasSubtype: .popup) } ?? false)
}
func testReplyStoresVisibleTextAnnotationWithBestEffortParentID() throws {
let page = PDFPage()
let parent = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 100, y: 100),
comment: "Parent",
author: "Professor"
).annotation
let reply = AnnotationFactory.replyInsertion(
to: parent,
on: page,
comment: "Reply",
author: "Reader",
parentID: "parent-id"
)
XCTAssertTrue(AnnotationKeys.annotation(reply.annotation, hasSubtype: .text))
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String, "parent-id")
XCTAssertEqual(reply.annotation.value(forAnnotationKey: AnnotationKeys.replyType) as? String, "R")
}
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
let page = PDFPage()
let insertion = AnnotationFactory.freeTextInsertion(
on: page,
near: CGPoint(x: 200, y: 200),
text: "Important definition",
author: "Professor"
)
XCTAssertTrue(AnnotationKeys.annotation(insertion.annotation, hasSubtype: .freeText))
XCTAssertEqual(insertion.annotation.contents, "Important definition")
XCTAssertNil(insertion.popup)
}
private func makeSelectableTextDocument() throws -> PDFDocument {
let data = NSMutableData()
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792)
let consumer = try XCTUnwrap(CGDataConsumer(data: data))
let context = try XCTUnwrap(CGContext(consumer: consumer, mediaBox: &mediaBox, nil))
context.beginPDFPage(nil)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: false)
let text = NSAttributedString(
string: "This is selectable academic text.",
attributes: [.font: NSFont.systemFont(ofSize: 18)]
)
text.draw(at: CGPoint(x: 72, y: 700))
NSGraphicsContext.restoreGraphicsState()
context.endPDFPage()
context.closePDF()
return try XCTUnwrap(PDFDocument(data: data as Data))
}
private func makeImageOnlyDocument() throws -> PDFDocument {
let image = NSImage(size: CGSize(width: 612, height: 792))
image.lockFocus()
NSColor.white.setFill()
NSRect(x: 0, y: 0, width: 612, height: 792).fill()
NSColor.darkGray.setStroke()
let path = NSBezierPath(rect: NSRect(x: 72, y: 580, width: 468, height: 80))
path.lineWidth = 2
path.stroke()
image.unlockFocus()
let document = PDFDocument()
document.insert(try XCTUnwrap(PDFPage(image: image)), at: 0)
return document
}
private func saveAndReopen(_ document: PDFDocument) throws -> PDFDocument {
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("pdf")
XCTAssertTrue(document.write(to: outputURL))
let reopened = try XCTUnwrap(PDFDocument(url: outputURL))
try? FileManager.default.removeItem(at: outputURL)
return reopened
}
}
private extension Optional {
func unwrap(
file: StaticString = #filePath,
line: UInt = #line
) throws -> Wrapped {
try XCTUnwrap(self, file: file, line: line)
}
}

38
docs/DESIGN_REVIEW.md Normal file
View File

@@ -0,0 +1,38 @@
# macOS Design Review
This review checks the current app against Apple's Human Interface Guidelines before a public release.
References:
- Apple HIG overview: https://developer.apple.com/design/human-interface-guidelines
- Designing for macOS: https://developer.apple.com/design/human-interface-guidelines/designing-for-macos
- Toolbars: https://developer.apple.com/design/human-interface-guidelines/toolbars
- Sidebars: https://developer.apple.com/design/human-interface-guidelines/sidebars
- Menus and the menu bar: https://developer.apple.com/design/human-interface-guidelines/menus and https://developer.apple.com/design/human-interface-guidelines/the-menu-bar
- Color: https://developer.apple.com/design/human-interface-guidelines/color
- Typography: https://developer.apple.com/design/human-interface-guidelines/typography
## Result
Status: Pass for the current version 1 implementation direction, with manual visual QA still required on physical Intel and Apple Silicon Macs before a tagged release.
## Checks
- Platform fit: The app is macOS-only, targets macOS 13 or newer, uses SwiftUI/AppKit/PDFKit, and ships as a normal `.app` bundle inside a `.dmg`.
- Window and toolbar: Primary document controls live in the titlebar toolbar, grouped by opening/sharing, navigation, zoom, annotation, search, and saving.
- Menus and shortcuts: File, View, and Annotate commands are available through native command menus with standard keyboard shortcuts where appropriate.
- Sidebars: Page thumbnails, annotation list, and comments review are optional sidebars. The default open-PDF state is single-pane reading, and sidebars open only when requested or restored from user preference.
- Comments review: The comments sidebar uses a compact review-stream layout with a visible total count, add-comment affordance, collapsible page groups, hidden search/filter controls, and connected reply threads.
- Color and appearance: The UI uses system colors and materials, so light mode, dark mode, and automatic appearance inherit from macOS.
- Typography: Text uses system fonts and native SwiftUI controls; no custom brand typography is used in the reading interface.
- Reading focus: The PDF view remains the central, quiet surface; controls are compact and document-oriented.
- Accessibility basics: Native controls supply focus states and keyboard access; colors use system palettes with restrained highlight/note colors.
- Academic workflow: The open, select, highlight, comment, continue reading, save, and share path is available without accounts, sync, projects, or conversion.
## Release QA Still Required
- Run the app on both Apple Silicon and Intel hardware.
- Verify contrast and focus states in light and dark mode.
- Verify toolbar and sidebar behavior at narrow and wide window sizes.
- Verify keyboard-only operation for opening, searching, navigating, annotating, saving, and reviewing comments.
- Verify VoiceOver labels for toolbar buttons and sidebar controls.

72
docs/QA.md Normal file
View File

@@ -0,0 +1,72 @@
# Manual PDF Interoperability QA
Run this checklist before tagging a public release.
## Test Files
Use at least:
- A selectable-text journal article.
- A scanned/image-only course reading.
- A long PDF near or above 500 pages.
- A PDF that already contains annotations from Preview or Acrobat.
- A PDF with bookmarks or an outline.
## App Workflow
1. Open the PDF in I Hate PDFs.
2. Select text and add a highlight.
3. Add a comment to the highlight.
4. Add an underline with a comment.
5. Select text, right-click, and add a comment from the context menu.
6. Add free text directly on the page.
7. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
8. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state.
9. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply.
10. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation.
11. Verify highlights, comment markers, reply icons, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document.
12. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained.
13. Save As an annotated copy.
14. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain.
15. Save over a disposable original and verify the overwrite warning appears.
## External Readers
Before manual reader checks, run the automated PDF structure checks:
```sh
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
```
These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Popup`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
Open the saved annotated copy in:
- macOS Preview
- Adobe Acrobat Reader
- Safari, Chrome, and Firefox PDF viewers where annotations are supported
Verify:
- Highlighted text remains highlighted.
- Underlined text remains underlined.
- Selected-text comments remain attached to the referenced text.
- Highlight and selected-text comments can be opened.
- Free text remains visible on the page.
- Existing text, images, layout, bookmarks, and prior annotations remain intact.
## Visual QA Screenshots
Capture current screenshots in `docs/screenshots` for:
- `no-document.png`
- `default-reading.png`
- `highlight-comment-popover.png`
- `selected-text-comment-popover.png`
- `comments-sidebar.png`, including at least one reply thread with a visible connector line
- `dark-mode-reading.png`
## Known Version 1 Limitation
PDFKit rejects object-valued `/IRT` reply relationships through its public API. Replies created in this app are saved as visible standard `/Text` annotations, while full cross-reader reply-thread presentation must be verified and improved with a lower-level PDF writer if needed.

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="920" viewBox="0 0 760 920" role="img" aria-label="I Hate PDFs comments sidebar screenshot">
<rect width="760" height="920" fill="#f5f5f7"/>
<rect x="80" y="60" width="600" height="800" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
<text x="118" y="118" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="26" fill="#202124">Comments</text>
<text x="118" y="146" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" fill="#606166">4 total</text>
<rect x="118" y="176" width="524" height="34" rx="7" fill="#ffffff" stroke="#d1d1d6"/>
<text x="136" y="198" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#7a7a80">Search comments</text>
<rect x="118" y="242" width="524" height="1" fill="#d8d8dd"/>
<text x="118" y="282" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 2</text>
<rect x="118" y="306" width="524" height="154" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="140" y="344" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Highlight</text>
<text x="140" y="374" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
<text x="140" y="406" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">This is the core passage for Friday's discussion.</text>
<rect x="150" y="480" width="492" height="86" rx="8" fill="#f1f8f8" stroke="#c8dddd"/>
<text x="172" y="516" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" fill="#202124">Reply</text>
<text x="172" y="546" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Add this to the lecture notes.</text>
<text x="118" y="624" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 5</text>
<rect x="118" y="648" width="524" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="140" y="686" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Note</text>
<text x="140" y="716" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
<text x="140" y="748" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Compare this footnote with the appendix.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="920" viewBox="0 0 1440 920" role="img" aria-label="I Hate PDFs main window screenshot">
<rect width="1440" height="920" fill="#f5f5f7"/>
<rect x="40" y="40" width="1360" height="840" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
<rect x="40" y="40" width="1360" height="52" rx="10" fill="#ececf0"/>
<circle cx="68" cy="66" r="7" fill="#ff5f57"/>
<circle cx="92" cy="66" r="7" fill="#febc2e"/>
<circle cx="116" cy="66" r="7" fill="#28c840"/>
<rect x="64" y="116" width="210" height="724" fill="#f1f1f4"/>
<rect x="308" y="132" width="650" height="692" fill="#ffffff" stroke="#d1d1d6"/>
<rect x="358" y="190" width="550" height="14" fill="#d8d8dd"/>
<rect x="358" y="228" width="480" height="14" fill="#d8d8dd"/>
<rect x="358" y="266" width="520" height="14" fill="#f7dc55" opacity="0.75"/>
<rect x="358" y="304" width="470" height="14" fill="#d8d8dd"/>
<rect x="358" y="342" width="510" height="14" fill="#d8d8dd"/>
<rect x="996" y="116" width="364" height="724" fill="#f7f7f9"/>
<text x="1020" y="158" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" fill="#202124">Comments</text>
<rect x="1020" y="190" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="1040" y="226" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Highlight</text>
<text x="1040" y="254" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Discuss this claim in class.</text>
<rect x="1020" y="326" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="1040" y="362" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Note</text>
<text x="1040" y="390" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Connect to the seminar reading.</text>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

447
goal.md Normal file
View File

@@ -0,0 +1,447 @@
# Project Goal
Build an open-source macOS desktop application for professors to read, annotate, and share academic PDFs using standard PDF annotations that remain visible and interactive when opened by other people in common PDF readers.
The application must let a professor open a local PDF, add comments attached to selected text, highlights, and underlines, save those annotations directly into the PDF file, and share the resulting PDF with students, colleagues, or publishers. The recipient must be able to see the annotations and open the associated comment popups without needing this application.
## Core Requirement
Annotations must be written as standards-compliant PDF annotations, not stored in a separate database, sidecar file, hidden metadata format, or app-specific layer.
A PDF annotated in this app must preserve its comments when opened in:
- macOS Preview
- Adobe Acrobat Reader
- Common browser PDF viewers where supported
## Primary Users
The primary user is a professor reading academic PDFs such as journal articles, book chapters, working papers, syllabi, dissertations, and scanned course readings.
The professor needs to:
- Read PDFs comfortably on macOS
- Highlight passages
- Attach explanatory comments to highlighted text
- Attach comments to selected text from the toolbar, comments sidebar, or right-click menu
- Save and share the annotated PDF
- Trust that another person can open the file and see the comments
## Platform
The first version must run on macOS only.
Minimum supported version should be explicitly chosen before development. Recommended target:
- macOS 13 Ventura or newer
- Apple Silicon and Intel Macs
- Distributed as a downloadable `.dmg`
- Open-source repository with build instructions
## License
The project should use a permissive open-source license unless there is a specific reason not to. Recommended:
- MIT License or Apache 2.0
## Required Features
## Design and macOS User Experience
The app must feel like a polished, modern macOS application, not a generic cross-platform document viewer.
The user interface must follow Apple's Human Interface Guidelines for macOS where practical, including native-feeling window behavior, toolbar placement, sidebar behavior, menus, keyboard shortcuts, typography, spacing, focus states, and system color usage.
The app must be aesthetically restrained, calm, and pleasant for long academic reading sessions. The visual design should prioritize readability, focus, and low cognitive load over decorative styling.
The app must support:
- Light mode
- Dark mode
- Automatic appearance matching the user's macOS system setting
- Native macOS toolbar behavior
- Native macOS menu bar commands
- Native macOS keyboard shortcuts where applicable
- Smooth scrolling and zooming
- Crisp rendering on Retina displays
- Clear hover, selected, active, disabled, and focus states
- Accessible contrast for text, icons, controls, highlights, and annotation markers
- Subdued annotation colors that remain visible without looking like high-contrast demo markup
The app should use system typography, system colors, native controls, and familiar macOS interaction patterns unless there is a specific reason not to.
The PDF reading area must be visually quiet. Controls should not distract from the document. Toolbars, sidebars, popovers, and annotation panels must be useful, compact, and consistent with macOS conventions.
Annotation markers, comment icons, reply icons, and sidebar selection states must match the restrained macOS visual theme in both light mode and dark mode. They should be legible, but not harsh, neon, or visually louder than the PDF text.
The app must avoid:
- Cluttered toolbars
- Unnecessary branding inside the reading interface
- Bright or harsh color palettes
- Oversaturated comment/reply colors that clash with native macOS light or dark appearance
- Web-app-style controls that feel out of place on macOS
- Decorative gradients, oversized cards, or marketing-style layouts
- Custom UI patterns that conflict with standard macOS behavior
- Animations that slow down reading, annotation, or navigation
The app should feel appropriate for professors, researchers, graduate students, and academic professionals who spend long periods reading dense documents.
## Current UI Audit and Revised Design Direction
The current implementation is functionally promising but visually and interaction-wise off target. The screenshot evidence shows the app behaving more like a debug/admin interface around a PDF than a polished academic reading tool. The core PDF interoperability work remains valuable, but the user experience must be redesigned before the app can be considered complete.
### Current UI Problems
The current UI is wrong in these specific ways:
- Too much chrome is open by default. The page thumbnail sidebar and comments sidebar both appear immediately, leaving the PDF squeezed between panels.
- The default view does not prioritize reading. The PDF should dominate the window; sidebars should support the reading task, not frame the entire experience at all times.
- The comments sidebar is too dashboard-like. Search fields, segmented filters, type pickers, author pickers, status pickers, refresh buttons, and grouped metadata are all visible before the user asks for a review workflow.
- The app exposes a refresh button in the comments panel, which makes comments feel stale or manually synchronized. Comments must update live when annotations are created, edited, deleted, or selected.
- The comment creation flow feels like filling out a form. Adding a comment to selected text or a highlight should feel anchored, immediate, lightweight, and dismissible.
- It is unclear where to type a comment after selecting text. The app needs an obvious anchored comment popover, not a separate form-like editor whose relationship to the highlighted text is ambiguous.
- The highlight/comment workflow is too indirect. A professor should be able to select text, click highlight, immediately type a comment in context, press Command-Return or click away, and continue reading.
- The comments sidebar does not visibly update as part of the annotation action. A newly created or edited comment should appear immediately without requiring refresh.
- The PDF margins and surrounding gray space feel accidental and oversized. Page spacing should be tuned for reading: enough separation to orient the user, but not large dead zones.
- Toolbar controls are too dense and visually equal. Primary actions, reading controls, annotation tools, search, and save are competing for attention.
- Sidebars feel heavy. They use too much width and visual weight for routine reading.
- The right comments sidebar is open even when there is only one comment or no active review task.
- The left sidebar shows large empty vertical regions and thumbnail spacing that make the app feel unfinished.
- The annotation list and comments review views duplicate concepts without clear mode distinction.
- The selected text context menu can obscure the annotation workflow; the app should make its own annotation affordance more obvious than the system text context menu.
- The visible layout does not feel calm enough for long academic reading sessions.
### Revised Product Design Principle
The app is primarily a reading surface, not an annotation database.
The default experience after opening a PDF must be:
- A large, centered, comfortable PDF reading area.
- No right comments sidebar by default.
- No left thumbnail sidebar by default unless the user explicitly opens it or the window is wide enough and the user has previously enabled it.
- A compact native toolbar with only the most important controls visible.
- Annotation tools that are obvious but not visually dominant.
- Icon-only controls should explain their action on hover with native macOS help/tooltips.
- Toolbar icons should be visually distinct without stacked custom arrows or ambiguous overlays.
- A comments review panel that appears only when requested, when clicking a comments button, or when entering review mode.
- Automatic live updates everywhere; no manual refresh button for comments.
### Target First-Run and Open-PDF Layout
When no PDF is open:
- Show a quiet native empty state with one primary "Open PDF" action.
- Do not show disabled annotation controls as a dominant visual element.
- Keep the window simple and restrained.
When a PDF is open:
- The PDF occupies the center and most of the window.
- The default layout is single-pane reading mode.
- The toolbar contains compact groups:
- Open/save/share
- page navigation
- zoom/fit
- annotation tools
- search
- sidebar toggles
- Two-page continuous view should stay available from the View menu and keyboard shortcut, not as a persistent toolbar icon.
- The thumbnail sidebar is hidden by default and opens with a sidebar button or keyboard shortcut.
- The comments review sidebar is hidden by default and opens with a comments button or keyboard shortcut.
- The app remembers whether the user last had sidebars open for that document/window size.
- On narrow windows, opening one sidebar should not automatically force both sidebars into view.
### Target Commenting Interaction
Commenting must be immediate, anchored, and selection-based.
There must not be two primary kinds of comment posts. A user-facing comment is a message attached to selected PDF text or to an existing text markup annotation. The app must not present "Add a comment" as a loose page-marker creation flow that waits for the user to click somewhere on the page.
Required highlight comment flow:
1. The user selects text.
2. The user clicks Highlight or presses the highlight shortcut.
3. The text is highlighted immediately.
4. A small native popover appears near the highlight or in the nearest margin.
5. The popover contains a focused comment text area with placeholder text such as "Add comment".
6. The user types a comment.
7. The comment is saved automatically when the popover closes, when focus leaves the popover, or when the user presses Command-Return.
8. The comments sidebar, if open, updates immediately.
9. The user continues reading without navigating away from the PDF.
The user must not have to understand a separate form, save button, manual refresh button, or detached editor window just to attach a comment to highlighted text.
Required selected-text comment flow:
1. The user selects text with the mouse.
2. The user clicks Comment, uses the comments sidebar add-comment affordance, or right-clicks and chooses Comment.
3. The selected text receives a restrained standard PDF text markup annotation.
4. A small anchored popover opens immediately for the comment text.
5. The comment appears in the comments sidebar as one normal comment row, not as a separate post type.
6. Hovering the sidebar comment temporarily highlights the referenced text in the PDF without navigating away from the current reading position.
7. The comment saves automatically and remains standard PDF annotation contents.
Standalone page-placement comments are not a version 1 commenting flow. The app may preserve existing annotations from other readers and may use standard PDF text annotations for replies where PDFKit requires them, but the ordinary "add comment" path must be selection-bound.
Required edit flow:
- Clicking an existing highlight, underline, selection-bound comment, reply, or free-text annotation opens the anchored comment popover.
- Editing is inline and live-updating.
- Deleting an annotation is available from the popover through a clear but secondary destructive action.
- The comments sidebar can also edit comments, but it is not the primary creation experience.
### Target Comments Sidebar
The comments sidebar is a review mode, not the default annotation input surface.
The sidebar should read like a clean document-review conversation stream, closer to a native comments/chat inspector than a database table. Comments should be easy to scan in sequence, with a clear comment icon or author marker, author/time metadata, the full comment text, compact reply actions, and visible thread structure for replies.
It should:
- Be hidden by default.
- Open from a comments toolbar button, View menu command, or keyboard shortcut.
- Show a compact header with total comment count.
- Update automatically as annotations change.
- Never require or expose a manual refresh button in ordinary use.
- Group comments by page.
- Show author, date, review state, and full comment text.
- Use the row's circular marker icon to differentiate comments, highlights, underlines, and replies; do not duplicate that icon in the metadata row.
- Let users change review state directly from a compact Reviewed/Not reviewed chip in each comment row.
- Include search and filters, but hide advanced filters behind a compact filter menu or disclosure control.
- Keep replies visually subordinate to their parent comment.
- Draw subtle vertical connector lines that make reply threads visually clear, like a clean comments/chat section.
- Navigate to and select the associated PDF annotation when a parent comment or reply is clicked.
- Temporarily highlight the referenced PDF text when a comment row or reply row is hovered.
- Feel like a native macOS inspector/sidebar, not a web dashboard.
The comments sidebar may have a refresh command only as a hidden/debug or menu-level recovery action if PDFKit state becomes inconsistent. It must not be a primary visible control.
### Target Annotation Sidebar and Thumbnail Sidebar
The left sidebar must have clear purpose:
- Thumbnail mode is for navigation.
- Annotation list mode is for scanning annotations.
- The app should not show both the left navigation sidebar and right comments review sidebar by default.
- If both are open, widths must be compact and the PDF must remain the dominant visual element.
- Thumbnail spacing should be dense enough to feel native and useful.
- Empty sidebar regions should be avoided.
### Visual Density, Margins, and Reading Comfort
The PDF view should feel like a high-quality macOS document reader.
Requirements:
- Page margins and inter-page spacing must be deliberately tuned.
- Fit-to-width should use available space efficiently without huge dead zones.
- Actual-size mode should not be the default if it creates awkward margins for common slides or articles.
- The background around pages should be a subtle system color, not a visually heavy gray field.
- Sidebars should use compact row spacing and native materials.
- Buttons should use icon-only labels where the meaning is standard, with accessibility labels and tooltips.
- Search should not consume excessive toolbar width.
- Annotation colors should be readable and restrained.
- There should be no visible decorative branding inside the reading interface.
### Path to Fixing the Current UI
The UI must be fixed in this order:
1. Change the default open-PDF layout to single-pane reading mode with both sidebars hidden.
2. Add a compact comments button that toggles the comments review sidebar.
3. Remove the visible comments refresh button and make annotation changes update the sidebar automatically.
4. Replace the form-like comment editor with an anchored popover tied to the selected highlight, selection-bound comment, underline, or free-text annotation.
5. Make highlight creation open the comment popover immediately after creating the PDF annotation.
6. Make selected-text comment creation available from the toolbar, comments sidebar, and right-click menu, then open the anchored comment popover immediately.
7. Tune PDF page margins, page-break spacing, and fit behavior so documents use available space well.
8. Simplify and regroup the toolbar around reading and annotation tasks.
9. Make advanced comment filters collapsible or menu-based.
10. Redesign comment rows to feel like a clean threaded review/chat stream, including visible connector lines for replies and click-to-navigate behavior on comments and replies.
11. Reduce sidebar widths and row spacing.
12. Add visual QA screenshots for:
- no document open
- PDF open in default reading mode
- highlight comment popover
- selected-text comment popover
- comments review sidebar open
- dark mode reading
13. Run a design review using real academic PDFs, lecture slides, scanned readings, and long journal articles.
### Revised UI Acceptance Standard
The app is not visually acceptable until a user can open a PDF and immediately understand:
- where the document is,
- how to highlight selected text,
- where to type the comment,
- how to close the comment and keep reading,
- how to reopen the comment,
- how to show or hide all comments,
- and how to save/share the annotated PDF.
- how comment replies belong to a parent comment in the review sidebar.
No default screen should make the user feel like they are managing a database of comments before they have started reading.
### 1. PDF Opening
The app must allow the user to open a local `.pdf` file from disk.
The app must support:
- Text-based PDFs
- Scanned/image-based PDFs
- Multi-page PDFs
- Large academic PDFs of at least 500 pages
### 2. Reading Interface
The app must provide:
- Page scrolling
- Zoom in and zoom out
- Fit to width
- Fit to page
- Page number navigation
- Search within selectable text PDFs
- Sidebar with page thumbnails
- Sidebar toggle
### 3. Annotation Types
The app must support at minimum:
- Highlight annotation with optional comment
- Selection-bound comment created from selected text, including right-click Comment
- Underline annotation with optional comment
- Free-text annotation placed directly on the page
The first version does not need standalone page-placement comments, drawing, shapes, stamps, audio annotations, collaboration, OCR, or AI features.
### 4. Comment Popups
For every annotation that has a comment, the user must be able to open a popup displaying the comment text.
The popup must support:
- Viewing the full comment
- Editing the comment
- Closing the popup
- Reopening the popup by clicking the annotation
When the PDF is saved and opened in another PDF reader, the comment must still be associated with the annotation and must be openable there.
### 5. Saving
The app must support:
- Save annotations into the original PDF
- Save As a new annotated copy
- Warn before overwriting the original file
- Preserve existing PDF content
- Preserve existing annotations from other PDF readers whenever possible
### 6. Interoperability
The app must not rely on proprietary annotation storage.
A successful export means:
- Highlighted text remains highlighted
- Selection-bound comments remain visible and readable as standard PDF annotations
- Comments remain readable as popup annotation contents
- The PDF can be emailed or uploaded to an LMS without losing comments
### 7. Annotation Sidebar
The app must include an annotation list showing:
- Page number
- Annotation type
- Author name
- Date created
- First line of comment text
Clicking an item in the list must navigate to that annotation.
### 8. Comments Review Sidebar
The app must include an Adobe Acrobat-style comments review sidebar for quickly reviewing and responding to annotations across the whole PDF.
This is separate from one-off annotation popups. It should provide a persistent document-level comments panel that can be opened beside the PDF reading area.
The comments sidebar must support:
- Total comment count for the current PDF
- Comments grouped by page
- Collapsible page groups
- Search within comments
- Filtering comments by annotation type, author, and status where practical
- Author name for each comment
- Date and time created or modified
- Full comment text, not only a truncated preview
- Reply threads attached to an existing annotation comment
- Adding a reply from the sidebar
- Editing the user's own comments from the sidebar
- Deleting the user's own comments from the sidebar
- Clicking a comment to navigate to the corresponding page and annotation
- Selecting the associated annotation in the PDF view when a comment is selected
Threaded replies should be saved using standards-compliant PDF annotation reply relationships where supported by PDFKit or the chosen PDF-writing layer. If full reply interoperability is limited by common PDF readers, the app must still preserve the primary annotation comment as standard PDF annotation contents and document the known limitations.
The comments sidebar should feel native to macOS: compact, quiet, keyboard-navigable, accessible, and suitable for long review sessions.
The app must not require AI features for comment review. Any future summary feature must be optional and out of scope for version 1 unless explicitly added later.
### 9. Professor-Focused Workflow
The app should make academic annotation fast.
Required workflow:
- Open PDF
- Select text
- Click highlight
- Type optional comment
- Continue reading
- Save annotated PDF
- Share file
The app should not require accounts, cloud sync, project setup, import libraries, or document conversion.
## Out of Scope for Version 1
The following are explicitly not required:
- Real-time collaboration
- Cloud storage
- User accounts
- LMS integration
- Citation management
- OCR
- AI summarization
- Handwriting recognition
- iPad support
- Windows/Linux support
- Browser extension
- Mobile app
- Custom proprietary comment system
## Acceptance Criteria
The project is complete when:
1. A professor can open a PDF on macOS, highlight text, add a comment, save the file, and reopen it with the annotation still present.
2. A second person can open that saved PDF in macOS Preview or Adobe Acrobat Reader and see the highlight and open the comment popup.
3. Selection-bound comments created in the app remain visible as standard PDF annotations in other readers.
4. Existing PDF text, images, layout, bookmarks, and prior annotations are not destroyed during save.
5. The app can be built from source using documented commands.
6. The GitHub repository includes installation instructions, development setup instructions, license, screenshots, and a basic roadmap.
7. The app visually fits on macOS, supports light and dark mode, uses native-feeling controls and keyboard shortcuts, and remains pleasant to use during long PDF reading sessions.
8. The app includes a persistent comments review sidebar that shows the document comment count, groups comments by page, supports search/filtering, supports replies where interoperable, highlights referenced text on hover, and navigates from a sidebar comment to the matching PDF annotation.
9. The app passes a design review against Apple's macOS Human Interface Guidelines before the first public release.
## One-Sentence Version
Build an open-source, polished, native-feeling macOS PDF reader and annotation app for professors that saves highlights, underlines, and selection-bound comments as standard embedded PDF annotations so annotated PDFs can be shared and viewed with pop-up comments in common PDF readers without requiring the app.

BIN
ihatepdf.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

117
scripts/build-app.sh Executable file
View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="I Hate PDFs"
EXECUTABLE_NAME="IHatePDFs"
CONFIGURATION="${CONFIGURATION:-release}"
if [[ -z "${ARCHS+x}" && "$CONFIGURATION" == "release" ]]; then
ARCHS="arm64 x86_64"
else
ARCHS="${ARCHS:-}"
fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_SOURCE="$ROOT_DIR/ihatepdf.png"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
for ARCH in $ARCHS; do
SWIFT_BUILD_ARGS+=(--arch "$ARCH")
done
swift build "${SWIFT_BUILD_ARGS[@]}"
BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
if [[ ! -f "$ICON_SOURCE" ]]; then
echo "Missing app icon source: $ICON_SOURCE" >&2
exit 1
fi
ICONSET_DIR="$DIST_DIR/$ICON_NAME.iconset"
rm -rf "$ICONSET_DIR"
mkdir -p "$ICONSET_DIR"
make_icon() {
local pixels="$1"
local output="$2"
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$ICONSET_DIR/$output" >/dev/null
}
make_icon 16 "icon_16x16.png"
make_icon 32 "icon_16x16@2x.png"
make_icon 32 "icon_32x32.png"
make_icon 64 "icon_32x32@2x.png"
make_icon 128 "icon_128x128.png"
make_icon 256 "icon_128x128@2x.png"
make_icon 256 "icon_256x256.png"
make_icon 512 "icon_256x256@2x.png"
make_icon 512 "icon_512x512.png"
make_icon 1024 "icon_512x512@2x.png"
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns"
rm -rf "$ICONSET_DIR"
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$EXECUTABLE_NAME</string>
<key>CFBundleIdentifier</key>
<string>org.ihatepdfs.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundleDisplayName</key>
<string>$APP_NAME</string>
<key>CFBundleIconFile</key>
<string>$ICON_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>PDF Document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
</array>
</dict>
</array>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT License</string>
</dict>
</plist>
PLIST
echo "Built $APP_DIR"

22
scripts/make-dmg.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="I Hate PDFs"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
DMG_PATH="$DIST_DIR/IHatePDFs.dmg"
if [[ ! -d "$APP_DIR" ]]; then
"$ROOT_DIR/scripts/build-app.sh"
fi
rm -f "$DMG_PATH"
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$APP_DIR" \
-ov \
-format UDZO \
"$DMG_PATH"
echo "Created $DMG_PATH"

View File

@@ -0,0 +1,283 @@
import CoreGraphics
import Foundation
struct AnnotationSummary {
var highlights = 0
var selectedTextComments = 0
var underlines = 0
var textNotes = 0
var replies = 0
var freeText = 0
var popups = 0
}
enum VerificationError: Error, CustomStringConvertible {
case unreadablePDF(String)
case missingPageAnnotations(Int)
case missingDictionary(page: Int, index: Int)
case missingName(page: Int, index: Int, key: String)
case missingString(page: Int, index: Int, key: String)
case missingArray(page: Int, index: Int, key: String)
case missingPopup(page: Int, index: Int, subtype: String)
case missingPopupParent(page: Int, index: Int)
case missingExpectedSubtype(String)
var description: String {
switch self {
case .unreadablePDF(let path):
return "Unable to read PDF at \(path)"
case .missingPageAnnotations(let page):
return "Page \(page) has no /Annots array"
case .missingDictionary(let page, let index):
return "Annotation \(index) on page \(page) is not a dictionary"
case .missingName(let page, let index, let key):
return "Annotation \(index) on page \(page) is missing name key /\(key)"
case .missingString(let page, let index, let key):
return "Annotation \(index) on page \(page) is missing string key /\(key)"
case .missingArray(let page, let index, let key):
return "Annotation \(index) on page \(page) is missing array key /\(key)"
case .missingPopup(let page, let index, let subtype):
return "\(subtype) annotation \(index) on page \(page) is missing a /Popup dictionary"
case .missingPopupParent(let page, let index):
return "Popup annotation \(index) on page \(page) is missing a /Parent dictionary"
case .missingExpectedSubtype(let subtype):
return "Expected at least one /\(subtype) annotation"
}
}
}
let inputPath = CommandLine.arguments.dropFirst().first ?? "dist/annotation-verification.pdf"
let inputURL = URL(fileURLWithPath: inputPath)
guard let document = CGPDFDocument(inputURL as CFURL) else {
throw VerificationError.unreadablePDF(inputURL.path)
}
var summary = AnnotationSummary()
for pageNumber in 1...document.numberOfPages {
guard let page = document.page(at: pageNumber),
let pageDictionary = page.dictionary
else {
continue
}
var annotationsArray: CGPDFArrayRef?
guard CGPDFDictionaryGetArray(pageDictionary, "Annots", &annotationsArray),
let annotationsArray
else {
continue
}
for annotationIndex in 0..<CGPDFArrayGetCount(annotationsArray) {
let annotation = try annotationDictionary(
in: annotationsArray,
at: annotationIndex,
page: pageNumber
)
let subtype = try nameValue(
in: annotation,
key: "Subtype",
page: pageNumber,
index: annotationIndex
)
switch subtype {
case "Highlight":
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Highlight")
if hasString(in: annotation, key: "IHatePDFsKind") {
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
summary.selectedTextComments += 1
} else {
summary.highlights += 1
}
case "Underline":
summary.underlines += 1
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Underline")
case "Text":
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text")
if hasString(in: annotation, key: "IRT") || hasString(in: annotation, key: "RT") {
summary.replies += 1
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
} else {
summary.textNotes += 1
}
case "FreeText":
summary.freeText += 1
try requireString(in: annotation, key: "Contents", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "T", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "M", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "DA", page: pageNumber, index: annotationIndex)
try requireArray(in: annotation, key: "C", page: pageNumber, index: annotationIndex)
try requireArray(in: annotation, key: "Rect", page: pageNumber, index: annotationIndex)
case "Popup":
summary.popups += 1
var parentDictionary: CGPDFDictionaryRef?
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else {
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex)
}
default:
continue
}
}
}
guard summary.highlights > 0 else {
throw VerificationError.missingExpectedSubtype("Highlight")
}
guard summary.selectedTextComments > 0 else {
throw VerificationError.missingExpectedSubtype("selected-text Comment")
}
guard summary.underlines > 0 else {
throw VerificationError.missingExpectedSubtype("Underline")
}
guard summary.textNotes > 0 else {
throw VerificationError.missingExpectedSubtype("Text")
}
guard summary.replies > 0 else {
throw VerificationError.missingExpectedSubtype("Text reply")
}
guard summary.freeText > 0 else {
throw VerificationError.missingExpectedSubtype("FreeText")
}
guard summary.popups >= 5 else {
throw VerificationError.missingExpectedSubtype("Popup")
}
print("Verified raw PDF annotation dictionaries in \(inputURL.path): \(summary.highlights) highlight, \(summary.selectedTextComments) selected-text comment, \(summary.underlines) underline, \(summary.textNotes) text note, \(summary.replies) reply, \(summary.freeText) free-text, \(summary.popups) popups.")
func annotationDictionary(
in array: CGPDFArrayRef,
at index: Int,
page: Int
) throws -> CGPDFDictionaryRef {
var object: CGPDFObjectRef?
guard CGPDFArrayGetObject(array, index, &object),
let object
else {
throw VerificationError.missingDictionary(page: page, index: index)
}
var dictionary: CGPDFDictionaryRef?
guard CGPDFObjectGetValue(object, .dictionary, &dictionary),
let dictionary
else {
throw VerificationError.missingDictionary(page: page, index: index)
}
return dictionary
}
func nameValue(
in dictionary: CGPDFDictionaryRef,
key: String,
page: Int,
index: Int
) throws -> String {
var name: UnsafePointer<Int8>?
guard CGPDFDictionaryGetName(dictionary, key, &name),
let name
else {
throw VerificationError.missingName(page: page, index: index, key: key)
}
return String(cString: name)
}
func requireString(
in dictionary: CGPDFDictionaryRef,
key: String,
page: Int,
index: Int
) throws {
var value: CGPDFStringRef?
guard CGPDFDictionaryGetString(dictionary, key, &value),
value != nil
else {
throw VerificationError.missingString(page: page, index: index, key: key)
}
}
func requireName(
in dictionary: CGPDFDictionaryRef,
key: String,
page: Int,
index: Int
) throws {
var value: UnsafePointer<Int8>?
guard CGPDFDictionaryGetName(dictionary, key, &value),
value != nil
else {
throw VerificationError.missingName(page: page, index: index, key: key)
}
}
func hasString(
in dictionary: CGPDFDictionaryRef,
key: String
) -> Bool {
var value: CGPDFStringRef?
return CGPDFDictionaryGetString(dictionary, key, &value) && value != nil
}
func requireArray(
in dictionary: CGPDFDictionaryRef,
key: String,
page: Int,
index: Int
) throws {
var value: CGPDFArrayRef?
guard CGPDFDictionaryGetArray(dictionary, key, &value),
value != nil
else {
throw VerificationError.missingArray(page: page, index: index, key: key)
}
}
func requireMarkupKeys(
in dictionary: CGPDFDictionaryRef,
page: Int,
index: Int
) throws {
try requireString(in: dictionary, key: "Contents", page: page, index: index)
try requireArray(in: dictionary, key: "QuadPoints", page: page, index: index)
try requireArray(in: dictionary, key: "C", page: page, index: index)
try requireString(in: dictionary, key: "T", page: page, index: index)
try requireString(in: dictionary, key: "M", page: page, index: index)
}
func requireTextKeys(
in dictionary: CGPDFDictionaryRef,
page: Int,
index: Int
) throws {
try requireString(in: dictionary, key: "Contents", page: page, index: index)
try requireName(in: dictionary, key: "Name", page: page, index: index)
try requireArray(in: dictionary, key: "C", page: page, index: index)
try requireString(in: dictionary, key: "T", page: page, index: index)
try requireString(in: dictionary, key: "M", page: page, index: index)
}
func requirePopup(
in dictionary: CGPDFDictionaryRef,
page: Int,
index: Int,
subtype: String
) throws {
var popupDictionary: CGPDFDictionaryRef?
guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary),
let popupDictionary
else {
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype)
}
let popupSubtype = try nameValue(in: popupDictionary, key: "Subtype", page: page, index: index)
guard popupSubtype == "Popup" else {
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype)
}
}

View File

@@ -0,0 +1,180 @@
import AppKit
import Foundation
import PDFKit
let outputURL = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "dist/annotation-verification.pdf")
try FileManager.default.createDirectory(
at: outputURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let page = PDFPage()
let document = PDFDocument()
document.insert(page, at: 0)
let verificationDate = Date(timeIntervalSince1970: 1_797_000_000)
let highlight = PDFAnnotation(
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
forType: .highlight,
withProperties: nil
)
highlight.markupType = .highlight
highlight.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.24)
highlight.quadrilateralPoints = quadPoints(width: 260, height: 24)
standardize(
highlight,
name: "verify-highlight",
contents: "This is a standards-compliant PDF highlight comment.",
author: "Professor"
)
page.addAnnotation(highlight)
addPopup(for: highlight, bounds: CGRect(x: 352, y: 592, width: 240, height: 110))
let selectedTextComment = PDFAnnotation(
bounds: CGRect(x: 72, y: 594, width: 260, height: 22),
forType: .highlight,
withProperties: nil
)
selectedTextComment.markupType = .highlight
selectedTextComment.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.10)
selectedTextComment.quadrilateralPoints = quadPoints(width: 260, height: 22)
standardize(
selectedTextComment,
name: "verify-selected-text-comment",
contents: "This selected-text comment is saved as standard PDF markup with popup contents.",
author: "Professor"
)
_ = selectedTextComment.setValue("Comment", forAnnotationKey: PDFAnnotationKey(rawValue: "IHatePDFsKind"))
page.addAnnotation(selectedTextComment)
addPopup(for: selectedTextComment, bounds: CGRect(x: 352, y: 472, width: 240, height: 110))
let underline = PDFAnnotation(
bounds: CGRect(x: 72, y: 570, width: 260, height: 24),
forType: .underline,
withProperties: nil
)
underline.markupType = .underline
underline.color = NSColor(calibratedRed: 0.48, green: 0.53, blue: 0.62, alpha: 0.56)
underline.quadrilateralPoints = quadPoints(width: 260, height: 24)
standardize(
underline,
name: "verify-underline",
contents: "This underline comment should remain openable.",
author: "Professor"
)
page.addAnnotation(underline)
addPopup(for: underline, bounds: CGRect(x: 352, y: 540, width: 240, height: 110))
let textNote = PDFAnnotation(
bounds: CGRect(x: 360, y: 620, width: 28, height: 28),
forType: .text,
withProperties: nil
)
textNote.iconType = .note
textNote.color = NSColor(calibratedRed: 0.64, green: 0.59, blue: 0.49, alpha: 0.90)
standardize(
textNote,
name: "verify-text-note",
contents: "This standard PDF text annotation remains visible in common PDF readers.",
author: "Professor"
)
page.addAnnotation(textNote)
let reply = PDFAnnotation(
bounds: CGRect(x: 402, y: 586, width: 24, height: 24),
forType: .text,
withProperties: nil
)
reply.iconType = .comment
reply.color = NSColor(calibratedRed: 0.52, green: 0.58, blue: 0.60, alpha: 0.88)
standardize(
reply,
name: "verify-reply",
contents: "This reply is saved as a visible PDF text annotation.",
author: "Reader"
)
_ = reply.setValue("verify-text-note", forAnnotationKey: PDFAnnotationKey(rawValue: "IRT"))
_ = reply.setValue("R", forAnnotationKey: PDFAnnotationKey(rawValue: "RT"))
page.addAnnotation(reply)
let freeText = PDFAnnotation(
bounds: CGRect(x: 72, y: 500, width: 260, height: 50),
forType: .freeText,
withProperties: nil
)
freeText.font = NSFont.systemFont(ofSize: 13)
freeText.fontColor = NSColor(calibratedWhite: 0.22, alpha: 1)
freeText.color = NSColor(calibratedRed: 0.91, green: 0.86, blue: 0.75, alpha: 0.32)
freeText.alignment = .left
let border = PDFBorder()
border.lineWidth = 0.75
freeText.border = border
standardize(
freeText,
name: "verify-free-text",
contents: "Free text remains visible on the PDF page.",
author: "Professor"
)
page.addAnnotation(freeText)
guard document.write(to: outputURL) else {
fatalError("Unable to write \(outputURL.path)")
}
let reopened = PDFDocument(url: outputURL)!
let annotations = reopened.page(at: 0)!.annotations
precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("highlight") == true })
precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("selected-text comment") == true })
precondition(annotations.contains { matches($0, .underline) && $0.contents?.contains("underline") == true })
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("text annotation") == true })
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("reply") == true })
precondition(annotations.contains { matches($0, .freeText) && $0.contents?.contains("Free text") == true })
print("Verified standard PDF annotations in \(outputURL.path)")
func standardize(
_ annotation: PDFAnnotation,
name: String,
contents: String,
author: String
) {
annotation.contents = contents
annotation.userName = author
annotation.modificationDate = verificationDate
annotation.shouldDisplay = true
annotation.shouldPrint = true
_ = annotation.setValue(name, forAnnotationKey: .name)
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
_ = annotation.setValue(verificationDate, forAnnotationKey: .date)
_ = annotation.setValue("D:20261215132000Z00'00'", forAnnotationKey: PDFAnnotationKey(rawValue: "CreationDate"))
_ = annotation.setValue("Unmarked", forAnnotationKey: PDFAnnotationKey(rawValue: "State"))
}
func addPopup(for annotation: PDFAnnotation, bounds: CGRect) {
let popup = PDFAnnotation(bounds: bounds, forType: .popup, withProperties: nil)
popup.contents = annotation.contents
popup.userName = annotation.userName
popup.modificationDate = annotation.modificationDate
popup.isOpen = false
popup.shouldDisplay = true
popup.shouldPrint = true
annotation.popup = popup
page.addAnnotation(popup)
}
func quadPoints(width: CGFloat, height: CGFloat) -> [NSValue] {
[
NSValue(point: CGPoint(x: 0, y: height)),
NSValue(point: CGPoint(x: width, y: height)),
NSValue(point: CGPoint(x: 0, y: 0)),
NSValue(point: CGPoint(x: width, y: 0))
]
}
func matches(_ annotation: PDFAnnotation, _ 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
}