5 Commits
v0.1 ... v0.3

Author SHA1 Message Date
Akshay Kolli
085d7a16dc Release v0.3 2026-06-24 17:51:26 -07:00
Akshay Kolli
3d112c677a Release v0.2 2026-06-18 16:44:19 -07:00
Akshay Kolli
c2ca546c8c Fix typos and enhance README content
Corrected typos and improved wording in README.
2026-06-12 15:39:36 -04:00
Akshay Kolli
4d030f1d0f Document v0.1 DMG release 2026-06-11 22:20:14 -07:00
Akshay Kolli
eae686a45f Update README.md 2026-06-12 01:12:07 -04:00
34 changed files with 3051 additions and 530 deletions

5
.gitignore vendored
View File

@@ -2,6 +2,11 @@
.build/
DerivedData/
dist/
release/
*.zip
*.xcuserstate
*.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
*.mobileprovision
*.provisionprofile
*.p12

86
CHANGELOG.md Normal file
View File

@@ -0,0 +1,86 @@
# Changelog
## Version 0.3.0 (build 4) - 2026-06-24
Version 0.3 is focused on making annotation work feel reliable enough for real PDF review: clearer highlights, better comment behavior, safer saving, and release packaging for the Mac App Store.
### Highlights
- Added a Settings window for highlight and comment colors, including opacity.
- Added drag-and-drop opening when no PDF is open.
- Highlighting selected text now creates a highlight immediately instead of opening an empty comment box.
- Pressing Return now saves comments and replies; Shift-Return inserts a new line.
- Saved text comments now remain visible in macOS Preview and Adobe Acrobat.
- Added Mac App Store packaging for bundle ID `net.akkolli.ihatepdfs`.
### Annotation And Comment Improvements
- Highlight and comment colors now have stronger default contrast.
- Custom highlight and comment colors keep a minimum readable opacity.
- New comment popovers focus the text box immediately, so the text cursor appears before typing.
- Clicking commented or underlined text reopens the editor more accurately.
- Clicking nearby whitespace or the line below a comment no longer opens the popover by mistake.
- Empty newly created selected-text comments and free-text notes are discarded when closed, so they do not leave behind blank annotations.
- Plain highlights and underlines can remain empty without being deleted.
- Comments imported from other PDF readers are shown even when the app-specific comment field is missing.
### Saving, Sharing, And Document Safety
- The app now prompts before closing, replacing, or quitting with unsaved annotation changes.
- The window uses the native macOS edited-document indicator while annotations or reply drafts are unsaved.
- Save is disabled when there is nothing to save.
- Save, Save As, and Share warn before omitting an unsent sidebar reply draft.
- Share avoids redundant save prompts when the current PDF is already saved.
- Save-before-close prompts name the file that would be overwritten.
### Comments Sidebar
- The comments sidebar now handles replies, filters, collapsed page groups, and search more consistently.
- Matching replies keep their parent thread visible in search results.
- Filters that hide every comment now show a clear empty state with a Clear Filters action.
- Starting a new reply no longer silently discards a draft for another comment.
- Sidebar hover and selection highlights now clear when filters, collapsed groups, or sidebar visibility hide the selected row.
- Selecting a sidebar reply scrolls to and highlights the visible parent annotation instead of a hidden reply marker.
### Search And Navigation
- Closing PDF search clears match highlights from the document.
- Editing the search field clears stale match highlights until the new search is submitted.
- Search now reports the current match number while stepping through results.
- Page navigation disables unavailable previous/next controls and recovers cleanly from invalid page-number entries.
### Packaging And Release
- The app version is now `0.3.0`, build `4`.
- Release scripts now build a v0.3 DMG filename by default.
- Added a shared release-version script so app bundle versions, DMG names, and App Store package names stay aligned.
- Added App Store sandbox entitlements for user-selected PDF read/write access.
- Added a signed Mac App Store `.pkg` build path.
- Added release QA, App Store packaging, and engineering-size documentation.
### Tests
- Added tests for color preference storage and minimum opacity.
- Added tests for tighter text-markup hit testing.
- Added tests for PDF drag-and-drop file selection.
- Added tests for Return versus Shift-Return commit behavior.
- Expanded PDF annotation export tests for Preview-compatible comments, popup cleanup, configured colors, replies, and imported annotations.
## Version 0.2.0 - 2026-06-18
### Fixed
- Opening a PDF from Finder Open With now opens in its own window state instead of mirroring into an existing window.
- Zoom toolbar and menu commands now apply to the focused PDF window instead of another open window.
- Pressing Return in a new comment editor now saves the comment without requiring the mouse.
- Page and comments sidebar toolbar icons remain visible in narrow windows.
### Changed
- Sidebar toolbar controls are grouped together in the leading toolbar area for better visibility.
## Version 0.1.0
- Initial native macOS SwiftUI/PDFKit release.
- Local PDF opening, reading, zoom, fit width, fit page, page navigation, and search.
- Highlight, underline, selection-bound comment, free-text annotation, comments sidebar, Save, Save As, and Share workflows.

View File

@@ -1,33 +1,41 @@
# 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.
I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload.
Minimum supported macOS version: macOS 13 Ventura.
Supported Mac architectures: Apple Silicon and Intel, subject to the local Swift/Xcode toolchain used to build.
Supported Mac architectures: Apple Silicon and Intel.
## Latest Release
Current version: `0.3.0` build `4`.
Download the v0.3 macOS DMG from the GitHub release page:
<https://github.com/akkolli/ihatepdfs/releases/tag/v0.3>
Use `IHatePDFs-v0.3-macos.dmg` for direct installation. Open the DMG, then move `I Hate PDFs.app` into `/Applications`.
The direct-download DMG is separate from the Mac App Store build. The App Store package uses bundle ID `net.akkolli.ihatepdfs` and is built with the sandbox entitlements documented in `docs/APP_STORE.md`.
## Features
- Open local `.pdf` files from disk.
- Drag a PDF onto the empty app window to open it.
- 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.
- Configure highlight and comment colors, including opacity, from Settings.
- Create standalone highlights from selected text.
- Create selected-text comments and underline comments.
- Create free-text annotations directly on the page.
- Click annotations in the PDF to reopen and edit the comment in place.
- Press Return to save comments and replies, or Shift-Return for a new line.
- Click commented or underlined text 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.
- Review annotations in a comments sidebar with page grouping, search, filters, replies, edit/delete, and click-to-navigate.
## Build From Source
@@ -67,19 +75,34 @@ Create a downloadable `.dmg`:
scripts/make-dmg.sh
```
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs.dmg`.
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.3-macos.dmg` by default.
Build an App Store upload package after installing the application signing certificate, installer signing certificate, and App Store provisioning profile:
```sh
APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \
INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \
PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
scripts/make-app-store-pkg.sh
```
The App Store package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. More details are in `docs/APP_STORE.md`.
## 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.
Download `IHatePDFs-v0.3-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`.
For local direct-download builds, the app may not be Developer ID notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm.
## Development
The project is a Swift Package with two targets:
- `IHatePDFsCore`: PDF annotation models and factory helpers.
- `IHatePDFsCore`: PDF annotation models, annotation export helpers, hit testing, color preference logic, file selection, and keyboard policies.
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
Engineering rule: keep this a native macOS app with the smallest final bundle that still delivers the required fluidity and functionality. See `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes.
Useful checks:
```sh
@@ -89,9 +112,9 @@ 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.
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship 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`.
Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. App Store packaging is documented in `docs/APP_STORE.md`.
## Screenshots

30
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1,30 @@
# Release Notes
## I Hate PDFs v0.3.0
Version 0.3 makes I Hate PDFs much safer and more comfortable for everyday PDF review.
### What's New
- Settings for highlight and comment colors, including opacity.
- Drag-and-drop opening from the empty app window.
- Standalone highlights that do not open a comment editor.
- Return saves a comment or reply; Shift-Return inserts a new line.
- Better default highlight contrast.
- Mac App Store packaging for `net.akkolli.ihatepdfs`.
### Reliability Fixes
- Comment text now survives when saved PDFs are opened in macOS Preview and Adobe Acrobat.
- The comment editor focuses correctly when a new selected-text comment is created.
- Comment popovers now open from the actual annotated text instead of nearby whitespace.
- The app warns before unsaved annotations or reply drafts are lost.
- Search highlights clear correctly when search is closed or edited.
- The comments sidebar keeps threads, filters, replies, and selected highlights in sync.
### Version
- App version: `0.3.0`
- Build number: `4`
- Direct-download DMG name: `IHatePDFs-v0.3-macos.dmg`
- Mac App Store package name: `IHatePDFs-v0.3-macos-appstore.pkg`

View File

@@ -14,7 +14,18 @@
- `.app` and `.dmg` build scripts.
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
## Version 0.2
## Shipped In Version 0.3
- Settings for highlight and comment colors.
- Higher-contrast default highlights and comments.
- Standalone highlights that do not open a comment editor.
- Drag-and-drop PDF opening from the empty app window.
- Return-to-save and Shift-Return-for-newline comment behavior.
- Preview-compatible exported comments for selected-text markup.
- Safer close/open/quit prompts for unsaved annotations and reply drafts.
- Mac App Store packaging path for `net.akkolli.ihatepdfs`.
## Next
- More explicit visual selection handles for the active annotation.
- Better undo/redo integration for annotation edits.

View File

@@ -0,0 +1,10 @@
<?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>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,121 @@
import AppKit
import Foundation
import IHatePDFsCore
import SwiftUI
enum AppSettings {
static let highlightColorStorageKey = "IHatePDFs.highlightColorRGBA.v1"
static let commentColorStorageKey = "IHatePDFs.commentColorRGBA.v1"
static let defaultHighlightColorStorageValue = storageString(for: AcademicAnnotationPalette.highlight)
static let defaultCommentColorStorageValue = storageString(for: AcademicAnnotationPalette.comment)
private static let minimumHighlightAlpha: CGFloat = 0.38
private static let minimumCommentAlpha: CGFloat = 0.12
static var highlightColor: NSColor {
get {
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forHighlightColor: newValue), forKey: highlightColorStorageKey)
}
}
static var commentColor: NSColor {
get {
commentColor(from: UserDefaults.standard.string(forKey: commentColorStorageKey))
}
set {
UserDefaults.standard.set(storageString(forCommentColor: newValue), forKey: commentColorStorageKey)
}
}
static func highlightColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.highlight,
minimumAlpha: minimumHighlightAlpha
)
}
static func commentColor(from storageValue: String?) -> NSColor {
AnnotationColorPreference.color(
from: storageValue,
fallback: AcademicAnnotationPalette.comment,
minimumAlpha: minimumCommentAlpha
)
}
static func storageString(for color: NSColor) -> String {
AnnotationColorPreference.storageString(for: color)
}
static func storageString(for color: Color) -> String {
storageString(for: NSColor(color))
}
static func storageString(forHighlightColor color: NSColor) -> String {
storageString(for: highlightColor(from: storageString(for: color)))
}
static func storageString(forHighlightColor color: Color) -> String {
storageString(forHighlightColor: NSColor(color))
}
static func storageString(forCommentColor color: NSColor) -> String {
storageString(for: commentColor(from: storageString(for: color)))
}
static func storageString(forCommentColor color: Color) -> String {
storageString(forCommentColor: NSColor(color))
}
}
struct SettingsView: View {
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
@AppStorage(AppSettings.commentColorStorageKey)
private var storedCommentColor = AppSettings.defaultCommentColorStorageValue
var body: some View {
Form {
Section("Annotations") {
ColorPicker(
"Highlight color",
selection: highlightColor,
supportsOpacity: true
)
ColorPicker(
"Comment color",
selection: commentColor,
supportsOpacity: true
)
Button {
storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
storedCommentColor = AppSettings.defaultCommentColorStorageValue
} label: {
Label("Reset Annotation Colors", systemImage: "arrow.counterclockwise")
}
}
}
.padding(20)
.frame(width: 360)
}
private var highlightColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.highlightColor(from: storedHighlightColor))
} set: { newValue in
storedHighlightColor = AppSettings.storageString(forHighlightColor: newValue)
}
}
private var commentColor: Binding<Color> {
Binding {
Color(nsColor: AppSettings.commentColor(from: storedCommentColor))
} set: { newValue in
storedCommentColor = AppSettings.storageString(forCommentColor: newValue)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,9 @@ struct CommentEditorView: View {
.onChange(of: model.author) { _ in
model.updateDraft()
}
.commitOnPlainReturn {
model.commit()
}
}
private var header: some View {

View File

@@ -3,154 +3,389 @@ import SwiftUI
@main
struct IHatePDFsApp: App {
@StateObject private var appState = AppState()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
MainView()
.environmentObject(appState)
.onOpenURL { url in
appState.loadDocument(from: url)
}
AppWindowRoot()
}
.windowStyle(.titleBar)
.commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
appState.openDocument()
}
.keyboardShortcut("o")
AppCommands()
}
Button("Save") {
appState.saveDocument()
}
.keyboardShortcut("s")
.disabled(appState.document == nil)
Settings {
SettingsView()
}
}
}
Button("Save As...") {
appState.saveDocumentAs()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(appState.document == nil)
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
AppStateRegistry.shared.confirmApplicationShouldTerminate()
? .terminateNow
: .terminateCancel
}
}
Button("Share...") {
appState.shareDocument()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(appState.document == nil)
@MainActor
private final class AppStateRegistry {
static let shared = AppStateRegistry()
Divider()
private var appStates: [WeakAppState] = []
private(set) var isTerminationApproved = false
Button("Close PDF") {
appState.closeDocument()
}
.keyboardShortcut("w")
.disabled(appState.document == nil)
func register(_ appState: AppState) {
prune()
guard !appStates.contains(where: { $0.value === appState }) else {
return
}
appStates.append(WeakAppState(appState))
}
func unregister(_ appState: AppState) {
appStates.removeAll { $0.value == nil || $0.value === appState }
}
func confirmApplicationShouldTerminate() -> Bool {
prune()
for appState in appStates.compactMap(\.value) {
guard appState.confirmApplicationQuit() else {
cancelTerminationApproval()
return false
}
}
isTerminationApproved = true
return true
}
func cancelTerminationApproval() {
isTerminationApproved = false
}
private func prune() {
appStates.removeAll { $0.value == nil }
}
}
private final class WeakAppState {
weak var value: AppState?
init(_ value: AppState) {
self.value = value
}
}
private struct AppWindowRoot: View {
@StateObject private var appState = AppState()
var body: some View {
MainView()
.environmentObject(appState)
.focusedObject(appState)
.background(WindowCloseGuard(appState: appState))
.onOpenURL { url in
appState.loadDocument(from: url)
}
.onAppear {
AppStateRegistry.shared.register(appState)
}
.onDisappear {
AppStateRegistry.shared.unregister(appState)
}
}
}
private struct WindowCloseGuard: NSViewRepresentable {
@ObservedObject var appState: AppState
func makeCoordinator() -> Coordinator {
Coordinator(appState: appState)
}
func makeNSView(context: Context) -> WindowCloseGuardView {
let view = WindowCloseGuardView()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
return view
}
func updateNSView(_ view: WindowCloseGuardView, context: Context) {
context.coordinator.appState = appState
context.coordinator.updateDocumentState()
view.onWindowChange = { [weak coordinator = context.coordinator] window in
coordinator?.attach(to: window)
}
view.reportWindow()
}
@MainActor
final class Coordinator: NSObject, NSWindowDelegate {
weak var appState: AppState?
private weak var window: NSWindow?
private weak var previousDelegate: NSWindowDelegate?
init(appState: AppState) {
self.appState = appState
}
func attach(to window: NSWindow?) {
guard self.window !== window else { return }
if let oldWindow = self.window, oldWindow.delegate === self {
oldWindow.delegate = previousDelegate
}
CommandGroup(after: .textEditing) {
Button("Find in PDF") {
appState.showSearch()
}
.keyboardShortcut("f")
.disabled(appState.document == nil)
self.window = window
previousDelegate = window?.delegate
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)
if window?.delegate !== self {
window?.delegate = self
}
CommandMenu("View") {
Button("Toggle Page Sidebar") {
appState.showLeftSidebar.toggle()
}
.keyboardShortcut("0", modifiers: [.command, .option])
.disabled(appState.document == nil)
updateDocumentState()
}
Button("Toggle Comments Sidebar") {
appState.showCommentsSidebar.toggle()
}
.keyboardShortcut("1", modifiers: [.command, .option])
.disabled(appState.document == nil)
func updateDocumentState() {
guard let window else { return }
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)
let representedURL = appState?.documentURL
if window.representedURL != representedURL {
window.representedURL = representedURL
}
CommandMenu("Annotate") {
Button("Highlight Selection") {
appState.addHighlight()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(appState.document == nil)
let isDocumentEdited = appState?.hasUnsavedWork == true
if window.isDocumentEdited != isDocumentEdited {
window.isDocumentEdited = isDocumentEdited
}
}
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)
func windowShouldClose(_ sender: NSWindow) -> Bool {
if previousDelegate?.windowShouldClose?(sender) == false {
AppStateRegistry.shared.cancelTerminationApproval()
return false
}
CommandGroup(after: .windowArrangement) {
Button("Minimize") {
appState.minimizeWindow()
}
.keyboardShortcut("m", modifiers: [.command])
if AppStateRegistry.shared.isTerminationApproved {
return true
}
Button("Toggle Full Screen") {
appState.toggleFullScreen()
return appState?.confirmDocumentWindowClose() ?? true
}
func windowWillClose(_ notification: Notification) {
previousDelegate?.windowWillClose?(notification)
if window?.delegate === self {
window?.delegate = previousDelegate
}
window = nil
previousDelegate = nil
}
deinit {
MainActor.assumeIsolated {
if window?.delegate === self {
window?.delegate = previousDelegate
}
.keyboardShortcut("f", modifiers: [.command, .control])
}
}
}
}
private final class WindowCloseGuardView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
}
}
private struct AppCommands: Commands {
@FocusedObject private var appState: AppState?
private var hasDocument: Bool {
appState?.document != nil
}
private var hasTextSelection: Bool {
appState?.hasTextSelection == true
}
private var canSaveDocument: Bool {
appState?.canSaveDocument == true
}
private var saveHelpText: String {
appState?.saveHelpText ?? "Open a PDF before saving."
}
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
appState?.openDocument()
}
.keyboardShortcut("o")
.disabled(appState == nil)
Button("Save") {
appState?.saveDocument()
}
.keyboardShortcut("s")
.disabled(!canSaveDocument)
.help(saveHelpText)
Button("Save As...") {
appState?.saveDocumentAs()
}
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(!hasDocument)
Button("Share...") {
appState?.shareDocument()
}
.keyboardShortcut("e", modifiers: [.command, .shift])
.disabled(!hasDocument)
Divider()
Button("Settings...") {
openSettingsWindow()
}
Divider()
Button("Close PDF") {
appState?.closeDocument()
}
.keyboardShortcut("w")
.disabled(!hasDocument)
}
CommandGroup(after: .textEditing) {
Button("Find in PDF") {
appState?.showSearch()
}
.keyboardShortcut("f")
.disabled(!hasDocument)
Button("Find Next") {
appState?.nextSearchResult()
}
.keyboardShortcut("g")
.disabled(appState?.searchResults.isEmpty != false)
Button("Find Previous") {
appState?.previousSearchResult()
}
.keyboardShortcut("g", modifiers: [.command, .shift])
.disabled(appState?.searchResults.isEmpty != false)
}
CommandMenu("View") {
Button("Toggle Page Sidebar") {
appState?.showLeftSidebar.toggle()
}
.keyboardShortcut("0", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Comments Sidebar") {
appState?.showCommentsSidebar.toggle()
}
.keyboardShortcut("1", modifiers: [.command, .option])
.disabled(!hasDocument)
Divider()
Button("Zoom In") {
appState?.zoomIn()
}
.keyboardShortcut("+")
.disabled(!hasDocument)
Button("Zoom Out") {
appState?.zoomOut()
}
.keyboardShortcut("-")
.disabled(!hasDocument)
Button("Fit to Width") {
appState?.fitWidth()
}
.keyboardShortcut("9", modifiers: [.command])
.disabled(!hasDocument)
Button("Fit to Page") {
appState?.fitPage()
}
.keyboardShortcut("8", modifiers: [.command])
.disabled(!hasDocument)
Button("Two Pages Continuous") {
appState?.twoPageContinuous()
}
.keyboardShortcut("7", modifiers: [.command])
.disabled(!hasDocument)
}
CommandMenu("Annotate") {
Button("Highlight Selection") {
appState?.addHighlight()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(!hasDocument || !hasTextSelection)
Button("Underline Selection") {
appState?.addUnderline()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
.disabled(!hasDocument || !hasTextSelection)
Button("Comment on Selection") {
appState?.addComment()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(!hasDocument || !hasTextSelection)
Button("Add Free Text") {
appState?.addFreeText()
}
.keyboardShortcut("t", modifiers: [.command, .shift])
.disabled(!hasDocument)
}
CommandGroup(after: .windowArrangement) {
Button("Minimize") {
appState?.minimizeWindow()
}
.keyboardShortcut("m", modifiers: [.command])
.disabled(appState == nil)
Button("Toggle Full Screen") {
appState?.toggleFullScreen()
}
.keyboardShortcut("f", modifiers: [.command, .control])
.disabled(appState == nil)
}
}
private func openSettingsWindow() {
if !NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) {
NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
}
}
}

View File

@@ -1,5 +1,6 @@
import IHatePDFsCore
import SwiftUI
import UniformTypeIdentifiers
struct MainView: View {
@EnvironmentObject private var appState: AppState
@@ -56,12 +57,13 @@ private struct PDFReaderView: View {
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState
@State private var isDropTargeted = false
var body: some View {
VStack(spacing: 16) {
Image(systemName: "doc.richtext")
Image(systemName: isDropTargeted ? "tray.and.arrow.down" : "doc.richtext")
.font(.system(size: 48, weight: .regular))
.foregroundStyle(.secondary)
.foregroundColor(isDropTargeted ? .accentColor : .secondary)
Text("Open a PDF")
.font(.title2)
@@ -82,6 +84,20 @@ private struct EmptyDocumentView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(
isDropTargeted ? Color.accentColor : Color.clear,
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
)
.padding(18)
}
.onDrop(
of: [UTType.fileURL.identifier],
isTargeted: $isDropTargeted
) { providers in
appState.openDroppedDocument(from: providers)
}
}
}
@@ -97,6 +113,9 @@ private struct StatusBarView: View {
Spacer()
if appState.document != nil {
if appState.hasUnsentSidebarReplyDraft {
Text("Reply draft")
}
Text("\(appState.annotations.count) annotations")
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
}
@@ -129,6 +148,15 @@ private struct ReaderToolbar: ToolbarContent {
}
.disabled(appState.document == nil)
.help("Toggle Page Sidebar")
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")
}
ToolbarItemGroup(placement: .principal) {
@@ -137,7 +165,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Previous Page", systemImage: "chevron.up")
}
.disabled(appState.document == nil)
.disabled(!appState.canGoToPreviousPage)
.help("Previous Page")
TextField("Page", text: $appState.pageText)
@@ -156,7 +184,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Next Page", systemImage: "chevron.down")
}
.disabled(appState.document == nil)
.disabled(!appState.canGoToNextPage)
.help("Next Page")
}
@@ -216,7 +244,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Highlight", systemImage: "highlighter")
}
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Highlight Selection")
Button {
@@ -224,7 +252,7 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Underline", systemImage: "underline")
}
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Underline Selection")
Button {
@@ -234,7 +262,7 @@ private struct ReaderToolbar: ToolbarContent {
}
.accessibilityLabel("Comment on Selection")
.help("Comment on Selection")
.disabled(appState.document == nil)
.disabled(appState.document == nil || !appState.hasTextSelection)
}
ToolbarItemGroup {
@@ -277,8 +305,8 @@ private struct ReaderToolbar: ToolbarContent {
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.disabled(appState.document == nil)
.help("Save PDF")
.disabled(!appState.canSaveDocument)
.help(appState.saveHelpText)
Button {
appState.shareDocument()
@@ -288,16 +316,5 @@ private struct ReaderToolbar: ToolbarContent {
.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

@@ -6,6 +6,7 @@ import SwiftUI
final class AcademicPDFView: PDFView {
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onCancelPlacement: (() -> Void)?
var onSelectionComment: (() -> Void)?
var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)?
@@ -91,6 +92,11 @@ final class AcademicPDFView: PDFView {
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53, placementTool != nil {
onCancelPlacement?()
return
}
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event)
@@ -156,24 +162,15 @@ final class AcademicPDFView: PDFView {
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
if let direct = page.annotation(at: point),
let editable = editableParent(for: direct, on: page) {
let editable = editableParent(for: direct, on: page),
isInteractionPoint(point, on: direct, editable: editable) {
return editable
}
for annotation in page.annotations.reversed() {
guard let editable = editableParent(for: annotation, on: page) 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),
textMarkupInteractionBounds(for: editable, on: page).contains(point) {
if isInteractionPoint(point, on: annotation, editable: editable) {
return editable
}
}
@@ -181,6 +178,31 @@ final class AcademicPDFView: PDFView {
return nil
}
private func isInteractionPoint(
_ point: CGPoint,
on annotation: PDFAnnotation,
editable: PDFAnnotation
) -> Bool {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
return annotation.bounds.insetBy(dx: -10, dy: -10).contains(point)
}
if isTextMarkup(editable) {
return AnnotationHitTesting.containsTextMarkupPoint(point, in: editable)
}
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) {
return true
}
if let popup = editable.popup,
popup.bounds.insetBy(dx: -10, dy: -10).contains(point) {
return true
}
return false
}
private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
if let owner = popupOwner(for: annotation, on: page) {
return isEditableAcademicAnnotation(owner) ? owner : nil
@@ -228,20 +250,6 @@ final class AcademicPDFView: PDFView {
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
}
private func textMarkupInteractionBounds(
for annotation: PDFAnnotation,
on page: PDFPage
) -> CGRect {
var bounds = annotation.bounds.insetBy(dx: -48, dy: -48)
if let popup = annotation.popup {
bounds = bounds.union(popup.bounds.insetBy(dx: -16, dy: -16))
}
let pageBounds = page.bounds(for: displayBox).insetBy(dx: -64, dy: -64)
return bounds.intersection(pageBounds)
}
private func closeNativePopups(on page: PDFPage) {
for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
@@ -253,8 +261,16 @@ final class AcademicPDFView: PDFView {
}
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
let isSelectionComment = annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String
== AnnotationKeys.appKindComment
let hasCommentText = !AnnotationKeys.commentText(for: annotation)
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
return isSelectionComment || hasCommentText
}
return AnnotationKeys.annotation(annotation, hasSubtype: .underline)
|| AnnotationKeys.annotation(annotation, hasSubtype: .text)
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
}
@@ -279,6 +295,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
appState.placePendingAnnotation(on: page, near: point)
}
}
view.onCancelPlacement = {
Task { @MainActor in
appState.cancelPlacementTool()
}
}
view.onSelectionComment = {
Task { @MainActor in
appState.addComment()
@@ -364,6 +385,45 @@ struct PDFKitRepresentedView: NSViewRepresentable {
of: view,
preferredEdge: preferredEdge(for: anchor, in: view)
)
focusCommentEditor(in: controller.view)
}
private func focusCommentEditor(in view: NSView) {
Self.focusFirstTextView(in: view)
DispatchQueue.main.async { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak view] in
guard let view else { return }
Self.focusFirstTextView(in: view)
}
}
private static func focusFirstTextView(in view: NSView) {
view.layoutSubtreeIfNeeded()
guard let textView = firstTextView(in: view) else { return }
textView.window?.makeFirstResponder(textView)
textView.setSelectedRange(NSRange(location: textView.string.utf16.count, length: 0))
textView.insertionPointColor = .labelColor
textView.needsDisplay = true
}
private static func firstTextView(in view: NSView) -> NSTextView? {
if let textView = view as? NSTextView {
return textView
}
for subview in view.subviews {
if let textView = firstTextView(in: subview) {
return textView
}
}
return nil
}
private func dismissCurrent(commit: Bool) {

View File

@@ -0,0 +1,106 @@
import AppKit
import IHatePDFsCore
import SwiftUI
extension View {
func commitOnPlainReturn(isEnabled: Bool = true, _ action: @escaping () -> Void) -> some View {
modifier(ReturnKeyCommitMonitor(isEnabled: isEnabled, action: action))
}
}
private struct ReturnKeyCommitMonitor: ViewModifier {
let isEnabled: Bool
let action: () -> Void
@State private var monitor: Any?
@State private var eventWindowBox = EventWindowBox()
func body(content: Content) -> some View {
content
.background(
EventWindowReader { window in
eventWindowBox.windowID = window.map(ObjectIdentifier.init)
}
)
.onAppear {
eventWindowBox.isEnabled = isEnabled
installMonitor()
}
.onChange(of: isEnabled) { value in
eventWindowBox.isEnabled = value
}
.onDisappear {
removeMonitor()
}
}
private func installMonitor() {
removeMonitor()
let eventWindowBox = eventWindowBox
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
guard eventWindowBox.isEnabled,
shouldCommit(event),
eventWindowBox.windowID.map({ event.window.map(ObjectIdentifier.init) == $0 }) == true
else {
return event
}
action()
return nil
}
}
private func removeMonitor() {
guard let monitor else { return }
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
private func shouldCommit(_ event: NSEvent) -> Bool {
let textView = event.window?.firstResponder as? NSTextView
let isEditableMultilineText = textView?.isEditable == true && textView?.isFieldEditor == false
return ReturnKeyCommitPolicy.shouldCommit(
keyCode: UInt16(event.keyCode),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
command: event.modifierFlags.contains(.command),
control: event.modifierFlags.contains(.control),
isEditableMultilineText: isEditableMultilineText
)
}
}
private final class EventWindowBox {
var windowID: ObjectIdentifier?
var isEnabled = true
}
private struct EventWindowReader: NSViewRepresentable {
let onWindowChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> WindowReportingView {
let view = WindowReportingView()
view.onWindowChange = onWindowChange
return view
}
func updateNSView(_ view: WindowReportingView, context: Context) {
view.onWindowChange = onWindowChange
view.reportWindow()
}
}
private final class WindowReportingView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
}
}

View File

@@ -98,6 +98,7 @@ struct CommentsReviewSidebar: View {
@State private var showsSearch = false
@State private var showsFilters = false
@State private var showsAdvancedFilters = false
@FocusState private var isCommentSearchFocused: Bool
private var groupedComments: [(pageIndex: Int, items: [AnnotationSnapshot])] {
let grouped = Dictionary(grouping: appState.topLevelComments, by: \.pageIndex)
@@ -106,6 +107,27 @@ struct CommentsReviewSidebar: View {
.sorted { $0.pageIndex < $1.pageIndex }
}
private var visibleCommentCount: Int {
appState.topLevelComments.reduce(0) { partial, item in
partial + 1 + (appState.repliesByParent[item.id]?.count ?? 0)
}
}
private var isFilteringComments: Bool {
hasActiveCommentSearch || hasActiveCommentFilters
}
private var hasActiveCommentSearch: Bool {
!appState.commentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var hasActiveCommentFilters: Bool {
appState.commentFilter != .all
|| appState.selectedKindFilter != nil
|| appState.selectedAuthorFilter != "All Authors"
|| appState.selectedStatusFilter != ReviewState.allStatuses
}
var body: some View {
VStack(spacing: 0) {
header
@@ -132,7 +154,7 @@ struct CommentsReviewSidebar: View {
.font(.headline)
.lineLimit(1)
Text("\(appState.annotations.count)")
Text("\(visibleCommentCount)")
.font(.headline.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -141,18 +163,31 @@ struct CommentsReviewSidebar: View {
Button {
showsSearch.toggle()
if showsSearch {
focusCommentSearch()
} else {
isCommentSearchFocused = false
}
} label: {
Label("Search Comments", systemImage: showsSearch ? "magnifyingglass.circle.fill" : "magnifyingglass")
Label(
"Search Comments",
systemImage: (showsSearch || hasActiveCommentSearch) ? "magnifyingglass.circle.fill" : "magnifyingglass"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentSearch ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Search Comments")
Button {
showsFilters.toggle()
} label: {
Label("Filter Comments", systemImage: showsFilters ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
Label(
"Filter Comments",
systemImage: (showsFilters || hasActiveCommentFilters) ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle"
)
}
.labelStyle(.iconOnly)
.foregroundStyle(hasActiveCommentFilters ? InterfacePalette.actionText(for: colorScheme) : InterfacePalette.secondaryText(for: colorScheme))
.help("Filter Comments")
}
.padding(.horizontal, 10)
@@ -184,6 +219,7 @@ struct CommentsReviewSidebar: View {
}
}
.buttonStyle(.plain)
.disabled(!appState.hasTextSelection)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.help("Select text, then add a comment")
@@ -194,6 +230,10 @@ struct CommentsReviewSidebar: View {
if showsSearch {
TextField("Search comments", text: $appState.commentSearchText)
.textFieldStyle(.roundedBorder)
.focused($isCommentSearchFocused)
.onAppear {
focusCommentSearch()
}
}
if showsFilters {
@@ -238,23 +278,75 @@ struct CommentsReviewSidebar: View {
.padding(10)
}
private func focusCommentSearch() {
DispatchQueue.main.async {
isCommentSearchFocused = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
isCommentSearchFocused = true
}
}
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
)
Group {
if groupedComments.isEmpty {
CommentsEmptyState(isFiltering: isFilteringComments)
} else {
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,
isFiltering: isFilteringComments
)
}
}
.padding(.vertical, 4)
}
}
.padding(.vertical, 4)
}
}
}
private struct CommentsEmptyState: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
let isFiltering: Bool
var body: some View {
VStack(spacing: 9) {
Image(systemName: isFiltering ? "line.3.horizontal.decrease.circle" : "text.bubble")
.font(.system(size: 28, weight: .regular))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
Text(isFiltering ? "No matching comments" : "No comments yet")
.font(.callout.weight(.semibold))
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
Text(isFiltering ? "Adjust the search or filters to show more comments." : "Select text in the PDF, then add a comment.")
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
if isFiltering {
Button {
appState.clearCommentFilters()
} label: {
Label("Clear Filters", systemImage: "xmark.circle")
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct PageCommentGroup: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@@ -262,40 +354,37 @@ private struct PageCommentGroup: View {
let items: [AnnotationSnapshot]
let repliesByParent: [String: [AnnotationSnapshot]]
let showsPageHeader: Bool
let isFiltering: Bool
private var isCollapsed: Bool {
showsPageHeader && appState.collapsedPageIndexes.contains(pageIndex)
showsPageHeader && !isFiltering && appState.collapsedPageIndexes.contains(pageIndex)
}
private var visibleItemCount: Int {
items.reduce(0) { partial, item in
partial + 1 + (repliesByParent[item.id]?.count ?? 0)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
if showsPageHeader {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
if isFiltering {
pageHeader
.help("Filtered results are expanded")
} else {
Button {
if isCollapsed {
appState.collapsedPageIndexes.remove(pageIndex)
} else {
appState.collapsedPageIndexes.insert(pageIndex)
}
} label: {
pageHeader
}
} 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")
}
.buttonStyle(.plain)
.help(isCollapsed ? "Expand Page Comments" : "Collapse Page Comments")
}
if !isCollapsed {
@@ -307,6 +396,25 @@ private struct PageCommentGroup: View {
}
}
}
private var pageHeader: some View {
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("\(visibleItemCount)")
.font(.caption.monospacedDigit())
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
}
.padding(.horizontal, 10)
.padding(.top, 7)
.padding(.bottom, 5)
}
}
private struct CommentRow: View {
@@ -574,6 +682,11 @@ private struct SidebarReplyComposer: View {
isFocused = true
}
}
.commitOnPlainReturn {
if !appState.sidebarReplyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
appState.commitSidebarReply()
}
}
}
}

View File

@@ -0,0 +1,84 @@
import AppKit
import Foundation
public enum AnnotationColorPreference {
public static func color(
from storageValue: String?,
fallback: NSColor,
minimumAlpha: CGFloat = 0
) -> NSColor {
guard let storageValue,
let color = color(from: storageValue)
else {
return normalized(fallback, fallback: fallback, minimumAlpha: minimumAlpha)
}
return normalized(color, fallback: fallback, minimumAlpha: minimumAlpha)
}
public static func storageString(for color: NSColor, fallback: String = "#FFD11F85") -> String {
guard let rgb = color.usingColorSpace(.deviceRGB) else {
return fallback
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(
format: "#%02X%02X%02X%02X",
byte(red),
byte(green),
byte(blue),
byte(alpha)
)
}
private static func color(from storageValue: String) -> NSColor? {
var raw = storageValue.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.hasPrefix("#") {
raw.removeFirst()
}
guard raw.count == 8,
let value = UInt32(raw, radix: 16)
else {
return nil
}
let red = CGFloat((value >> 24) & 0xFF) / 255
let green = CGFloat((value >> 16) & 0xFF) / 255
let blue = CGFloat((value >> 8) & 0xFF) / 255
let alpha = CGFloat(value & 0xFF) / 255
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
}
private static func normalized(
_ color: NSColor,
fallback: NSColor,
minimumAlpha: CGFloat
) -> NSColor {
guard let rgb = color.usingColorSpace(.deviceRGB) else {
return fallback
}
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return NSColor(
deviceRed: red,
green: green,
blue: blue,
alpha: max(alpha, minimumAlpha)
)
}
private static func byte(_ value: CGFloat) -> Int {
max(0, min(255, Int((value * 255).rounded())))
}
}

View File

@@ -4,16 +4,16 @@ import PDFKit
public enum AcademicAnnotationPalette {
public static let comment = NSColor(
calibratedRed: 0.88,
green: 0.72,
blue: 0.46,
alpha: 0.10
calibratedRed: 0.98,
green: 0.64,
blue: 0.16,
alpha: 0.30
)
public static let highlight = NSColor(
calibratedRed: 0.88,
green: 0.72,
blue: 0.46,
alpha: 0.24
calibratedRed: 1.0,
green: 0.78,
blue: 0.0,
alpha: 0.52
)
public static let underline = NSColor(
calibratedRed: 0.48,
@@ -58,10 +58,13 @@ public enum MarkupAnnotationStyle {
}
}
var color: NSColor {
func color(
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
commentColor: NSColor = AcademicAnnotationPalette.comment
) -> NSColor {
switch self {
case .comment: return AcademicAnnotationPalette.comment
case .highlight: return AcademicAnnotationPalette.highlight
case .comment: return commentColor
case .highlight: return highlightColor
case .underline: return AcademicAnnotationPalette.underline
}
}
@@ -95,6 +98,8 @@ public enum AnnotationFactory {
style: MarkupAnnotationStyle,
comment: String,
author: String,
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
commentColor: NSColor = AcademicAnnotationPalette.comment,
date: Date = Date()
) -> [AnnotationInsertion] {
let lineSelections = selection.selectionsByLine()
@@ -120,7 +125,7 @@ public enum AnnotationFactory {
}
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
annotation.markupType = style.markupType
annotation.color = style.color
annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
annotation.quadrilateralPoints = group.rects.flatMap { rect in
quadPoints(for: rect, relativeTo: unionRect)
}
@@ -266,7 +271,9 @@ public enum AnnotationFactory {
date: Date
) {
AnnotationKeys.setCommentText(comment, for: annotation)
annotation.contents = comment
annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: comment
annotation.userName = author
annotation.modificationDate = date
annotation.shouldDisplay = true
@@ -357,6 +364,51 @@ public enum AnnotationFactory {
@discardableResult
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
let contents = AnnotationKeys.commentText(for: annotation)
return restoreCommentText(contents, forExportIn: annotation)
}
@discardableResult
public static func prepareForPreviewCompatibleExport(
_ annotation: PDFAnnotation,
on page: PDFPage
) -> Bool {
let contents = AnnotationKeys.commentText(for: annotation)
var didChange = restoreCommentText(contents, forExportIn: annotation)
guard !AnnotationKeys.annotation(annotation, hasSubtype: .freeText) else {
return didChange
}
if let popup = annotation.popup {
if popup.page != nil {
page.removeAnnotation(popup)
}
annotation.popup = nil
didChange = true
}
let linkedPopups = page.annotations.filter { candidate in
guard AnnotationKeys.annotation(candidate, hasSubtype: .popup) else { return false }
return parentAnnotation(for: candidate) === annotation
}
for popup in linkedPopups {
page.removeAnnotation(popup)
didChange = true
}
if restoreCommentText(contents, forExportIn: annotation) {
didChange = true
}
return didChange
}
@discardableResult
private static func restoreCommentText(
_ contents: String,
forExportIn annotation: PDFAnnotation
) -> Bool {
let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: contents

View File

@@ -0,0 +1,46 @@
import Foundation
import PDFKit
public enum AnnotationHitTesting {
public static func containsTextMarkupPoint(
_ point: CGPoint,
in annotation: PDFAnnotation,
tolerance: CGFloat = 3
) -> Bool {
guard AnnotationKeys.annotation(annotation, hasSubtype: .highlight)
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline)
else {
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
let quadPoints = annotation.quadrilateralPoints ?? []
guard !quadPoints.isEmpty else {
return annotation.bounds.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
var index = 0
while index + 3 < quadPoints.count {
let points = quadPoints[index..<(index + 4)].map { value in
let relativePoint = value.pointValue
return CGPoint(
x: annotation.bounds.minX + relativePoint.x,
y: annotation.bounds.minY + relativePoint.y
)
}
if boundingRect(for: points).insetBy(dx: -tolerance, dy: -tolerance).contains(point) {
return true
}
index += 4
}
return false
}
private static func boundingRect(for points: [CGPoint]) -> CGRect {
guard let first = points.first else { return .null }
return points.dropFirst().reduce(CGRect(origin: first, size: .zero)) { rect, point in
rect.union(CGRect(origin: point, size: .zero))
}
}
}

View File

@@ -157,11 +157,16 @@ public enum AnnotationKeys {
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
public static func commentText(for annotation: PDFAnnotation) -> String {
if let value = annotation.value(forAnnotationKey: appCommentText) as? String {
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
!value.isEmpty {
return value
}
return annotation.contents ?? ""
if let contents = annotation.contents, !contents.isEmpty {
return contents
}
return annotation.popup?.contents ?? ""
}
public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
@@ -287,54 +292,42 @@ public enum AnnotationKeys {
public enum AnnotationReader {
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
var result: [AnnotationSnapshot] = []
var namedAnnotationIDs: [String: String]?
for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex) else { continue }
for (annotationIndex, annotation) in page.annotations.enumerated() {
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
let kind = AcademicAnnotationKind(annotation: annotation)
let contents = AnnotationKeys.commentText(for: annotation)
guard kind != .other || !contents.isEmpty else { continue }
let id = AnnotationKeys.stableID(
for: annotation,
pageIndex: pageIndex,
annotationIndex: annotationIndex
)
let pageLabel = page.label ?? "\(pageIndex + 1)"
let author = annotation.userName
?? annotation.value(forAnnotationKey: .textLabel) as? String
?? "Unknown"
let createdAt = AnnotationKeys.dateValue(for: AnnotationKeys.creationDate, in: annotation)
?? annotation.modificationDate
let status = annotation.value(forAnnotationKey: AnnotationKeys.state) as? String
?? "Unmarked"
let parentID = AnnotationKeys.parentID(for: annotation, document: document)
result.append(
AnnotationSnapshot(
id: id,
pageIndex: pageIndex,
pageLabel: pageLabel,
annotationIndex: annotationIndex,
kind: kind,
author: author,
createdAt: createdAt,
modifiedAt: annotation.modificationDate,
status: status,
contents: contents,
bounds: annotation.bounds,
annotation: annotation,
page: page,
parentID: parentID
)
)
}
result.append(contentsOf: snapshots(
in: document,
page: page,
pageIndex: pageIndex,
namedAnnotationIDs: &namedAnnotationIDs
))
}
return result.sorted { left, right in
return sorted(result)
}
public static func snapshots(in document: PDFDocument, pages: [PDFPage]) -> [AnnotationSnapshot] {
var result: [AnnotationSnapshot] = []
var seenPageIndexes = Set<Int>()
var namedAnnotationIDs: [String: String]?
for page in pages {
let pageIndex = document.index(for: page)
guard pageIndex != NSNotFound, seenPageIndexes.insert(pageIndex).inserted else { continue }
result.append(contentsOf: snapshots(
in: document,
page: page,
pageIndex: pageIndex,
namedAnnotationIDs: &namedAnnotationIDs
))
}
return sorted(result)
}
public static func sorted(_ snapshots: [AnnotationSnapshot]) -> [AnnotationSnapshot] {
snapshots.sorted { left, right in
if left.pageIndex != right.pageIndex {
return left.pageIndex < right.pageIndex
}
@@ -344,4 +337,112 @@ public enum AnnotationReader {
return left.bounds.minX < right.bounds.minX
}
}
private static func snapshots(
in document: PDFDocument,
page: PDFPage,
pageIndex: Int,
namedAnnotationIDs: inout [String: String]?
) -> [AnnotationSnapshot] {
var result: [AnnotationSnapshot] = []
for (annotationIndex, annotation) in page.annotations.enumerated() {
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue }
let kind = AcademicAnnotationKind(annotation: annotation)
let contents = AnnotationKeys.commentText(for: annotation)
guard kind != .other || !contents.isEmpty else { continue }
let id = AnnotationKeys.stableID(
for: annotation,
pageIndex: pageIndex,
annotationIndex: annotationIndex
)
let pageLabel = page.label ?? "\(pageIndex + 1)"
let author = annotation.userName
?? annotation.value(forAnnotationKey: .textLabel) as? String
?? "Unknown"
let createdAt = AnnotationKeys.dateValue(for: AnnotationKeys.creationDate, in: annotation)
?? annotation.modificationDate
let status = annotation.value(forAnnotationKey: AnnotationKeys.state) as? String
?? "Unmarked"
let parentID = parentID(
for: annotation,
document: document,
namedAnnotationIDs: &namedAnnotationIDs
)
result.append(
AnnotationSnapshot(
id: id,
pageIndex: pageIndex,
pageLabel: pageLabel,
annotationIndex: annotationIndex,
kind: kind,
author: author,
createdAt: createdAt,
modifiedAt: annotation.modificationDate,
status: status,
contents: contents,
bounds: annotation.bounds,
annotation: annotation,
page: page,
parentID: parentID
)
)
}
return result
}
private static func parentID(
for annotation: PDFAnnotation,
document: PDFDocument,
namedAnnotationIDs: inout [String: String]?
) -> String? {
if let parentID = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? String,
!parentID.isEmpty {
if namedAnnotationIDs == nil {
namedAnnotationIDs = makeNamedAnnotationIDs(in: document)
}
return namedAnnotationIDs?[parentID]
}
guard let parent = annotation.value(forAnnotationKey: AnnotationKeys.inReplyTo) as? PDFAnnotation else {
return nil
}
guard let page = parent.page,
document.index(for: page) != NSNotFound
else {
return parent.value(forAnnotationKey: .name) as? String
}
let pageIndex = document.index(for: page)
let annotationIndex = page.annotations.firstIndex(where: { $0 === parent }) ?? 0
return AnnotationKeys.stableID(for: parent, pageIndex: pageIndex, annotationIndex: annotationIndex)
}
private static func makeNamedAnnotationIDs(in document: PDFDocument) -> [String: String] {
var result: [String: String] = [:]
for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex) else { continue }
for (annotationIndex, annotation) in page.annotations.enumerated() {
guard let name = annotation.value(forAnnotationKey: .name) as? String,
!name.isEmpty
else {
continue
}
result[name] = AnnotationKeys.stableID(
for: annotation,
pageIndex: pageIndex,
annotationIndex: annotationIndex
)
}
}
return result
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
import UniformTypeIdentifiers
public enum PDFFileSelection {
public static func isPDFFileURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let resourceValues = try? url.resourceValues(forKeys: [.contentTypeKey, .isDirectoryKey])
if resourceValues?.isDirectory == true {
return false
}
if let contentType = resourceValues?.contentType {
return contentType.conforms(to: .pdf)
}
return url.pathExtension.localizedCaseInsensitiveCompare("pdf") == .orderedSame
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
public enum ReturnKeyCommitPolicy {
public static func shouldCommit(
keyCode: UInt16,
shift: Bool,
option: Bool,
command: Bool,
control: Bool,
isEditableMultilineText: Bool
) -> Bool {
guard isEditableMultilineText else { return false }
guard keyCode == 36 || keyCode == 76 else { return false }
return !shift && !option && !command && !control
}
}

View File

@@ -0,0 +1,74 @@
import XCTest
import AppKit
@testable import IHatePDFsCore
final class AnnotationColorPreferenceTests: XCTestCase {
func testColorPreferenceRoundTripsRGBAStorage() throws {
let color = NSColor(deviceRed: 0.25, green: 0.5, blue: 0.75, alpha: 0.4)
let storage = AnnotationColorPreference.storageString(for: color)
XCTAssertEqual(storage, "#4080BF66")
let decoded = AnnotationColorPreference.color(
from: storage,
fallback: AcademicAnnotationPalette.highlight
)
let components = try rgbaComponents(decoded)
XCTAssertEqual(components.red, 0x40 / 255, accuracy: 0.001)
XCTAssertEqual(components.green, 0x80 / 255, accuracy: 0.001)
XCTAssertEqual(components.blue, 0xBF / 255, accuracy: 0.001)
XCTAssertEqual(components.alpha, 0x66 / 255, accuracy: 0.001)
}
func testColorPreferenceUsesFallbackForInvalidStorage() throws {
let decoded = AnnotationColorPreference.color(
from: "not-a-color",
fallback: AcademicAnnotationPalette.comment
)
try XCTAssertColor(decoded, equals: AcademicAnnotationPalette.comment)
}
func testColorPreferenceAppliesMinimumAlphaWithoutChangingRGB() throws {
let decoded = AnnotationColorPreference.color(
from: "#33669905",
fallback: AcademicAnnotationPalette.highlight,
minimumAlpha: 0.3
)
let components = try rgbaComponents(decoded)
XCTAssertEqual(components.red, 0x33 / 255, accuracy: 0.001)
XCTAssertEqual(components.green, 0x66 / 255, accuracy: 0.001)
XCTAssertEqual(components.blue, 0x99 / 255, accuracy: 0.001)
XCTAssertEqual(components.alpha, 0.3, accuracy: 0.001)
}
private func XCTAssertColor(
_ actual: NSColor,
equals expected: NSColor,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let actualComponents = try rgbaComponents(actual, file: file, line: line)
let expectedComponents = try rgbaComponents(expected, file: file, line: line)
XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line)
}
private func rgbaComponents(
_ color: NSColor,
file: StaticString = #filePath,
line: UInt = #line
) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return (red, green, blue, alpha)
}
}

View File

@@ -42,6 +42,97 @@ final class AnnotationFactoryTests: XCTestCase {
)
}
func testHighlightUsesHigherContrastDefaultColor() 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 insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "",
author: "Professor"
).first
)
let annotationColor = try rgbaComponents(insertion.annotation.color)
let defaultColor = try rgbaComponents(AcademicAnnotationPalette.highlight)
XCTAssertEqual(annotationColor.red, defaultColor.red, accuracy: 0.001)
XCTAssertEqual(annotationColor.green, defaultColor.green, accuracy: 0.001)
XCTAssertEqual(annotationColor.blue, defaultColor.blue, accuracy: 0.001)
XCTAssertEqual(annotationColor.alpha, defaultColor.alpha, accuracy: 0.001)
XCTAssertGreaterThanOrEqual(annotationColor.alpha, 0.5)
}
func testHighlightCreatedWithoutCommentHasNoPopupOrCommentText() 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 insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "",
author: "Professor"
).first
)
XCTAssertNil(insertion.popup)
XCTAssertEqual(AnnotationKeys.commentText(for: insertion.annotation), "")
XCTAssertNil(insertion.annotation.contents)
XCTAssertEqual(AcademicAnnotationKind(annotation: insertion.annotation), .highlight)
}
func testHighlightUsesConfiguredColor() 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 configuredColor = NSColor(
calibratedRed: 0.18,
green: 0.58,
blue: 0.95,
alpha: 0.52
)
let insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "",
author: "Professor",
highlightColor: configuredColor
).first
)
try XCTAssertColor(insertion.annotation.color, equals: configuredColor)
}
func testSelectionBoundCommentUsesConfiguredColor() 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 configuredColor = NSColor(
calibratedRed: 0.88,
green: 0.18,
blue: 0.26,
alpha: 0.34
)
let insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .comment,
comment: "",
author: "Professor",
commentColor: configuredColor
).first
)
try XCTAssertColor(insertion.annotation.color, equals: configuredColor)
XCTAssertEqual(
insertion.annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String,
AnnotationKeys.appKindComment
)
}
func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
@@ -205,6 +296,108 @@ final class AnnotationFactoryTests: XCTestCase {
})
}
func testPreviewCompatibleExportKeepsMarkupCommentWithoutPopupAnnotation() 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 insertion = try XCTUnwrap(
AnnotationFactory.markupInsertions(
from: selection,
style: .highlight,
comment: "Preview should show this comment.",
author: "Professor"
).first
)
page.addAnnotation(insertion.annotation)
if let popup = insertion.popup {
page.addAnnotation(popup)
}
XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(insertion.annotation, on: page))
XCTAssertNil(insertion.annotation.popup)
XCTAssertEqual(insertion.annotation.contents, "Preview should show this comment.")
XCTAssertFalse(page.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .popup)
})
let reopenedPage = try saveAndReopen(document).page(at: 0).unwrap()
let highlights = reopenedPage.annotations.filter {
AnnotationKeys.annotation($0, hasSubtype: .highlight)
}
XCTAssertEqual(highlights.count, 1)
XCTAssertEqual(highlights.first?.contents, "Preview should show this comment.")
XCTAssertFalse(reopenedPage.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .popup)
})
}
func testPreviewCompatibleExportRecoversPopupOnlyCommentText() 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 bounds = selection.bounds(for: page)
let annotation = PDFAnnotation(bounds: bounds, forType: .highlight, withProperties: nil)
annotation.markupType = .highlight
annotation.color = AcademicAnnotationPalette.highlight
annotation.userName = "Professor"
annotation.quadrilateralPoints = [
NSValue(point: CGPoint(x: 0, y: bounds.height)),
NSValue(point: CGPoint(x: bounds.width, y: bounds.height)),
NSValue(point: .zero),
NSValue(point: CGPoint(x: bounds.width, y: 0))
]
let popup = PDFAnnotation(
bounds: CGRect(x: 360, y: 620, width: 220, height: 90),
forType: .popup,
withProperties: nil
)
popup.contents = "Popup-only comment from another reader."
annotation.popup = popup
page.addAnnotation(annotation)
page.addAnnotation(popup)
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup-only comment from another reader.")
XCTAssertTrue(AnnotationFactory.prepareForPreviewCompatibleExport(annotation, on: page))
XCTAssertNil(annotation.popup)
XCTAssertEqual(annotation.contents, "Popup-only comment from another reader.")
XCTAssertFalse(page.annotations.contains {
AnnotationKeys.annotation($0, hasSubtype: .popup)
})
}
func testEmptyAppCommentTextFallsBackToStandardContents() throws {
let annotation = PDFAnnotation(
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
forType: .highlight,
withProperties: nil
)
AnnotationKeys.setCommentText("", for: annotation)
annotation.contents = "Comment added by another PDF reader."
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Comment added by another PDF reader.")
}
func testEmptyAppCommentTextFallsBackToPopupContents() throws {
let annotation = PDFAnnotation(
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
forType: .highlight,
withProperties: nil
)
let popup = PDFAnnotation(
bounds: CGRect(x: 360, y: 620, width: 220, height: 90),
forType: .popup,
withProperties: nil
)
AnnotationKeys.setCommentText("", for: annotation)
popup.contents = "Popup comment added by another PDF reader."
annotation.popup = popup
XCTAssertEqual(AnnotationKeys.commentText(for: annotation), "Popup comment added by another PDF reader.")
}
func testAddingAnnotationPreservesPriorAnnotation() throws {
let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0))
@@ -379,6 +572,66 @@ final class AnnotationFactoryTests: XCTestCase {
XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id)
}
func testUnresolvedStringReplyParentIDStaysVisibleAsTopLevelReply() throws {
let document = PDFDocument()
let page = PDFPage()
document.insert(page, at: 0)
let orphanedReply = PDFAnnotation(
bounds: CGRect(x: 100, y: 100, width: 24, height: 24),
forType: .text,
withProperties: nil
)
AnnotationFactory.standardize(
orphanedReply,
comment: "Reply from an external reader.",
author: "Reader",
date: Date()
)
_ = orphanedReply.setValue("missing-parent", forAnnotationKey: AnnotationKeys.inReplyTo)
_ = orphanedReply.setValue("R", forAnnotationKey: AnnotationKeys.replyType)
page.addAnnotation(orphanedReply)
let snapshot = try XCTUnwrap(AnnotationReader.snapshots(in: document).first)
XCTAssertEqual(snapshot.kind, .reply)
XCTAssertNil(snapshot.parentID)
XCTAssertFalse(snapshot.isReply)
XCTAssertEqual(snapshot.contents, "Reply from an external reader.")
}
func testPageScopedSnapshotsOnlyReadRequestedPages() throws {
let document = PDFDocument()
let firstPage = PDFPage()
let secondPage = PDFPage()
let thirdPage = PDFPage()
document.insert(firstPage, at: 0)
document.insert(secondPage, at: 1)
document.insert(thirdPage, at: 2)
let firstAnnotation = AnnotationFactory.noteInsertion(
on: firstPage,
near: CGPoint(x: 100, y: 100),
comment: "First page note",
author: "Professor"
).annotation
firstPage.addAnnotation(firstAnnotation)
let thirdAnnotation = AnnotationFactory.noteInsertion(
on: thirdPage,
near: CGPoint(x: 200, y: 200),
comment: "Third page note",
author: "Professor"
).annotation
thirdPage.addAnnotation(thirdAnnotation)
let scopedSnapshots = AnnotationReader.snapshots(in: document, pages: [thirdPage, thirdPage])
XCTAssertEqual(scopedSnapshots.count, 1)
XCTAssertEqual(scopedSnapshots.first?.contents, "Third page note")
XCTAssertEqual(scopedSnapshots.first?.pageIndex, 2)
XCTAssertFalse(scopedSnapshots.contains { $0.annotation === firstAnnotation })
}
func testFreeTextCreatesStandardFreeTextAnnotation() throws {
let page = PDFPage()
let insertion = AnnotationFactory.freeTextInsertion(
@@ -439,6 +692,35 @@ final class AnnotationFactoryTests: XCTestCase {
try? FileManager.default.removeItem(at: outputURL)
return reopened
}
private func XCTAssertColor(
_ actual: NSColor,
equals expected: NSColor,
file: StaticString = #filePath,
line: UInt = #line
) throws {
let actualComponents = try rgbaComponents(actual, file: file, line: line)
let expectedComponents = try rgbaComponents(expected, file: file, line: line)
XCTAssertEqual(actualComponents.red, expectedComponents.red, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.green, expectedComponents.green, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.blue, expectedComponents.blue, accuracy: 0.001, file: file, line: line)
XCTAssertEqual(actualComponents.alpha, expectedComponents.alpha, accuracy: 0.001, file: file, line: line)
}
private func rgbaComponents(
_ color: NSColor,
file: StaticString = #filePath,
line: UInt = #line
) throws -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
let rgb = try XCTUnwrap(color.usingColorSpace(.deviceRGB), file: file, line: line)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
rgb.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return (red, green, blue, alpha)
}
}
private extension Optional {

View File

@@ -0,0 +1,38 @@
import XCTest
import PDFKit
@testable import IHatePDFsCore
final class AnnotationHitTestingTests: XCTestCase {
func testTextMarkupHitTestingUsesQuadPointsInsteadOfUnionBounds() {
let annotation = PDFAnnotation(
bounds: CGRect(x: 10, y: 20, width: 100, height: 60),
forType: .highlight,
withProperties: nil
)
annotation.quadrilateralPoints = [
NSValue(point: CGPoint(x: 0, y: 55)),
NSValue(point: CGPoint(x: 100, y: 55)),
NSValue(point: CGPoint(x: 0, y: 45)),
NSValue(point: CGPoint(x: 100, y: 45)),
NSValue(point: CGPoint(x: 0, y: 15)),
NSValue(point: CGPoint(x: 100, y: 15)),
NSValue(point: CGPoint(x: 0, y: 5)),
NSValue(point: CGPoint(x: 100, y: 5))
]
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 70), in: annotation))
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation))
XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 50), in: annotation))
}
func testTextMarkupHitTestingFallsBackToBoundsWithoutQuadPoints() {
let annotation = PDFAnnotation(
bounds: CGRect(x: 10, y: 20, width: 100, height: 20),
forType: .underline,
withProperties: nil
)
XCTAssertTrue(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 30), in: annotation))
XCTAssertFalse(AnnotationHitTesting.containsTextMarkupPoint(CGPoint(x: 50, y: 60), in: annotation))
}
}

View File

@@ -0,0 +1,24 @@
import XCTest
@testable import IHatePDFsCore
final class PDFFileSelectionTests: XCTestCase {
func testPDFFileURLAcceptsPDFExtensionsCaseInsensitively() {
XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.pdf")))
XCTAssertTrue(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/article.PDF")))
}
func testPDFFileURLRejectsNonPDFAndRemoteURLs() {
XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(fileURLWithPath: "/tmp/notes.txt")))
XCTAssertFalse(PDFFileSelection.isPDFFileURL(URL(string: "https://example.com/article.pdf")!))
}
func testPDFFileURLRejectsDirectoriesNamedLikePDFs() throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("pdf")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
XCTAssertFalse(PDFFileSelection.isPDFFileURL(directory))
}
}

View File

@@ -0,0 +1,48 @@
import XCTest
@testable import IHatePDFsCore
final class ReturnKeyCommitPolicyTests: XCTestCase {
func testPlainReturnCommitsInEditableMultilineText() {
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: false,
control: false,
isEditableMultilineText: true
))
}
func testKeypadEnterCommitsInEditableMultilineText() {
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 76,
shift: false,
option: false,
command: false,
control: false,
isEditableMultilineText: true
))
}
func testShiftReturnDoesNotCommitSoTextViewCanInsertNewline() {
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: true,
option: false,
command: false,
control: false,
isEditableMultilineText: true
))
}
func testReturnDoesNotCommitOutsideEditableMultilineText() {
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: false,
control: false,
isEditableMultilineText: false
))
}
}

44
docs/APP_STORE.md Normal file
View File

@@ -0,0 +1,44 @@
# Mac App Store Release
Bundle ID: `net.akkolli.ihatepdfs`
Current App Store build values:
- `CFBundleShortVersionString`: `0.3.0`
- `CFBundleVersion`: `4`
- Privacy policy URL: `https://www.akkolli.net/ihatepdfs/privacy`
## Required Apple Developer Items
- An explicit macOS App ID for `net.akkolli.ihatepdfs`.
- An App Store provisioning profile for that App ID.
- An application signing certificate installed in Keychain, usually named `Apple Distribution: ...` or `3rd Party Mac Developer Application: ...`.
- An installer signing certificate installed in Keychain, usually named `3rd Party Mac Developer Installer: ...`. Apple may label this certificate type as Mac Installer Distribution in the developer portal or Xcode.
The app only needs these sandbox entitlements right now:
- `com.apple.security.app-sandbox`
- `com.apple.security.files.user-selected.read-write`
Do not add network, Apple Events, Downloads-folder, or bookmark entitlements unless the app gains a feature that requires them.
## Build The Upload Package
Download the App Store provisioning profile from Apple Developer, then run:
```sh
APP_SIGNING_IDENTITY="3rd Party Mac Developer Application: Your Name (TEAMID)" \
INSTALLER_SIGNING_IDENTITY="3rd Party Mac Developer Installer: Your Name (TEAMID)" \
PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
scripts/make-app-store-pkg.sh
```
The package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`.
The script derives the App Store application identifier and team identifier from the provisioning profile before signing. It also clears download quarantine metadata from the bundle before packaging, because App Store Connect rejects packages that contain quarantine extended attributes.
## Upload
Upload the `.pkg` with Transporter. You can also set `VALIDATE_WITH_ALTOOL=1` when running `scripts/make-app-store-pkg.sh` if you want the script to perform an `altool` validation after packaging. After App Store Connect processes the build, select it in the app version, finish metadata, answer App Privacy, fill review notes, and submit for review.
Keep `CFBundleShortVersionString` as `0.3.0` and `CFBundleVersion` as `4` for this upload. Increment `BUILD_NUMBER` in `scripts/release-version.sh` before uploading another build for the same version.

70
docs/ENGINEERING.md Normal file
View File

@@ -0,0 +1,70 @@
# Engineering Principles
I Hate PDFs is intentionally a small native macOS app. Future work should preserve that constraint unless there is a documented, user-visible reason to do otherwise.
## Native First
- Build features with Swift, SwiftUI, AppKit, PDFKit, and other system frameworks that ship with macOS.
- Do not replace the app with Electron, Chromium, a web runtime, a bundled JavaScript app shell, or a cross-platform UI toolkit.
- Do not bundle a PDF renderer, OCR engine, database, scripting runtime, or large framework when a macOS system API can satisfy the requirement.
- Prefer native macOS controls and document behaviors over custom reimplementations when they meet the product need.
## Small By Default
Every change should aim for the smallest final app that still delivers the required fluidity, reliability, and functionality.
- Keep third-party dependencies at or near zero. Any new package must justify its shipped size, runtime cost, maintenance cost, and why system APIs are insufficient.
- Keep assets minimal. Avoid large raster images, fonts, sample PDFs, videos, model files, or generated resources in the app bundle.
- Keep build outputs out of source and releases unless they are intentional release artifacts.
- Prefer dynamic links to Apple system frameworks over vendored libraries.
- Avoid storing duplicate PDF data, rendered page caches, or annotation indexes unless profiling shows they are required for fluid interaction.
- Favor targeted updates over whole-document rescans for common interactions such as editing, replying, filtering, hovering, and sidebar refreshes.
## Size Budget
The release DMG should stay as small as practical. Treat size growth as a product regression, not just a packaging detail.
Before merging release-impacting work, compare:
```sh
scripts/build-app.sh
scripts/make-dmg.sh
du -sh "dist/I Hate PDFs.app" \
"dist/I Hate PDFs.app/Contents/MacOS/IHatePDFs" \
"dist/I Hate PDFs.app/Contents/Resources/AppIcon.icns" \
dist/IHatePDFs-v*-macos.dmg
```
If a change materially increases the app bundle or DMG size, document why in the PR or commit notes. A useful rule of thumb: any dependency addition, bundled asset addition, or release-size increase above roughly 10% needs explicit justification.
## Performance Budget
Small size should not come at the expense of reader fluidity.
- Opening, scrolling, zooming, searching, annotating, saving, and sidebar navigation should remain responsive on long PDFs.
- Optimize around measured user workflows instead of speculative micro-optimizations.
- Keep expensive work page-scoped or lazy when possible.
- Use `swift test` plus the PDF verification scripts after behavior changes:
```sh
swift test
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
```
## Release Packaging
Release builds should use the existing lightweight packaging path:
```sh
scripts/build-app.sh
scripts/make-dmg.sh
```
`scripts/build-app.sh` strips release binaries by default to reduce shipped size. Use `STRIP_RELEASE=0 scripts/build-app.sh` only when a symbol-rich release build is needed for debugging.
Universal `arm64` + `x86_64` builds are the default for public releases. Single-architecture builds are acceptable for local testing:
```sh
ARCHS="" scripts/build-app.sh
```

View File

@@ -15,20 +15,50 @@ Use at least:
## 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, hidden page-level replies, 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.
2. Close the PDF, then drag a `.pdf` file onto the empty no-document window and verify it opens.
3. Open Settings from File > Settings... and with Command-, then verify highlight color, comment color, and opacity changes can be edited and reset.
4. Select text and add a highlight; verify no comment popover opens.
5. Select text and add a comment; verify the comment color matches the Settings value.
6. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved.
7. Add an underline with a comment.
8. Select text, right-click, and add a comment from the context menu.
9. Add free text directly on the page.
10. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
11. 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.
12. 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.
13. 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.
14. Click on commented text and underlined text and verify the comment popover opens; then click the line below or nearby whitespace and verify no popover opens.
15. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document.
16. 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.
17. Save As an annotated copy.
18. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain.
19. Save over a disposable original and verify the overwrite warning appears.
20. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
21. Search for a word, close the search toolbar, and verify the match highlights disappear; repeat after opening a different PDF to confirm stale search highlights do not carry over.
22. Type an invalid page number and an out-of-range page number in the page field, and verify the app restores the current page number with a clear status message; also verify previous/next page controls disable at the first and last pages.
23. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies.
24. Collapse a page group in the comments sidebar, search for a comment on that page, and verify the matching results are shown while the filter is active.
25. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until you send or cancel it.
26. Click one comment row, then click Reply on a different comment or reply, and verify the sidebar selection and PDF highlight move to the reply target.
27. Click one comment row, then click Edit or the review-status chip on a different row, and verify the sidebar selection and PDF highlight move to the edited or reviewed row.
28. Set a comments-sidebar filter and collapse a page group, then open another PDF and verify the comments sidebar starts unfiltered with page groups expanded.
29. In Settings, choose very low-opacity highlight and comment colors, add each annotation type, and verify saved annotations remain visibly readable.
30. Start typing a sidebar reply without sending it, then close or replace the PDF and verify the app asks before discarding the draft and the window shows the edited indicator while the draft exists.
31. Start typing a sidebar reply without sending it, choose Share, and verify the app warns that the draft will not be included unless it is sent first.
32. Start typing a sidebar reply, delete the comment thread it belongs to, and verify the app asks before discarding the reply draft.
33. Add replies to a comment, delete the parent comment from both the sidebar and the popover path in separate runs, and verify the whole thread is removed each time.
34. Hover a comment row until the matching PDF annotation highlights, then hide the comments sidebar or apply a filter that removes the row and verify the hover highlight clears.
35. Hover and click a sidebar reply, and verify the PDF scrolls to and highlights the visible parent annotation rather than jumping to a hidden reply marker.
36. Search for a word with matches, edit the search field without pressing Return, and verify old PDF match highlights clear and previous/next search buttons disable until the new query is submitted.
37. Start typing a sidebar reply without sending it, choose Save As, and verify the app warns that the draft will not be included unless it is sent first.
38. Create a new selected-text comment or free text, leave its popover empty, choose Save before closing the popover, and verify the temporary empty annotation is discarded instead of saved.
39. Start typing a sidebar reply with no other unsaved annotation changes and verify the status bar shows a reply draft instead of presenting the PDF as clean.
40. Search for a word with matches, step through results, and verify the status bar reports the current match position; then search for text that is not present and verify PDF match highlights clear.
41. Open comments search and verify the field is focused immediately; enter a search, hide the search controls, and verify the search icon still indicates an active hidden filter.
42. Select a comment row, apply a comments-sidebar filter or search that hides that row, and verify the PDF selection highlight clears instead of lingering on the page.
43. Create a new selected-text comment or free text, leave its popover empty, choose Share, and verify the temporary empty annotation is discarded before any Save and Share output is written.
44. Select a comment or annotation row, hide the only sidebar that shows that row, and verify the PDF selection highlight clears; repeat while the left Annotations sidebar is visible and verify the selection stays visible there.
45. Select a comment row, collapse its page group in the comments sidebar, and verify the PDF selection highlight clears; then search/filter comments and verify matching page groups expand while filtering.
## External Readers
@@ -39,7 +69,9 @@ 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.
These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
For Preview interoperability, exported markup comments should keep the comment text on the parent annotation's standard `/Contents` key and should not depend on PDFKit-generated `/Popup` links for highlights or underlines.
Open the saved annotated copy in:

View File

@@ -3,13 +3,22 @@ set -euo pipefail
APP_NAME="I Hate PDFs"
EXECUTABLE_NAME="IHatePDFs"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
CONFIGURATION="${CONFIGURATION:-release}"
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
STRIP_RELEASE="${STRIP_RELEASE:-1}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-}"
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}"
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
CODESIGN_TIMESTAMP="${CODESIGN_TIMESTAMP:-1}"
CODESIGN_OPTIONS="${CODESIGN_OPTIONS:-}"
PLISTBUDDY="/usr/libexec/PlistBuddy"
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"
@@ -17,6 +26,29 @@ MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_SOURCE="$ROOT_DIR/ihatepdf.png"
ICON_NAME="AppIcon"
DERIVED_ENTITLEMENTS_PATH=""
PROFILE_PLIST_PATH=""
cleanup() {
if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then
rm -f "$DERIVED_ENTITLEMENTS_PATH"
fi
if [[ -n "$PROFILE_PLIST_PATH" ]]; then
rm -f "$PROFILE_PLIST_PATH"
fi
}
trap cleanup EXIT
set_plist_string() {
local plist="$1"
local key="$2"
local value="$3"
if "$PLISTBUDDY" -c "Set :$key $value" "$plist" >/dev/null 2>&1; then
return
fi
"$PLISTBUDDY" -c "Add :$key string $value" "$plist"
}
cd "$ROOT_DIR"
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
@@ -24,13 +56,26 @@ 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)"
swift build "${SWIFT_BUILD_ARGS[@]}"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then
strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
fi
if [[ -n "$PROVISIONING_PROFILE" ]]; then
if [[ ! -f "$PROVISIONING_PROFILE" ]]; then
echo "Missing provisioning profile: $PROVISIONING_PROFILE" >&2
exit 1
fi
cp "$PROVISIONING_PROFILE" "$CONTENTS_DIR/embedded.provisionprofile"
xattr -cr "$CONTENTS_DIR/embedded.provisionprofile" 2>/dev/null || true
fi
if [[ ! -f "$ICON_SOURCE" ]]; then
echo "Missing app icon source: $ICON_SOURCE" >&2
exit 1
@@ -70,7 +115,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<key>CFBundleExecutable</key>
<string>$EXECUTABLE_NAME</string>
<key>CFBundleIdentifier</key>
<string>org.ihatepdfs.app</string>
<string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
@@ -97,11 +142,13 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
</dict>
</array>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>$APP_VERSION</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$BUILD_NUMBER</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
@@ -114,4 +161,51 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
</plist>
PLIST
if [[ -n "$SIGNING_IDENTITY" ]]; then
if [[ -n "$ENTITLEMENTS_PATH" && ! -f "$ENTITLEMENTS_PATH" ]]; then
echo "Missing entitlements file: $ENTITLEMENTS_PATH" >&2
exit 1
fi
APP_ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH"
if [[ -n "$PROVISIONING_PROFILE" ]]; then
PROFILE_PLIST_PATH="$(mktemp "$DIST_DIR/profile.XXXXXX.plist")"
security cms -D -i "$PROVISIONING_PROFILE" > "$PROFILE_PLIST_PATH"
APP_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.application-identifier" "$PROFILE_PLIST_PATH")"
TEAM_IDENTIFIER="$("$PLISTBUDDY" -c "Print :Entitlements:com.apple.developer.team-identifier" "$PROFILE_PLIST_PATH")"
DERIVED_ENTITLEMENTS_PATH="$(mktemp "$DIST_DIR/entitlements.XXXXXX.plist")"
if [[ -n "$ENTITLEMENTS_PATH" ]]; then
cp "$ENTITLEMENTS_PATH" "$DERIVED_ENTITLEMENTS_PATH"
else
cat > "$DERIVED_ENTITLEMENTS_PATH" <<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/>
</plist>
PLIST
fi
set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.application-identifier" "$APP_IDENTIFIER"
set_plist_string "$DERIVED_ENTITLEMENTS_PATH" "com.apple.developer.team-identifier" "$TEAM_IDENTIFIER"
APP_ENTITLEMENTS_PATH="$DERIVED_ENTITLEMENTS_PATH"
fi
CODESIGN_ARGS=(--force --sign "$SIGNING_IDENTITY")
if [[ "$CODESIGN_TIMESTAMP" != "0" ]]; then
CODESIGN_ARGS+=(--timestamp)
fi
if [[ -n "$CODESIGN_OPTIONS" ]]; then
CODESIGN_ARGS+=(--options "$CODESIGN_OPTIONS")
fi
if [[ -n "$APP_ENTITLEMENTS_PATH" ]]; then
CODESIGN_ARGS+=(--entitlements "$APP_ENTITLEMENTS_PATH")
fi
codesign "${CODESIGN_ARGS[@]}" "$APP_DIR"
codesign --verify --strict --verbose=2 "$APP_DIR"
fi
echo "Built $APP_DIR"
du -sh "$APP_DIR" "$MACOS_DIR/$EXECUTABLE_NAME" "$RESOURCES_DIR/$ICON_NAME.icns"

69
scripts/make-app-store-pkg.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
APP_NAME="I Hate PDFs"
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
APP_SIGNING_IDENTITY="${APP_SIGNING_IDENTITY:-}"
INSTALLER_SIGNING_IDENTITY="${INSTALLER_SIGNING_IDENTITY:-}"
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}"
require_value() {
local name="$1"
local value="$2"
local hint="$3"
if [[ -z "$value" ]]; then
echo "Missing $name." >&2
echo "$hint" >&2
exit 2
fi
}
require_value "APP_SIGNING_IDENTITY" "$APP_SIGNING_IDENTITY" \
"Example: APP_SIGNING_IDENTITY=\"Apple Distribution: Your Name (TEAMID)\" or \"3rd Party Mac Developer Application: Your Name (TEAMID)\""
require_value "INSTALLER_SIGNING_IDENTITY" "$INSTALLER_SIGNING_IDENTITY" \
"Example: INSTALLER_SIGNING_IDENTITY=\"3rd Party Mac Developer Installer: Your Name (TEAMID)\""
require_value "PROVISIONING_PROFILE" "$PROVISIONING_PROFILE" \
"Download an App Store provisioning profile for $BUNDLE_ID and pass its local path."
mkdir -p "$DIST_DIR"
BUNDLE_ID="$BUNDLE_ID" \
APP_VERSION="$APP_VERSION" \
BUILD_NUMBER="$BUILD_NUMBER" \
SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \
ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \
PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \
"$ROOT_DIR/scripts/build-app.sh"
xattr -cr "$APP_DIR" 2>/dev/null || true
rm -f "$PKG_PATH"
productbuild \
--component "$APP_DIR" /Applications \
--sign "$INSTALLER_SIGNING_IDENTITY" \
"$PKG_PATH"
pkgutil --check-signature "$PKG_PATH"
if [[ "$VALIDATE_WITH_ALTOOL" == "1" ]]; then
require_value "ASC_USERNAME" "${ASC_USERNAME:-}" \
"Set ASC_USERNAME to the Apple ID or App Store Connect API key issuer format expected by altool."
require_value "ASC_PASSWORD" "${ASC_PASSWORD:-}" \
"Set ASC_PASSWORD to an app-specific password or app-store-connect API key password."
xcrun altool --validate-app \
--type macos \
--file "$PKG_PATH" \
--username "$ASC_USERNAME" \
--password "$ASC_PASSWORD"
fi
echo "Created App Store package: $PKG_PATH"

View File

@@ -2,13 +2,15 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
APP_NAME="I Hate PDFs"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
DMG_PATH="$DIST_DIR/IHatePDFs.dmg"
DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg"
BUILD_APP="${BUILD_APP:-1}"
if [[ ! -d "$APP_DIR" ]]; then
"$ROOT_DIR/scripts/build-app.sh"
if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then
APP_VERSION="$APP_VERSION" BUILD_NUMBER="$BUILD_NUMBER" "$ROOT_DIR/scripts/build-app.sh"
fi
rm -f "$DMG_PATH"

5
scripts/release-version.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
APP_VERSION="${APP_VERSION:-0.3.0}"
BUILD_NUMBER="${BUILD_NUMBER:-4}"
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"

View File

@@ -18,8 +18,9 @@ enum VerificationError: Error, CustomStringConvertible {
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 unexpectedMarkupPopup(page: Int, index: Int, subtype: String)
case unexpectedPopupLink(page: Int, index: Int, subtype: String)
case missingExpectedSubtype(String)
var description: String {
@@ -36,10 +37,12 @@ enum VerificationError: Error, CustomStringConvertible {
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 .unexpectedMarkupPopup(let page, let index, let subtype):
return "Popup annotation \(index) on page \(page) points at a /\(subtype) markup annotation; markup comments should export through /Contents"
case .unexpectedPopupLink(let page, let index, let subtype):
return "\(subtype) annotation \(index) on page \(page) should store comments in /Contents, not a /Popup link"
case .missingExpectedSubtype(let subtype):
return "Expected at least one /\(subtype) annotation"
}
@@ -85,7 +88,7 @@ for pageNumber in 1...document.numberOfPages {
switch subtype {
case "Highlight":
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Highlight")
try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex)
if hasString(in: annotation, key: "IHatePDFsKind") {
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
summary.selectedTextComments += 1
@@ -95,7 +98,7 @@ for pageNumber in 1...document.numberOfPages {
case "Underline":
summary.underlines += 1
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex)
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Underline")
try rejectPopupLink(in: annotation, subtype: subtype, page: pageNumber, index: annotationIndex)
case "Text":
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
@@ -104,7 +107,6 @@ for pageNumber in 1...document.numberOfPages {
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
} else {
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text")
summary.textNotes += 1
}
case "FreeText":
@@ -118,9 +120,24 @@ for pageNumber in 1...document.numberOfPages {
case "Popup":
summary.popups += 1
var parentDictionary: CGPDFDictionaryRef?
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else {
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary),
let parentDictionary
else {
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex)
}
let parentSubtype = try nameValue(
in: parentDictionary,
key: "Subtype",
page: pageNumber,
index: annotationIndex
)
if parentSubtype == "Highlight" || parentSubtype == "Underline" {
throw VerificationError.unexpectedMarkupPopup(
page: pageNumber,
index: annotationIndex,
subtype: parentSubtype
)
}
default:
continue
}
@@ -145,10 +162,6 @@ guard summary.replies > 0 else {
guard summary.freeText > 0 else {
throw VerificationError.missingExpectedSubtype("FreeText")
}
guard summary.popups >= 4 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(
@@ -251,6 +264,20 @@ func requireMarkupKeys(
try requireString(in: dictionary, key: "M", page: page, index: index)
}
func rejectPopupLink(
in dictionary: CGPDFDictionaryRef,
subtype: String,
page: Int,
index: Int
) throws {
var popupDictionary: CGPDFDictionaryRef?
guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary) else {
return
}
throw VerificationError.unexpectedPopupLink(page: page, index: index, subtype: subtype)
}
func requireTextKeys(
in dictionary: CGPDFDictionaryRef,
page: Int,
@@ -262,22 +289,3 @@ func requireTextKeys(
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

@@ -29,7 +29,6 @@ standardize(
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),
@@ -42,12 +41,11 @@ 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.",
contents: "This selected-text comment is saved as standard parent annotation 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),
@@ -64,7 +62,6 @@ standardize(
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),
@@ -153,18 +150,6 @@ func standardize(
_ = 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)),