6 Commits
v0.2 ... v0.4

Author SHA1 Message Date
Akshay Kolli
c62f0bf9c7 Fix v0.4 reader workflow issues 2026-06-30 10:47:14 -07:00
Akshay Kolli
9bdc1a4b09 Polish README and add Rust Book screenshots 2026-06-30 00:30:41 -07:00
Akshay Kolli
992f1444e6 Clean up repository structure and release docs 2026-06-30 00:18:59 -07:00
Akshay Kolli
226c29b565 Polish README hero badges 2026-06-29 23:46:24 -07:00
Akshay Kolli
504bd2d39a Prepare v0.4 release and open source docs 2026-06-29 23:42:39 -07:00
Akshay Kolli
085d7a16dc Release v0.3 2026-06-24 17:51:26 -07:00
76 changed files with 9704 additions and 3523 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @akkolli

34
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,34 @@
## Type
Bug report, feature request, documentation issue, or support question?
## Summary
What should maintainers know first?
## Steps To Reproduce
For bugs, list the shortest reproducible path:
1.
2.
3.
## Expected Behavior
What did you expect to happen?
## Environment
- I Hate PDFs version/build:
- macOS version:
- Install source: GitHub release or Mac App Store
- Mac architecture: Apple Silicon or Intel
## UI Evidence
Attach screenshots or recordings for UI problems. Each file must be under 1 MB.
## Size And Privacy Impact
For feature requests, note whether this requires new assets, dependencies, network behavior, bundled PDFs, or release-size increases.

34
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,34 @@
## Summary
Describe what changed and why.
## Vibe Coding Disclosure
Was this pull request vibe coded or assisted by generated code? If yes, describe what you manually reviewed before opening it.
## Screenshots Or Recordings
Required for UI changes. Include before and after screenshots for changed UI, or at least one screenshot/recording for new UI.
Each screenshot, recording, and committed media file included with this pull request must be under 1 MB.
## Checks
List the checks you ran:
- [ ] `swift test`
- [ ] `swift build -c release`
- [ ] `swift scripts/verify-pdf-annotations.swift`
- [ ] `scripts/verify-release-artifacts.sh`
- [ ] Manual UI check
- [ ] Not run, because:
## Checklist
- [ ] I read `CONTRIBUTING.md`.
- [ ] This pull request is focused on one behavior, fix, or documentation change.
- [ ] I updated `CHANGELOG.md` for user-visible changes, or this change is not user-visible.
- [ ] I documented the before/after behavior and QA performed.
- [ ] UI changes include screenshots or recordings, or this pull request does not change UI.
- [ ] Every screenshot, recording, and committed media file in this pull request is under 1 MB.
- [ ] I explained any app-size impact from new assets, dependencies, or release packaging changes.

53
.github/workflows/media-size.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Media Size
on:
pull_request:
paths:
- "**/*.gif"
- "**/*.jpeg"
- "**/*.jpg"
- "**/*.mov"
- "**/*.mp4"
- "**/*.png"
- "**/*.webp"
jobs:
media-size:
name: Media files under 1 MB
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check changed media size
shell: bash
run: |
set -euo pipefail
limit_bytes=1048576
failed=0
mapfile -t media_files < <(
git diff --name-only --diff-filter=ACMRT \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- \
'*.gif' '*.jpeg' '*.jpg' '*.mov' '*.mp4' '*.png' '*.webp'
)
for file in "${media_files[@]}"; do
if [[ ! -f "$file" ]]; then
continue
fi
size_bytes=$(wc -c < "$file" | tr -d ' ')
if (( size_bytes >= limit_bytes )); then
echo "::error file=${file}::Media file is ${size_bytes} bytes. Keep each screenshot, recording, and committed media file under 1 MB."
failed=1
fi
done
exit "$failed"

13
.gitignore vendored
View File

@@ -1,7 +1,20 @@
.DS_Store .DS_Store
.swiftpm/
.build/ .build/
DerivedData/ DerivedData/
dist/ dist/
release/
plans/
$tmpdir/
*.dmg
*.pkg
*.tar.gz
*.tar.xz
*.zip
*.xcuserstate *.xcuserstate
*.swp
*.xcworkspace/xcuserdata/ *.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/ *.xcodeproj/xcuserdata/
*.mobileprovision
*.provisionprofile
*.p12

View File

@@ -1,6 +1,159 @@
# Changelog # Changelog
## Version 0.2 - 2026-06-18 ## Unreleased
### Repository
- Added README badges for release, license, platform, Swift, contributions, and media-size policy.
- Added contribution, support, security, issue, and pull request policies for open-source contributors.
- Added a pull request media-size policy requiring UI screenshots or recordings and limiting each screenshot, recording, or committed media file to less than 1 MB.
- Renamed source, test, and signing directories to lowercase paths.
- Removed duplicate release/planning docs, stale screenshot targets, and the unused duplicate icon image.
- Removed redundant support/App Store copy docs and trimmed repository screenshots to one representative image.
- Consolidated release helper scripts by folding size checks into tiny archive creation and sample PDF generation into PDF annotation verification.
- Consolidated workflow audit guidance into the manual QA document.
- Consolidated engineering guidance into contributing docs and App Store packaging into release docs.
- Documented that vibe coded pull requests are welcome when they include clear change documentation, strict QA, and UI screenshots or recordings when relevant.
- Documented `https://www.akkolli.net/ihatepdfs` as the project website and `akshaykolli@hotmail.com` as the support contact.
- Clarified that the project is vibe coded and maintained with AI agents, and that feature requests and agent-assisted PRs are welcome under the QA guidelines.
## Version 0.4.0 (build 7) - 2026-06-30
Build 7 keeps the public v0.4 app version and prepares another upload with targeted reader workflow fixes.
### Fixed
- Added an always-visible Reply button to comment and reply rows in the comments sidebar.
- Made Escape leave active annotation modes, including highlighter mode and free-text placement.
- Kept the macOS full-screen shortcut available after focus moves to window chrome.
- Added annotation undo/redo for add and delete operations.
- Added selected-annotation deletion and visible delete controls for plain highlights.
## Version 0.4.0 (build 6) - 2026-06-25
Version 0.4 removes the experimental Fill & Sign, form-field navigation, and PDF signing implementation from the shipping app. The release is back to a small native reader and annotation tool while preserving the size target.
### Removed
- Prepared the release metadata for app version `0.4.0`, build `6`.
- Removed the Fill & Sign panel, menu commands, settings, flat fill-mark factory, form scanner/navigation, custom form choice popover, PDF signing pipeline, and related QA scripts/tests.
- Removed the SecurityInterface link from the app target.
- Preserved the size target by returning to the existing lightweight native stack: SwiftUI, AppKit, PDFKit, and Foundation.
### Reliability
- Cleaned up PDFKit page/selection observers when reattaching a PDF view and when app state is released, avoiding stale observer callbacks in long app sessions.
- Re-ran the core test suite, release build, annotation verification, and release artifact verification for the 0.4 prep pass.
## Version 0.3.0 (build 5) - 2026-06-25
Build 5 is a release-candidate polish build for signatures, Recent PDFs, and release metadata. Complete the manual Acrobat and Preview signature checks before public distribution.
### Fill And Sign
- Added a Fill & Sign toolbar/menu workflow for placing flat PDF fill marks without adding a bundled PDF engine.
- Added text, checkbox, date, initials, and typed signature-appearance marks as standard printable `/FreeText` annotations with app metadata.
- Added AcroForm widget scanning so PDFs with form fields report field counts in the reader/status UI.
- Added native form-field navigation from the Fill & Sign strip, Annotate menu, Tab, and Shift-Tab.
- Captured choice/list field options and export values through PDFKit's public form APIs.
- Added a compact native choice/list popover for PDFKit-backed combo and list fields.
- Kept visual signature appearances separate from real digital signatures; cryptographic signing still goes through Keychain-backed File > Sign PDF....
- Typed signature appearances can now be reused as the visible widget appearance for a real digital signature.
- Added unit coverage for Fill & Sign models, form scanning, choice/list options and fallback values, unsupported fields, form-value save/reopen behavior, form-scan performance, fill mark metadata, page clamping, visible signature appearance reuse, and signature-appearance non-detection as a digital signature.
### PDF Signatures
- Added digital PDF signing from File > Sign PDF..., using macOS Keychain identities and detached CMS signatures.
- Signed output is written as an incremental PDF update, preserving original PDF bytes instead of rewriting through PDFKit.
- Added invisible signatures and click-to-place visible signature boxes.
- Visible signatures are now drawn into page content as well as the signature widget appearance, so macOS Preview renders the signer text instead of a blank signature box.
- Added signature detection and status reporting for signed PDFs.
- Added a compact signature inspector from the status bar with signer, status, date, reason, location, format, and ByteRange details.
- Normal Save is disabled for signed PDFs; Save As creates an unsigned edited copy instead.
- Added CMS validation for signed PDFs, including separate reporting for invalid signatures and untrusted certificates.
- Added CMS parsing support for macOS Security's indefinite-length CMS output, so real Keychain-signed PDFs validate after signing.
- Added performance-budget coverage for large-document annotation snapshots, page-scoped annotation refresh, and large PDF signature scanning.
- Added opt-in Keychain signature QA that can either use an installed identity or create a temporary QA identity, then writes invisible and visible signed PDFs and checks Poppler `pdfsig` recognition when available.
- Added an Acrobat QA handoff script that verifies signed QA files and prints the exact remaining Acrobat Reader checks.
- Fixed signature QA fixtures to emit valid classic xref offsets, avoiding parser reconstruction warnings and Preview rejection.
- Added unit coverage for signature scanning, incremental writing, field construction, ByteRange/Contents patching, and validator parsing.
### Reader Polish
- Added compact Recent PDFs shortcuts in the empty window and File > Open Recent, backed by macOS's native recent-document list.
- Added paste-ready App Store metadata, review notes, privacy answers, and screenshot guidance.
- Added a bitmap Preview screenshot showing visible signed-PDF output for external-reader QA.
- Added isolated App Store package staging so Mac App Store builds no longer overwrite the direct-download app bundle in `dist`.
- Strengthened release artifact verification for bundle IDs, App Store package signing, embedded provisioning profiles, and expected sandbox entitlements.
- Removed SVG mock screenshots from the documentation assets.
## 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 ### Fixed
@@ -13,8 +166,8 @@
- Sidebar toolbar controls are grouped together in the leading toolbar area for better visibility. - Sidebar toolbar controls are grouped together in the leading toolbar area for better visibility.
## Version 0.1 ## Version 0.1.0
- Initial macOS SwiftUI/PDFKit release. - Initial native macOS SwiftUI/PDFKit release.
- Local PDF opening, reading, zoom, fit width, fit page, page navigation, and search. - 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. - Highlight, underline, selection-bound comment, free-text annotation, comments sidebar, Save, Save As, and Share workflows.

25
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,25 @@
# Code Of Conduct
This project expects direct, respectful, technical collaboration.
## Expected Behavior
- Be clear and constructive.
- Focus criticism on code, behavior, documentation, and project direction.
- Assume contributors may have different experience levels and constraints.
- Respect maintainer decisions about scope, release timing, licensing, and App Store distribution.
## Unacceptable Behavior
- Harassment, threats, insults, or discriminatory language.
- Personal attacks in issues, pull requests, discussions, commits, or reviews.
- Publishing private information without permission.
- Repeatedly pushing a rejected change without new technical evidence.
## Enforcement
Maintainers may edit, hide, or remove comments; close issues or pull requests; block participants; or restrict repository access when behavior harms the project.
Report conduct issues through the product support page:
<https://www.akkolli.net/ihatepdfs>

89
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,89 @@
# Contributing To I Hate PDFs
I Hate PDFs is a native macOS app for local PDF reading and annotation. Contributions should preserve that direction: small bundle, native system frameworks, local files, no account requirement, no analytics, and no cloud upload.
By contributing, you agree that your contribution is licensed under GNU General Public License version 2 only.
This project is vibe coded and maintained with AI agents. Feature requests are welcome; the maintainer will get to them as time allows. Agent-assisted pull requests are welcome too, but vibe coded does not mean unreviewed: every change must be understandable, documented, tested at the right level, and held to the same strict QA bar as hand-written code.
## Before You Start
- Check existing issues and pull requests before starting duplicate work.
- Open an issue first for large UI changes, new dependencies, release-process changes, or features that affect PDF saving/export behavior.
- Read `docs/QA.md` before changing a user workflow.
- Read `docs/RELEASE.md` before preparing a release.
## Development
Requirements:
- macOS 13 or newer
- Xcode 15 or newer with command line tools
- Swift Package Manager
Useful local commands:
```sh
swift run IHatePDFs
swift test
swift build -c release
scripts/build-app.sh
scripts/make-dmg.sh
scripts/verify-release-artifacts.sh
```
Run the checks that match your change and list them in the pull request. If you skip a relevant check, say why.
## Engineering Policy
- Build with Swift, SwiftUI, AppKit, PDFKit, and system frameworks that ship with macOS.
- Do not replace the app with Electron, Chromium, a web runtime, a bundled JavaScript 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.
- Keep third-party dependencies at or near zero. Any new package must justify 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 expensive work page-scoped or lazy when possible.
- Treat release-size growth as a product regression. Each direct-download per-architecture installer must stay under 400,000 bytes.
- Run `scripts/make-tiny-archives.sh` before release-impacting changes; it builds and checks the per-architecture archives.
## Pull Request Policy
- Keep each pull request focused on one behavior, bug fix, or documentation change.
- Explain the user-visible behavior change, not only the implementation detail.
- For vibe coded changes, say so in the pull request and describe what you reviewed manually before opening it.
- Link the issue when one exists.
- Update `CHANGELOG.md` for user-visible changes.
- Avoid unrelated formatting churn.
- Do not add a new dependency, bundled asset, PDF engine, runtime, or release artifact without explaining the size and maintenance impact.
- Preserve the app's local-first privacy model.
- Document the before/after behavior clearly enough that a maintainer can review the intent without reverse-engineering the diff.
- Include the QA commands and manual checks that prove the change works.
## UI Screenshot Policy
Every pull request that changes visible UI must include screenshots or a short screen recording in the pull request description.
For UI changes:
- Include before and after screenshots when changing an existing screen.
- Include at least one screenshot when adding a new screen, state, toolbar item, sidebar, popover, dialog, menu, or empty state.
- Cover light and dark mode when the change affects colors, contrast, icons, materials, or selection states.
- Cover narrow window behavior when the change affects layout or toolbar/sidebar density.
- Keep every screenshot, recording, and committed media file under 1 MB.
If a UI change cannot be captured meaningfully, explain that in the pull request.
## Size Policy
The app is intentionally small. Pull requests should keep source, assets, and release artifacts lean.
- Each committed screenshot, recording, fixture image, or other media file added or changed in a pull request must be less than 1 MB.
- Prefer cropped screenshots that show the changed UI instead of full-desktop captures.
- Prefer compressed PNG, JPEG, or WebP where appropriate.
- Do not commit raw screen recordings, large sample PDFs, generated archives, `.app` bundles, `.dmg` files, or App Store packages.
- Explain any app-size increase caused by assets, dependencies, or release packaging changes.
The `media-size.yml` workflow checks changed media files in pull requests. PR description attachments are still governed by this policy even though GitHub Actions cannot inspect them.
## Review Expectations
Maintainers may ask for smaller scope, screenshots, reduced assets, additional tests, or release-size justification before merging. Release submission and App Store upload decisions stay with the maintainers.

351
LICENSE
View File

@@ -1,21 +1,338 @@
MIT License GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (c) 2026 I Hate PDFs contributors Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy Preamble
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The licenses for most software are designed to take away your
copies or substantial portions of the Software. freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR When we speak of free software, we are referring to freedom, not
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, price. Our General Public Licenses are designed to make sure that you
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE have the freedom to distribute copies of free software (and charge for
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER this service if you wish), that you receive source code or can get it
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, if you want it, that you can change the software or use pieces of it
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE in new free programs; and that you know you can do these things.
SOFTWARE.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@@ -11,14 +11,24 @@ let package = Package(
.library(name: "IHatePDFsCore", targets: ["IHatePDFsCore"]) .library(name: "IHatePDFsCore", targets: ["IHatePDFsCore"])
], ],
targets: [ targets: [
.target(name: "IHatePDFsCore"), .target(
name: "IHatePDFsCore",
path: "sources/core"
),
.executableTarget( .executableTarget(
name: "IHatePDFs", name: "IHatePDFs",
dependencies: ["IHatePDFsCore"] dependencies: ["IHatePDFsCore"],
path: "sources/app"
), ),
.testTarget( .testTarget(
name: "IHatePDFsCoreTests", name: "IHatePDFsCoreTests",
dependencies: ["IHatePDFsCore"] dependencies: ["IHatePDFsCore"],
path: "tests/core"
),
.testTarget(
name: "IHatePDFsTests",
dependencies: ["IHatePDFs", "IHatePDFsCore"],
path: "tests/app"
) )
] ]
) )

147
README.md
View File

@@ -1,48 +1,74 @@
# I Hate PDFs <p align="center">
<img src="assets/app-icon.png" alt="I Hate PDFs app icon" width="128">
</p>
I Hate PDFs is an open-source macOS PDF reader for anyone who hates adobe. I think adobe is worth of any sentient being's disdain. <h1 align="center">I Hate PDFs</h1>
## Status <p align="center">
<strong>Native macOS PDF reading, highlighting, commenting, and review.</strong><br>
Local-first. No accounts. No tracking. No cloud upload.
</p>
This app is entirely vibe coded, but will somehow still be better than adobe acrobat soon. <p align="center">
<a href="#build-from-source"><img alt="Platform: macOS 13+" src="https://img.shields.io/badge/platform-macOS%2013%2B-111827?logo=apple&logoColor=white"></a>
<a href="LICENSE"><img alt="License: GPL v2 only" src="https://img.shields.io/badge/license-GPL--2.0--only-2563EB"></a>
<a href="https://github.com/akkolli/ihatepdfs/releases/latest"><img alt="Version" src="https://img.shields.io/github/v/release/akkolli/ihatepdfs?label=version&color=0A7AFF"></a>
<a href="#development"><img alt="Language: Swift" src="https://img.shields.io/badge/language-Swift-F05138?logo=swift&logoColor=white"></a>
<a href="CONTRIBUTING.md"><img alt="Status: active development" src="https://img.shields.io/badge/status-active%20development-16A34A"></a>
<img alt="Vibe coded" src="https://img.shields.io/badge/vibe-coded-FF69B4">
</p>
<p align="center">
<a href="https://github.com/akkolli/ihatepdfs/releases/latest">Download</a>
·
<a href="https://github.com/akkolli/ihatepdfs/issues/new">Report a bug</a>
·
<a href="CONTRIBUTING.md">Contribute</a>
·
<a href="mailto:akshaykolli@hotmail.com">Support</a>
·
<a href="https://www.akkolli.net/ihatepdfs/privacy">Privacy</a>
</p>
---
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. 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 ## Latest Release
Download the v0.2 macOS DMG from the GitHub release page: Current version: `0.4.0` build `7`.
<https://github.com/akkolli/ihatepdfs/releases/tag/v0.2> Download the v0.4 macOS DMG from the GitHub release page:
Use `IHatePDFs-v0.2-macos.dmg` for normal app installation. Open the DMG, then drag `I Hate PDFs.app` into `/Applications`. <https://github.com/akkolli/ihatepdfs/releases/tag/v0.4>
Signing status for v0.2: the DMG is ad-hoc signed, but it is not Developer ID signed or Apple-notarized yet. macOS Gatekeeper may require opening the app from Finder with Control-click, then Open, on first launch. Use `IHatePDFs-v0.4-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 sandbox entitlements documented in `docs/RELEASE.md`.
## Features ## Features
- Open local `.pdf` files from disk. - Incredibly small app size: v0.4 ships as a 998 KB direct-download DMG, with a 255 KB Apple Silicon archive and a 288 KB Intel archive.
- Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation. - Extremely fast native PDF reading: built on SwiftUI, AppKit, and PDFKit instead of a bundled browser, runtime, database, or PDF engine.
- Search selectable text PDFs from a compact toolbar control. - Power- and size-efficient by design: minimal assets, no bundled services, and no background sync workload.
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested. - Local-first privacy: opens user-selected PDFs from disk and does not require accounts, analytics, tracking, or cloud upload.
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size. - No internet auto-updater: releases are explicit downloads, so the app is not running update checks in the background.
- Toggle a compact page thumbnail/sidebar inspector. - No AI features: no summaries, remote prompts, embeddings, model downloads, or document analysis.
- Create selection-bound comments from highlighted PDF text. - Clean review system: focused highlighting, commenting, replies, bookmarks, search, and sidebars that stay hidden until needed.
- Create highlight annotations with anchored optional comments. - Standards-compatible annotations: comments and highlights are written back into PDFs so they remain useful in common PDF readers.
- Create underline annotations with optional comments.
- Create free-text annotations directly on the page.
- Click annotations in the PDF to reopen and edit the comment in place.
- Save annotations directly into the original PDF after an overwrite warning.
- Save As a new annotated copy.
- Share the annotated PDF through the native macOS share picker.
- Review annotations in a compact list with page number, type, author, date, and first comment line.
- Use an Acrobat-style comments sidebar with total count, page grouping, collapsible groups, an add-comment affordance, comment search, collapsed type/author/status filters, full text, replies, edit/delete, and click-to-navigate.
### Download Releases ## Privacy And Support
https://github.com/akkolli/ihatepdfs/releases/tag/v0.2 - Project website: <https://www.akkolli.net/ihatepdfs>
- Support email: <akshaykolli@hotmail.com>
- Privacy policy: <https://www.akkolli.net/ihatepdfs/privacy>
- Security policy: [SECURITY.md](SECURITY.md)
I Hate PDFs works with user-selected local files. It does not require an account, collect analytics, or upload PDFs.
## Build From Source ## Build From Source
@@ -82,57 +108,72 @@ Create a downloadable `.dmg`:
scripts/make-dmg.sh scripts/make-dmg.sh
``` ```
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.2-macos.dmg`. The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.4-macos.dmg` by default.
Create the size-gated per-architecture archives:
```sh
scripts/make-tiny-archives.sh
```
This writes `dist/IHatePDFs-v0.4-macos-arm64.tar.xz` and `dist/IHatePDFs-v0.4-macos-x86_64.tar.xz`, then verifies each archive is under the 400,000-byte direct-download budget.
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.4-macos-appstore.pkg`. More details are in `docs/RELEASE.md`.
## Installation ## Installation
Download `IHatePDFs-v0.2-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`. Download `IHatePDFs-v0.4-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`.
For v0.2 and local development builds, the app is not Developer ID signed or notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm. 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 ## Development
The project is a Swift Package with two targets: 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. - `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 `CONTRIBUTING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes.
Use `docs/RELEASE.md` when preparing a new version; it is the checklist for version bumps, release validation, size gates, and upload packaging.
Open source contribution policy:
- Contributions are accepted under GPL-2.0-only. See [LICENSE](LICENSE).
- Vibe coded pull requests are welcome, but they must include clear change documentation, strict QA notes, and screenshots or recordings for UI changes.
- This project is vibe coded and maintained with AI agents. Feature requests are welcome, and agent-assisted PRs are welcome when they follow the QA and contribution guidelines.
- Start with [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
- UI pull requests must include before/after screenshots or a short screen recording.
- Screenshots, recordings, and committed media files included with a pull request must each be under 1 MB.
Useful checks: Useful checks:
```sh ```sh
swift test swift test
swift build -c release swift build -c release
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift swift scripts/verify-pdf-annotations.swift
scripts/verify-release-artifacts.sh
``` ```
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, and popup annotation dictionaries. The PDF verification script generates and inspects standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship dictionaries.
The release artifact verifier checks the current direct-download app and DMG by default. Run `REQUIRE_APP_STORE_PKG=1 scripts/verify-release-artifacts.sh` after creating an App Store package.
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 and workflow guardrails for Preview, Acrobat Reader, and browser PDF viewers are documented in `docs/QA.md`. Release and App Store packaging are documented in `docs/RELEASE.md`.
## Screenshots ## Screenshot
Screenshots live in `docs/screenshots`. Screenshots live in `docs/screenshots`.
Current repository screenshots:
- `docs/screenshots/no-document.png`
- `docs/screenshots/default-reading.png`
- `docs/screenshots/highlight-comment-popover.png`
- `docs/screenshots/main-window.png`
- `docs/screenshots/comments-sidebar.png`
- `docs/screenshots/dark-mode-reading.png`
![No document open](docs/screenshots/no-document.png)
![Default reading mode](docs/screenshots/default-reading.png) ![Default reading mode](docs/screenshots/default-reading.png)
![Highlight comment popover](docs/screenshots/highlight-comment-popover.png)
![Comments sidebar](docs/screenshots/comments-sidebar.png)
![Dark mode reading](docs/screenshots/dark-mode-reading.png)
## License ## License
MIT. See `LICENSE`. GNU General Public License version 2 only. See [LICENSE](LICENSE).

View File

@@ -1,8 +0,0 @@
# Release Notes
## Version 0.2
- Fixed multi-window document state so opening a PDF with Finder Open With does not mirror it into an existing window.
- Fixed zoom commands so toolbar and menu zoom actions apply to the focused PDF window instead of another open window.
- Fixed comment entry so pressing Return saves a new comment without requiring the mouse.
- Kept the page and comments sidebar toolbar icons visible in narrow windows by grouping sidebar controls in the leading toolbar.

View File

@@ -1,20 +1,8 @@
# Roadmap # Roadmap
## Version 0.1 I Hate PDFs is a small native macOS reader and annotation app. The roadmap should stay focused on local PDF review, standards-compatible annotations, and a small bundle.
- Native macOS SwiftUI/PDFKit app. ## Next
- Local PDF opening.
- Reading controls: scrolling, zoom, fit width, fit page, page navigation, search.
- Focused default reading mode with optional page thumbnail sidebar.
- Highlight, underline, selection-bound comment, and free-text annotations.
- Anchored comment popovers from newly created selected-text comments, highlights, underlines, free text, and clicked annotations.
- Annotation list sidebar.
- Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation.
- Save, Save As, and native macOS sharing with standard PDF annotation writing.
- `.app` and `.dmg` build scripts.
- Visual QA screenshots for empty, reading, popover, comments, and dark-mode states.
## Version 0.2
- More explicit visual selection handles for the active annotation. - More explicit visual selection handles for the active annotation.
- Better undo/redo integration for annotation edits. - Better undo/redo integration for annotation edits.
@@ -23,6 +11,7 @@
- Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`. - Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`.
- Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers. - Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers.
- Import/export verification fixtures for existing annotated PDFs. - Import/export verification fixtures for existing annotated PDFs.
- Revisit form fill and signing later as a smaller, cleaner design.
## Later ## Later
@@ -31,3 +20,9 @@
- Optional AI summaries or question prompts. - Optional AI summaries or question prompts.
- iPad companion app. - iPad companion app.
- LMS integrations. - LMS integrations.
## Not Planned
- Accounts, sync, analytics, or cloud PDF upload.
- Bundled PDF engines or large runtimes when system frameworks are enough.
- Broad document-management features that pull focus away from reading and annotation.

23
SECURITY.md Normal file
View File

@@ -0,0 +1,23 @@
# Security Policy
I Hate PDFs works with user-selected local PDFs and does not require an account, analytics, or cloud upload.
## Supported Versions
Security fixes target the latest public release and the `main` branch.
## Reporting A Vulnerability
Do not report security vulnerabilities in public issues.
Use GitHub private vulnerability reporting if it is enabled for this repository. If it is not enabled, contact the maintainer at <akshaykolli@hotmail.com>.
Include:
- A clear description of the issue
- Reproduction steps
- A minimal sample PDF when one is required to reproduce the issue
- Your macOS version
- The app version and build number
The maintainer will assess impact, prepare a fix when needed, and coordinate disclosure timing before public details are posted.

File diff suppressed because it is too large Load Diff

View File

@@ -1,178 +0,0 @@
import AppKit
import SwiftUI
@main
struct IHatePDFsApp: App {
var body: some Scene {
WindowGroup {
AppWindowRoot()
}
.windowStyle(.titleBar)
.commands {
AppCommands()
}
}
}
private struct AppWindowRoot: View {
@StateObject private var appState = AppState()
var body: some View {
MainView()
.environmentObject(appState)
.focusedObject(appState)
.onOpenURL { url in
appState.loadDocument(from: url)
}
}
}
private struct AppCommands: Commands {
@FocusedObject private var appState: AppState?
private var hasDocument: Bool {
appState?.document != nil
}
var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("Open...") {
appState?.openDocument()
}
.keyboardShortcut("o")
.disabled(appState == nil)
Button("Save") {
appState?.saveDocument()
}
.keyboardShortcut("s")
.disabled(!hasDocument)
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("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)
Button("Underline Selection") {
appState?.addUnderline()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
.disabled(!hasDocument)
Button("Comment on Selection") {
appState?.addComment()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(!hasDocument)
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)
}
}
}

View File

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

View File

@@ -1,45 +0,0 @@
import AppKit
import SwiftUI
extension View {
func commitOnPlainReturn(_ action: @escaping () -> Void) -> some View {
modifier(ReturnKeyCommitMonitor(action: action))
}
}
private struct ReturnKeyCommitMonitor: ViewModifier {
let action: () -> Void
@State private var monitor: Any?
func body(content: Content) -> some View {
content
.onAppear {
installMonitor()
}
.onDisappear {
removeMonitor()
}
}
private func installMonitor() {
removeMonitor()
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
guard isPlainReturn(event) else { return event }
action()
return nil
}
}
private func removeMonitor() {
guard let monitor else { return }
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
private func isPlainReturn(_ event: NSEvent) -> Bool {
guard event.keyCode == 36 || event.keyCode == 76 else { return false }
let multilineModifiers: NSEvent.ModifierFlags = [.shift, .option, .command, .control]
return event.modifierFlags.intersection(multilineModifiers).isEmpty
}
}

View File

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

BIN
assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

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

View File

@@ -2,6 +2,32 @@
Run this checklist before tagging a public release. Run this checklist before tagging a public release.
## Workflow Contract
Use this section as the source of truth when checking whether a feature matches the product workflow before changing or releasing it.
- Opened PDFs intentionally reset to focused single-pane reading with sidebars hidden.
- Plain Highlight is standalone.
- Selected-text Comment and Underline open the anchored editor.
- The visible Save toolbar icon is intentionally absent. Save remains available from the File menu and keyboard shortcut; Share remains visible.
- Compact windows intentionally make left and right sidebars mutually exclusive to preserve usable PDF width.
- Sidebar resize handles should be easy to grab through hover/cursor affordance without becoming large visual dividers.
- Experimental Fill & Sign, custom form-field navigation, PDF signing, signature inspection, and related QA are outside v0.4 scope.
## Latest v0.4 Automated QA Run
Completed on 2026-06-30 for build 7:
- `swift test`
- `swift scripts/verify-pdf-annotations.swift`
- `swift build -c release --product IHatePDFs`
- `scripts/build-app.sh`
- `BUILD_APP=0 scripts/make-dmg.sh`
- `scripts/make-tiny-archives.sh`
- `PKG_PATH=dist/nonexistent-appstore.pkg scripts/verify-release-artifacts.sh`
Before App Store upload, also run `scripts/make-app-store-pkg.sh` with the required signing identities and provisioning profile, then verify the generated package with `scripts/verify-release-artifacts.sh`.
## Test Files ## Test Files
Use at least: Use at least:
@@ -14,59 +40,55 @@ Use at least:
## App Workflow ## App Workflow
1. Open the PDF in I Hate PDFs. 1. Open a PDF and verify it starts in focused single-pane reading: the PDF is fit to the available window width and all sidebars are hidden.
2. Select text and add a highlight. 2. Close the PDF, then drag a `.pdf` file onto the empty window and verify it opens.
3. Add a comment to the highlight. 3. Open one or more PDFs, close the current PDF, and verify recent PDFs appear in the empty window and File > Open Recent.
4. Add an underline with a comment. 4. Open Settings from File > Settings... and with Command-, then verify highlight and comment colors can be edited and reset.
5. Select text, right-click, and add a comment from the context menu. 5. Select text and add a highlight; verify no comment popover opens.
6. Add free text directly on the page. 6. Select text and add a comment; verify the comment color matches the Settings value.
7. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate. 7. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved.
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. 8. Add an underline with a comment.
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. 9. Select text, right-click, and add a comment from the context menu.
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. 10. Add free text directly on the page.
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. 11. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
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. 12. Open Pages, Marks, Comments, Highlights, and Bookmarks sidebars, then close and reopen the PDF; verify the reopened document returns to focused single-pane reading with sidebars hidden.
13. Save As an annotated copy. 13. Open Bookmarks, click the right-sidebar toolbar icon, and verify the right sidebar closes instead of switching to Comments; click it again and verify Bookmarks reopens.
14. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain. 14. Resize the same document through compact, regular, and wide widths; verify compact windows keep sidebars mutually exclusive while regular and wide windows can show both sidebars without shrinking the PDF below a usable reading width.
15. Save over a disposable original and verify the overwrite warning appears. 15. Hover and drag the divider between the PDF and each sidebar and verify the resize cursor/hover affordance is easy to grab without an oversized visual handle.
16. Add at least one reply and verify the comments sidebar presents the thread clearly.
17. Hover a comment row and verify the corresponding PDF text is highlighted; click parent and reply rows and verify the PDF navigates correctly.
18. Click commented text and underlined text and verify the comment popover opens; click nearby whitespace and verify no popover opens.
19. Switch the app to dark mode and verify the reading background, sidebars, editor popover, selected rows, text fields, and annotation markers remain legible.
20. Save As an annotated copy.
21. Reopen the annotated copy in I Hate PDFs and verify annotations and comments remain.
22. Save over a disposable original and verify the overwrite warning appears.
23. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
24. Search for a word, close the search toolbar, and verify match highlights disappear.
25. Type invalid and out-of-range page numbers and verify the app restores the current page number with a clear status message.
26. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies.
27. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until sent or canceled.
28. Hide a sidebar or apply a filter that removes the selected row and verify the PDF selection highlight clears.
29. Start typing a sidebar reply without sending it, then close, replace, save, or share the PDF and verify the app warns before omitting or discarding the draft.
30. Create a new selected-text comment or free text, leave its popover empty, choose Save or Share, and verify the temporary empty annotation is discarded.
## Regression Coverage
- Focused reading layout after opening a PDF.
- Returning to the empty-window workflow after closing a PDF.
- Compact one-sidebar-at-a-time behavior.
- Page sidebar toggle closing the active Marks sidebar instead of switching to Pages first.
- Right-sidebar toolbar toggle closing and reopening the current right mode without switching tabs.
- Regular-width ability to show navigation and review sidebars together.
- Save availability for clean, dirty, and reply-draft-only states.
## External Readers ## External Readers
Before manual reader checks, run the automated PDF structure checks: Before manual reader checks, run the automated PDF structure checks:
```sh ```sh
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.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. This check generates an annotated PDF, reopens it with PDFKit, and inspects raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
Open the saved annotated copy in: For Preview, Acrobat Reader, and browser PDF viewers, verify exported markup comments keep their comment text on the parent annotation's standard `/Contents` key and do not depend on PDFKit-generated `/Popup` links for highlights or underlines.
- macOS Preview
- Adobe Acrobat Reader
- Safari, Chrome, and Firefox PDF viewers where annotations are supported
Verify:
- Highlighted text remains highlighted.
- Underlined text remains underlined.
- Selected-text comments remain attached to the referenced text.
- Highlight and selected-text comments can be opened.
- Free text remains visible on the page.
- Existing text, images, layout, bookmarks, and prior annotations remain intact.
## Visual QA Screenshots
Capture current screenshots in `docs/screenshots` for:
- `no-document.png`
- `default-reading.png`
- `highlight-comment-popover.png`
- `selected-text-comment-popover.png`
- `comments-sidebar.png`, including at least one reply thread with a visible connector line
- `dark-mode-reading.png`
## Known Version 1 Limitation
PDFKit rejects object-valued `/IRT` reply relationships through its public API. Replies created in this app are saved as standard `/Text` annotations with string `/IRT` and `/RT` reply keys, but the reply annotation is hidden on the PDF page so it appears as a threaded sidebar reply instead of a second page icon. Full cross-reader reply-thread presentation must be verified and improved with a lower-level PDF writer if needed.

87
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,87 @@
# Release Workflow
Use this checklist when preparing a new public version.
## Version Source
The release version lives in one place:
```sh
scripts/release-version.sh
```
For a new app version, update `APP_VERSION` and reset or increment `BUILD_NUMBER`.
For another upload of the same app version, leave `APP_VERSION` alone and increment
`BUILD_NUMBER`.
`RELEASE_VERSION` defaults to the app version without a trailing `.0`, so `0.4.0`
produces release artifacts named with `v0.4`.
## Required Checks
Run these before tagging or uploading:
```sh
swift test
swift scripts/verify-pdf-annotations.swift
scripts/build-app.sh
BUILD_APP=0 scripts/make-dmg.sh
scripts/make-tiny-archives.sh
scripts/verify-release-artifacts.sh
```
`scripts/make-tiny-archives.sh` builds per-architecture direct-download archives and
fails if either archive is `>= 400,000` bytes.
Finish the manual reader checks in `docs/QA.md` before public distribution.
## Direct Download
The direct-download release artifacts are generated under `dist/`:
- `I Hate PDFs.app`
- `IHatePDFs-v<release>-macos.dmg`
- `IHatePDFs-v<release>-macos-arm64.tar.xz`
- `IHatePDFs-v<release>-macos-x86_64.tar.xz`
`dist/` and root package/archive extensions are ignored so generated outputs do not
pollute source control.
## App Store
Bundle ID: `net.akkolli.ihatepdfs`
Project website: `https://www.akkolli.net/ihatepdfs`
Support email: `akshaykolli@hotmail.com`
Required Apple Developer items:
- Explicit macOS App ID for `net.akkolli.ihatepdfs`.
- App Store provisioning profile for that App ID.
- Application signing certificate installed in Keychain.
- Installer signing certificate installed in Keychain.
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 a shipped feature requires them.
Build the upload package:
```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
```
After building the App Store package, run:
```sh
REQUIRE_APP_STORE_PKG=1 scripts/verify-release-artifacts.sh
```
Upload the `.pkg` with Transporter or App Store Connect tooling. Keep `APP_VERSION` and `BUILD_NUMBER` in `scripts/release-version.sh` aligned with App Store Connect before submitting.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

View File

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

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 451 KiB

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 496 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 KiB

448
goal.md
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -3,36 +3,115 @@ set -euo pipefail
APP_NAME="I Hate PDFs" APP_NAME="I Hate PDFs"
EXECUTABLE_NAME="IHatePDFs" EXECUTABLE_NAME="IHatePDFs"
APP_VERSION="${APP_VERSION:-0.2.0}" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_NUMBER="${BUILD_NUMBER:-2}" source "$ROOT_DIR/scripts/release-version.sh"
CONFIGURATION="${CONFIGURATION:-release}" CONFIGURATION="${CONFIGURATION:-release}"
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
SIZE_OPTIMIZED="${SIZE_OPTIMIZED:-0}"
STRIP_RELEASE="${STRIP_RELEASE:-1}"
ICON_MAX_SIZE="${ICON_MAX_SIZE:-1024}"
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 if [[ -z "${ARCHS+x}" && "$CONFIGURATION" == "release" ]]; then
ARCHS="arm64 x86_64" ARCHS="arm64 x86_64"
else else
ARCHS="${ARCHS:-}" ARCHS="${ARCHS:-}"
fi fi
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$ROOT_DIR/dist" DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app" APP_DIR="${APP_DIR:-$DIST_DIR/$APP_NAME.app}"
CONTENTS_DIR="$APP_DIR/Contents" CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS" MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources" RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_SOURCE="$ROOT_DIR/ihatepdf.png" ICON_SOURCE="${ICON_SOURCE:-$ROOT_DIR/assets/app-icon.png}"
if [[ ! -f "$ICON_SOURCE" ]]; then
echo "Missing app icon source: $ICON_SOURCE" >&2
echo "Set ICON_SOURCE to the path of a transparent PNG icon (for example: $ROOT_DIR/assets/app-icon.png)." >&2
exit 1
fi
if ! sips -g hasAlpha "$ICON_SOURCE" 2>/dev/null | grep -q "hasAlpha: yes"; then
echo "App icon source must include an alpha channel for transparent rendering: $ICON_SOURCE" >&2
exit 1
fi
ICON_NAME="AppIcon" ICON_NAME="AppIcon"
DERIVED_ENTITLEMENTS_PATH=""
PROFILE_PLIST_PATH=""
NORMALIZED_ICON_SOURCE=""
cleanup() {
if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then
rm -f "$DERIVED_ENTITLEMENTS_PATH"
fi
if [[ -n "$NORMALIZED_ICON_SOURCE" ]]; then
rm -f "$NORMALIZED_ICON_SOURCE"
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" cd "$ROOT_DIR"
SWIFT_BUILD_ARGS=(-c "$CONFIGURATION") SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
for ARCH in $ARCHS; do for ARCH in $ARCHS; do
SWIFT_BUILD_ARGS+=(--arch "$ARCH") SWIFT_BUILD_ARGS+=(--arch "$ARCH")
done done
if [[ "$CONFIGURATION" == "release" && "$SIZE_OPTIMIZED" == "1" ]]; then
SWIFT_BUILD_ARGS+=(
-Xswiftc -Osize
-Xswiftc -Xfrontend -Xswiftc -disable-reflection-metadata
-Xswiftc -Xfrontend -Xswiftc -remove-runtime-asserts
)
fi
swift build "${SWIFT_BUILD_ARGS[@]}"
BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)" BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
swift build "${SWIFT_BUILD_ARGS[@]}"
rm -rf "$APP_DIR" rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME" cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then
if [[ "$SIZE_OPTIMIZED" == "1" ]]; then
strip -u -r "$MACOS_DIR/$EXECUTABLE_NAME"
else
strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
fi
fi
NORMALIZED_ICON_SOURCE="$(mktemp /tmp/ihatepdf-appicon-XXXXXX.png)"
if ! sips -s format png "$ICON_SOURCE" --out "$NORMALIZED_ICON_SOURCE" >/dev/null; then
rm -f "$NORMALIZED_ICON_SOURCE"
NORMALIZED_ICON_SOURCE=""
echo "Failed to normalize icon source: $ICON_SOURCE" >&2
exit 1
fi
ICON_SOURCE="$NORMALIZED_ICON_SOURCE"
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 if [[ ! -f "$ICON_SOURCE" ]]; then
echo "Missing app icon source: $ICON_SOURCE" >&2 echo "Missing app icon source: $ICON_SOURCE" >&2
exit 1 exit 1
@@ -45,19 +124,31 @@ mkdir -p "$ICONSET_DIR"
make_icon() { make_icon() {
local pixels="$1" local pixels="$1"
local output="$2" local output="$2"
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$ICONSET_DIR/$output" >/dev/null local output_path="$ICONSET_DIR/$output"
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$output_path" >/dev/null
} }
make_icon 16 "icon_16x16.png" make_icon 16 "icon_16x16.png"
make_icon 32 "icon_16x16@2x.png" make_icon 32 "icon_16x16@2x.png"
make_icon 32 "icon_32x32.png" make_icon 32 "icon_32x32.png"
make_icon 64 "icon_32x32@2x.png" make_icon 64 "icon_32x32@2x.png"
make_icon 128 "icon_128x128.png" if (( ICON_MAX_SIZE >= 128 )); then
make_icon 256 "icon_128x128@2x.png" make_icon 128 "icon_128x128.png"
make_icon 256 "icon_256x256.png" fi
make_icon 512 "icon_256x256@2x.png" if (( ICON_MAX_SIZE >= 128 )); then
make_icon 512 "icon_512x512.png" make_icon 256 "icon_128x128@2x.png"
make_icon 1024 "icon_512x512@2x.png" fi
if (( ICON_MAX_SIZE >= 256 )); then
make_icon 256 "icon_256x256.png"
fi
if (( ICON_MAX_SIZE >= 512 )); then
make_icon 512 "icon_256x256@2x.png"
make_icon 512 "icon_512x512.png"
fi
if (( ICON_MAX_SIZE >= 1024 )); then
make_icon 1024 "icon_512x512@2x.png"
fi
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns" iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns"
rm -rf "$ICONSET_DIR" rm -rf "$ICONSET_DIR"
@@ -72,7 +163,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$EXECUTABLE_NAME</string> <string>$EXECUTABLE_NAME</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>org.ihatepdfs.app</string> <string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
@@ -104,6 +195,8 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>$BUILD_NUMBER</string> <string>$BUILD_NUMBER</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
<true/> <true/>
<key>NSSupportsAutomaticGraphicsSwitching</key> <key>NSSupportsAutomaticGraphicsSwitching</key>
@@ -111,9 +204,56 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>MIT License</string> <string>GNU General Public License version 2</string>
</dict> </dict>
</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" echo "Built $APP_DIR"
du -sh "$APP_DIR" "$MACOS_DIR/$EXECUTABLE_NAME" "$RESOURCES_DIR/$ICON_NAME.icns"

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

@@ -0,0 +1,79 @@
#!/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"
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}"
STAGING_DIR=""
cleanup() {
if [[ -n "$STAGING_DIR" ]]; then
rm -rf "$STAGING_DIR"
fi
}
trap cleanup EXIT
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"
rm -f "$PKG_PATH"
STAGING_DIR="$(mktemp -d "$DIST_DIR/appstore-pkg.XXXXXX")"
APP_DIR="$STAGING_DIR/$APP_NAME.app"
BUNDLE_ID="$BUNDLE_ID" \
APP_VERSION="$APP_VERSION" \
BUILD_NUMBER="$BUILD_NUMBER" \
APP_DIR="$APP_DIR" \
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
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 "@env:ASC_PASSWORD"
fi
echo "Created App Store package: $PKG_PATH"

View File

@@ -2,22 +2,31 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
APP_NAME="I Hate PDFs" APP_NAME="I Hate PDFs"
RELEASE_VERSION="${RELEASE_VERSION:-0.2}"
DIST_DIR="$ROOT_DIR/dist" DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app" APP_DIR="$DIST_DIR/$APP_NAME.app"
DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg" DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg"
BUILD_APP="${BUILD_APP:-1}"
if [[ ! -d "$APP_DIR" ]]; then if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then
"$ROOT_DIR/scripts/build-app.sh" APP_VERSION="$APP_VERSION" BUILD_NUMBER="$BUILD_NUMBER" "$ROOT_DIR/scripts/build-app.sh"
fi fi
rm -f "$DMG_PATH" rm -f "$DMG_PATH"
hdiutil create \ if diskutil image create from --help >/dev/null 2>&1; then
-volname "$APP_NAME" \ diskutil image create from \
-srcfolder "$APP_DIR" \ --format UDZO \
-ov \ --volumeName "$APP_NAME" \
-format UDZO \ "$APP_DIR" \
"$DMG_PATH" "$DMG_PATH"
else
hdiutil create \
-volname "$APP_NAME" \
-srcfolder "$APP_DIR" \
-ov \
-format UDZO \
"$DMG_PATH"
fi
echo "Created $DMG_PATH" echo "Created $DMG_PATH"

85
scripts/make-tiny-archives.sh Executable file
View File

@@ -0,0 +1,85 @@
#!/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"
DIST_DIR="$ROOT_DIR/dist"
STAGING_DIR="$DIST_DIR/tiny"
ARCHS_TO_BUILD="${ARCHS_TO_BUILD:-arm64 x86_64}"
PER_ARCH_INSTALLER_MAX_BYTES="${PER_ARCH_INSTALLER_MAX_BYTES:-400000}"
if ! command -v xz >/dev/null 2>&1; then
echo "xz is required to build size-gated tiny archives with architecture filters." >&2
exit 1
fi
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
compression_args_for_arch() {
local arch="$1"
if [[ -n "${XZ_OPT:-}" ]]; then
# Preserve explicit caller overrides.
echo "$XZ_OPT"
return
fi
case "$arch" in
arm64)
echo "--arm64 --lzma2=preset=9e"
;;
x86_64)
echo "--x86 --lzma2=preset=9e"
;;
*)
echo "--lzma2=preset=9e"
;;
esac
}
file_size() {
stat -f '%z' "$1"
}
verify_under_budget() {
local path="$1"
[[ -f "$path" ]] || {
echo "missing $path" >&2
exit 1
}
local bytes
bytes="$(file_size "$path")"
if (( bytes >= PER_ARCH_INSTALLER_MAX_BYTES )); then
echo "$path is $bytes bytes; per-architecture installer budget is < $PER_ARCH_INSTALLER_MAX_BYTES bytes" >&2
exit 1
fi
echo "OK: $path is $bytes bytes (< $PER_ARCH_INSTALLER_MAX_BYTES)."
}
for ARCH in $ARCHS_TO_BUILD; do
APP_DIR="$STAGING_DIR/$ARCH/$APP_NAME.app"
ARCHIVE_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-$ARCH.tar.xz"
rm -f "$ARCHIVE_PATH"
mkdir -p "$(dirname "$APP_DIR")"
ARCHS="$ARCH" \
SIZE_OPTIMIZED=1 \
ICON_MAX_SIZE="${ICON_MAX_SIZE:-32}" \
APP_VERSION="$APP_VERSION" \
BUILD_NUMBER="$BUILD_NUMBER" \
APP_DIR="$APP_DIR" \
"$ROOT_DIR/scripts/build-app.sh"
read -r -a XZ_ARGS <<< "$(compression_args_for_arch "$ARCH")"
COPYFILE_DISABLE=1 tar -C "$(dirname "$APP_DIR")" -cf - "$APP_NAME.app" \
| env XZ_OPT= xz "${XZ_ARGS[@]}" -c > "$ARCHIVE_PATH"
echo "Created $ARCHIVE_PATH"
verify_under_budget "$ARCHIVE_PATH"
done

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

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

View File

@@ -1,5 +1,16 @@
import AppKit
import CoreGraphics import CoreGraphics
import Foundation import Foundation
import PDFKit
let defaultInputPath = "dist/annotation-verification.pdf"
let arguments = Array(CommandLine.arguments.dropFirst())
let inputURL = URL(fileURLWithPath: arguments.first ?? defaultInputPath)
let verificationDate = Date(timeIntervalSince1970: 1_797_000_000)
if arguments.isEmpty {
try generateVerificationPDF(at: inputURL)
}
struct AnnotationSummary { struct AnnotationSummary {
var highlights = 0 var highlights = 0
@@ -18,8 +29,9 @@ enum VerificationError: Error, CustomStringConvertible {
case missingName(page: Int, index: Int, key: String) case missingName(page: Int, index: Int, key: String)
case missingString(page: Int, index: Int, key: String) case missingString(page: Int, index: Int, key: String)
case missingArray(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 missingPopupParent(page: Int, index: Int)
case unexpectedMarkupPopup(page: Int, index: Int, subtype: String)
case unexpectedPopupLink(page: Int, index: Int, subtype: String)
case missingExpectedSubtype(String) case missingExpectedSubtype(String)
var description: String { var description: String {
@@ -36,19 +48,18 @@ enum VerificationError: Error, CustomStringConvertible {
return "Annotation \(index) on page \(page) is missing string key /\(key)" return "Annotation \(index) on page \(page) is missing string key /\(key)"
case .missingArray(let page, let index, let key): case .missingArray(let page, let index, let key):
return "Annotation \(index) on page \(page) is missing array key /\(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): case .missingPopupParent(let page, let index):
return "Popup annotation \(index) on page \(page) is missing a /Parent dictionary" 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): case .missingExpectedSubtype(let subtype):
return "Expected at least one /\(subtype) annotation" return "Expected at least one /\(subtype) annotation"
} }
} }
} }
let inputPath = CommandLine.arguments.dropFirst().first ?? "dist/annotation-verification.pdf"
let inputURL = URL(fileURLWithPath: inputPath)
guard let document = CGPDFDocument(inputURL as CFURL) else { guard let document = CGPDFDocument(inputURL as CFURL) else {
throw VerificationError.unreadablePDF(inputURL.path) throw VerificationError.unreadablePDF(inputURL.path)
} }
@@ -85,7 +96,7 @@ for pageNumber in 1...document.numberOfPages {
switch subtype { switch subtype {
case "Highlight": case "Highlight":
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex) 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") { if hasString(in: annotation, key: "IHatePDFsKind") {
try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex) try requireString(in: annotation, key: "IHatePDFsKind", page: pageNumber, index: annotationIndex)
summary.selectedTextComments += 1 summary.selectedTextComments += 1
@@ -95,7 +106,7 @@ for pageNumber in 1...document.numberOfPages {
case "Underline": case "Underline":
summary.underlines += 1 summary.underlines += 1
try requireMarkupKeys(in: annotation, page: pageNumber, index: annotationIndex) 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": case "Text":
try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex) try requireTextKeys(in: annotation, page: pageNumber, index: annotationIndex)
@@ -104,7 +115,6 @@ for pageNumber in 1...document.numberOfPages {
try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex) try requireString(in: annotation, key: "IRT", page: pageNumber, index: annotationIndex)
try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex) try requireString(in: annotation, key: "RT", page: pageNumber, index: annotationIndex)
} else { } else {
try requirePopup(in: annotation, page: pageNumber, index: annotationIndex, subtype: "Text")
summary.textNotes += 1 summary.textNotes += 1
} }
case "FreeText": case "FreeText":
@@ -118,9 +128,24 @@ for pageNumber in 1...document.numberOfPages {
case "Popup": case "Popup":
summary.popups += 1 summary.popups += 1
var parentDictionary: CGPDFDictionaryRef? var parentDictionary: CGPDFDictionaryRef?
guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary) else { guard CGPDFDictionaryGetDictionary(annotation, "Parent", &parentDictionary),
let parentDictionary
else {
throw VerificationError.missingPopupParent(page: pageNumber, index: annotationIndex) 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: default:
continue continue
} }
@@ -145,10 +170,6 @@ guard summary.replies > 0 else {
guard summary.freeText > 0 else { guard summary.freeText > 0 else {
throw VerificationError.missingExpectedSubtype("FreeText") 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.") 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( func annotationDictionary(
@@ -251,6 +272,20 @@ func requireMarkupKeys(
try requireString(in: dictionary, key: "M", page: page, index: index) 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( func requireTextKeys(
in dictionary: CGPDFDictionaryRef, in dictionary: CGPDFDictionaryRef,
page: Int, page: Int,
@@ -263,21 +298,163 @@ func requireTextKeys(
try requireString(in: dictionary, key: "M", page: page, index: index) try requireString(in: dictionary, key: "M", page: page, index: index)
} }
func requirePopup( func generateVerificationPDF(at outputURL: URL) throws {
in dictionary: CGPDFDictionaryRef, try FileManager.default.createDirectory(
page: Int, at: outputURL.deletingLastPathComponent(),
index: Int, withIntermediateDirectories: true
subtype: String )
) throws {
var popupDictionary: CGPDFDictionaryRef? let page = PDFPage()
guard CGPDFDictionaryGetDictionary(dictionary, "Popup", &popupDictionary), let document = PDFDocument()
let popupDictionary document.insert(page, at: 0)
else {
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype) let highlight = PDFAnnotation(
bounds: CGRect(x: 72, y: 620, width: 260, height: 24),
forType: .highlight,
withProperties: nil
)
highlight.markupType = .highlight
highlight.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.24)
highlight.quadrilateralPoints = quadPoints(width: 260, height: 24)
standardize(
highlight,
name: "verify-highlight",
contents: "This is a standards-compliant PDF highlight comment.",
author: "Professor"
)
page.addAnnotation(highlight)
let selectedTextComment = PDFAnnotation(
bounds: CGRect(x: 72, y: 594, width: 260, height: 22),
forType: .highlight,
withProperties: nil
)
selectedTextComment.markupType = .highlight
selectedTextComment.color = NSColor(calibratedRed: 0.88, green: 0.72, blue: 0.46, alpha: 0.10)
selectedTextComment.quadrilateralPoints = quadPoints(width: 260, height: 22)
standardize(
selectedTextComment,
name: "verify-selected-text-comment",
contents: "This selected-text comment is saved as standard parent annotation contents.",
author: "Professor"
)
_ = selectedTextComment.setValue("Comment", forAnnotationKey: PDFAnnotationKey(rawValue: "IHatePDFsKind"))
page.addAnnotation(selectedTextComment)
let underline = PDFAnnotation(
bounds: CGRect(x: 72, y: 570, width: 260, height: 24),
forType: .underline,
withProperties: nil
)
underline.markupType = .underline
underline.color = NSColor(calibratedRed: 0.48, green: 0.53, blue: 0.62, alpha: 0.56)
underline.quadrilateralPoints = quadPoints(width: 260, height: 24)
standardize(
underline,
name: "verify-underline",
contents: "This underline comment should remain openable.",
author: "Professor"
)
page.addAnnotation(underline)
let textNote = PDFAnnotation(
bounds: CGRect(x: 360, y: 620, width: 28, height: 28),
forType: .text,
withProperties: nil
)
textNote.iconType = .note
textNote.color = NSColor(calibratedRed: 0.64, green: 0.59, blue: 0.49, alpha: 0.90)
standardize(
textNote,
name: "verify-text-note",
contents: "This standard PDF text annotation remains visible in common PDF readers.",
author: "Professor"
)
page.addAnnotation(textNote)
let reply = PDFAnnotation(
bounds: CGRect(x: 402, y: 586, width: 24, height: 24),
forType: .text,
withProperties: nil
)
reply.iconType = .comment
reply.color = NSColor(calibratedRed: 0.52, green: 0.58, blue: 0.60, alpha: 0.88)
standardize(
reply,
name: "verify-reply",
contents: "This reply is saved as PDF reply data without drawing an extra page icon.",
author: "Reader"
)
_ = reply.setValue("verify-text-note", forAnnotationKey: PDFAnnotationKey(rawValue: "IRT"))
_ = reply.setValue("R", forAnnotationKey: PDFAnnotationKey(rawValue: "RT"))
reply.shouldDisplay = false
reply.shouldPrint = false
page.addAnnotation(reply)
let freeText = PDFAnnotation(
bounds: CGRect(x: 72, y: 500, width: 260, height: 50),
forType: .freeText,
withProperties: nil
)
freeText.font = NSFont.systemFont(ofSize: 13)
freeText.fontColor = NSColor(calibratedWhite: 0.22, alpha: 1)
freeText.color = NSColor(calibratedRed: 0.91, green: 0.86, blue: 0.75, alpha: 0.32)
freeText.alignment = .left
let border = PDFBorder()
border.lineWidth = 0.75
freeText.border = border
standardize(
freeText,
name: "verify-free-text",
contents: "Free text remains visible on the PDF page.",
author: "Professor"
)
page.addAnnotation(freeText)
guard document.write(to: outputURL) else {
fatalError("Unable to write \(outputURL.path)")
} }
let popupSubtype = try nameValue(in: popupDictionary, key: "Subtype", page: page, index: index) let reopened = PDFDocument(url: outputURL)!
guard popupSubtype == "Popup" else { let annotations = reopened.page(at: 0)!.annotations
throw VerificationError.missingPopup(page: page, index: index, subtype: subtype) precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("highlight") == true })
} precondition(annotations.contains { matches($0, .highlight) && $0.contents?.contains("selected-text comment") == true })
precondition(annotations.contains { matches($0, .underline) && $0.contents?.contains("underline") == true })
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("text annotation") == true })
precondition(annotations.contains { matches($0, .text) && $0.contents?.contains("reply") == true && !$0.shouldDisplay && !$0.shouldPrint })
precondition(annotations.contains { matches($0, .freeText) && $0.contents?.contains("Free text") == true })
}
func standardize(
_ annotation: PDFAnnotation,
name: String,
contents: String,
author: String
) {
annotation.contents = contents
annotation.userName = author
annotation.modificationDate = verificationDate
annotation.shouldDisplay = true
annotation.shouldPrint = true
_ = annotation.setValue(name, forAnnotationKey: .name)
_ = annotation.setValue(author, forAnnotationKey: .textLabel)
_ = annotation.setValue(verificationDate, forAnnotationKey: .date)
_ = annotation.setValue("D:20261215132000Z00'00'", forAnnotationKey: PDFAnnotationKey(rawValue: "CreationDate"))
_ = annotation.setValue("Unmarked", forAnnotationKey: PDFAnnotationKey(rawValue: "State"))
}
func quadPoints(width: CGFloat, height: CGFloat) -> [NSValue] {
[
NSValue(point: CGPoint(x: 0, y: height)),
NSValue(point: CGPoint(x: width, y: height)),
NSValue(point: CGPoint(x: 0, y: 0)),
NSValue(point: CGPoint(x: width, y: 0))
]
}
func matches(_ annotation: PDFAnnotation, _ subtype: PDFAnnotationSubtype) -> Bool {
guard let type = annotation.type else { return false }
let raw = subtype.rawValue
let normalized = raw.hasPrefix("/") ? String(raw.dropFirst()) : raw
return type == raw || type == normalized
} }

View File

@@ -0,0 +1,129 @@
#!/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}"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg"
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
REQUIRE_APP_STORE_PKG="${REQUIRE_APP_STORE_PKG:-0}"
PLISTBUDDY="/usr/libexec/PlistBuddy"
TEMP_PATHS=()
cleanup() {
((${#TEMP_PATHS[@]})) || return 0
for path in "${TEMP_PATHS[@]}"; do
rm -rf "$path"
done
}
trap cleanup EXIT
fail() {
echo "release artifact verification failed: $*" >&2
exit 1
}
require_file() {
local path="$1"
[[ -e "$path" ]] || fail "missing $path"
}
plist_value() {
local plist="$1"
local key="$2"
"$PLISTBUDDY" -c "Print :$key" "$plist"
}
verify_app_bundle() {
require_file "$APP_DIR/Contents/Info.plist"
local version
local build
local bundle_id
version="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleShortVersionString")"
build="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleVersion")"
bundle_id="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleIdentifier")"
[[ "$bundle_id" == "$BUNDLE_ID" ]] || fail "$APP_DIR bundle id is $bundle_id, expected $BUNDLE_ID"
[[ "$version" == "$APP_VERSION" ]] || fail "$APP_DIR version is $version, expected $APP_VERSION"
[[ "$build" == "$BUILD_NUMBER" ]] || fail "$APP_DIR build is $build, expected $BUILD_NUMBER"
[[ ! -e "$APP_DIR/Contents/embedded.provisionprofile" ]] \
|| fail "$APP_DIR contains an embedded provisioning profile; direct DMG app should not"
}
verify_dmg() {
require_file "$DMG_PATH"
if command -v diskutil >/dev/null 2>&1 &&
diskutil image info "$DMG_PATH" 2>/dev/null | grep -q "Image Format: UDZO"; then
return
fi
if command -v hdiutil >/dev/null 2>&1 &&
hdiutil imageinfo "$DMG_PATH" 2>/dev/null | grep -q "Format: UDZO"; then
return
fi
fail "$DMG_PATH is not a compressed read-only UDZO image"
}
verify_pkg() {
require_file "$PKG_PATH"
pkgutil --check-signature "$PKG_PATH" >/dev/null
local expanded_parent
local expanded
expanded_parent="$(mktemp -d "$DIST_DIR/pkg-verify.XXXXXX")"
TEMP_PATHS+=("$expanded_parent")
expanded="$expanded_parent/expanded.pkg"
pkgutil --expand-full "$PKG_PATH" "$expanded" >/dev/null
local plist
plist="$(find "$expanded" -path "*/I Hate PDFs.app/Contents/Info.plist" -print -quit)"
[[ -n "$plist" ]] || fail "$PKG_PATH does not contain I Hate PDFs.app"
local app_contents
app_contents="$(dirname "$plist")"
local version
local build
local bundle_id
version="$(plist_value "$plist" "CFBundleShortVersionString")"
build="$(plist_value "$plist" "CFBundleVersion")"
bundle_id="$(plist_value "$plist" "CFBundleIdentifier")"
[[ "$bundle_id" == "$BUNDLE_ID" ]] || fail "$PKG_PATH app bundle id is $bundle_id, expected $BUNDLE_ID"
[[ "$version" == "$APP_VERSION" ]] || fail "$PKG_PATH app version is $version, expected $APP_VERSION"
[[ "$build" == "$BUILD_NUMBER" ]] || fail "$PKG_PATH app build is $build, expected $BUILD_NUMBER"
[[ -e "$app_contents/embedded.provisionprofile" ]] \
|| fail "$PKG_PATH app is missing embedded.provisionprofile"
local app_bundle
local entitlements
app_bundle="$(dirname "$app_contents")"
entitlements="$expanded/entitlements.plist"
codesign -d --entitlements :- "$app_bundle" > "$entitlements" 2>/dev/null \
|| fail "$PKG_PATH app entitlements could not be read"
[[ "$(plist_value "$entitlements" "com.apple.security.app-sandbox")" == "true" ]] \
|| fail "$PKG_PATH app is missing com.apple.security.app-sandbox"
[[ "$(plist_value "$entitlements" "com.apple.security.files.user-selected.read-write")" == "true" ]] \
|| fail "$PKG_PATH app is missing com.apple.security.files.user-selected.read-write"
rm -rf "$expanded_parent"
}
verify_app_bundle
verify_dmg
if [[ "$REQUIRE_APP_STORE_PKG" == "1" || -e "$PKG_PATH" ]]; then
verify_pkg
elif [[ "$REQUIRE_APP_STORE_PKG" == "0" ]]; then
echo "Skipping App Store pkg verification because $PKG_PATH does not exist."
else
fail "invalid REQUIRE_APP_STORE_PKG=$REQUIRE_APP_STORE_PKG"
fi
echo "Verified release artifacts for I Hate PDFs $APP_VERSION ($BUILD_NUMBER)."

View File

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

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,138 @@
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 highlightSwatches: [(name: String, color: NSColor)] {
[
("Yellow", AcademicAnnotationPalette.highlight),
("Green", NSColor(calibratedRed: 0.27, green: 0.78, blue: 0.28, alpha: 0.58)),
("Aqua", NSColor(calibratedRed: 0.0, green: 0.70, blue: 0.78, alpha: 0.58)),
("Pink", NSColor(calibratedRed: 1.0, green: 0.32, blue: 0.62, alpha: 0.58)),
("Orange", NSColor(calibratedRed: 1.0, green: 0.48, blue: 0.08, alpha: 0.58)),
("Purple", NSColor(calibratedRed: 0.62, green: 0.36, blue: 0.94, alpha: 0.58)),
("Cyan", NSColor(calibratedRed: 0.0, green: 0.78, blue: 0.92, alpha: 0.56)),
("Graphite", NSColor(calibratedWhite: 0.28, alpha: 0.50))
]
}
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 displayColor(forHighlightColor color: NSColor) -> NSColor {
highlightColor(from: storageString(for: color)).withAlphaComponent(1)
}
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)
}
}
}

2741
sources/app/AppState.swift Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,6 @@ final class CommentPopoverModel: ObservableObject {
struct CommentEditorView: View { struct CommentEditorView: View {
@ObservedObject var model: CommentPopoverModel @ObservedObject var model: CommentPopoverModel
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@FocusState private var isCommentFocused: Bool
private let editorHorizontalInset: CGFloat = 9 private let editorHorizontalInset: CGFloat = 9
private let editorVerticalInset: CGFloat = 7 private let editorVerticalInset: CGFloat = 7
@@ -58,20 +57,12 @@ struct CommentEditorView: View {
.padding(12) .padding(12)
.frame(width: 340) .frame(width: 340)
.background(.regularMaterial) .background(.regularMaterial)
.onAppear {
DispatchQueue.main.async {
isCommentFocused = true
}
}
.onChange(of: model.text) { _ in .onChange(of: model.text) { _ in
model.updateDraft() model.updateDraft()
} }
.onChange(of: model.author) { _ in .onChange(of: model.author) { _ in
model.updateDraft() model.updateDraft()
} }
.commitOnPlainReturn {
model.commit()
}
} }
private var header: some View { private var header: some View {
@@ -83,32 +74,23 @@ struct CommentEditorView: View {
Text(title) Text(title)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
Spacer()
Button {
model.commit()
} label: {
Label("Done", systemImage: "checkmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.return, modifiers: [.command])
.help("Done")
} }
} }
private var commentField: some View { private var commentField: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
TextEditor(text: $model.text) CommitTextView(
.font(.body) text: $model.text,
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) font: NSFont.preferredFont(forTextStyle: .body),
.scrollContentBackground(.hidden) onCommit: {
.focused($isCommentFocused) model.commit()
}
)
.padding(.horizontal, editorHorizontalInset) .padding(.horizontal, editorHorizontalInset)
.padding(.vertical, editorVerticalInset) .padding(.vertical, editorVerticalInset)
if model.text.isEmpty { if model.text.isEmpty {
Text("Add comment") Text(placeholderText)
.font(.body) .font(.body)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme)) .foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.padding(.leading, editorHorizontalInset + 7) .padding(.leading, editorHorizontalInset + 7)
@@ -130,6 +112,9 @@ struct CommentEditorView: View {
TextField("Author", text: $model.author) TextField("Author", text: $model.author)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) .foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.onSubmit {
model.commit()
}
.padding(.horizontal, 7) .padding(.horizontal, 7)
.frame(height: 28) .frame(height: 28)
.background(InterfacePalette.fieldFill(for: colorScheme)) .background(InterfacePalette.fieldFill(for: colorScheme))
@@ -143,7 +128,8 @@ struct CommentEditorView: View {
Spacer() Spacer()
if !model.context.isNewAnnotation, if model.context.allowsReply,
!model.context.isNewAnnotation,
model.context.primaryAnnotation != nil { model.context.primaryAnnotation != nil {
Button { Button {
model.reply() model.reply()
@@ -157,14 +143,26 @@ struct CommentEditorView: View {
} }
if model.context.allowsDelete { if model.context.allowsDelete {
Button(role: .destructive) { if model.context.isNewAnnotation {
model.delete() Button {
} label: { model.delete()
Label("Delete Annotation", systemImage: "trash") } label: {
Label("Cancel", systemImage: "xmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.cancelAction)
.frame(width: 34)
.help("Cancel")
} else {
Button(role: .destructive) {
model.delete()
} label: {
Label("Delete Annotation", systemImage: "trash")
}
.labelStyle(.iconOnly)
.frame(width: 34)
.help("Delete Annotation")
} }
.labelStyle(.iconOnly)
.frame(width: 34)
.help("Delete Annotation")
} }
} }
} }
@@ -173,6 +171,10 @@ struct CommentEditorView: View {
model.context.title.replacingOccurrences(of: " Comment", with: "") model.context.title.replacingOccurrences(of: " Comment", with: "")
} }
private var placeholderText: String {
model.context.allowsReply ? "Add comment" : "Edit text"
}
private var symbolName: String { private var symbolName: String {
guard let annotation = model.context.primaryAnnotation else { guard let annotation = model.context.primaryAnnotation else {
return "text.bubble" return "text.bubble"

View File

@@ -0,0 +1,106 @@
import AppKit
import IHatePDFsCore
import SwiftUI
struct CommitTextView: NSViewRepresentable {
@Binding var text: String
var font: NSFont
var focusOnAppear = true
var onCommit: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let textView = CommitTextNSTextView()
textView.delegate = context.coordinator
textView.string = text
textView.font = font
textView.textColor = .labelColor
textView.drawsBackground = false
textView.isRichText = false
textView.importsGraphics = false
textView.allowsUndo = true
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.onCommit = onCommit
scrollView.documentView = textView
context.coordinator.textView = textView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? CommitTextNSTextView else { return }
textView.onCommit = onCommit
textView.font = font
if textView.string != text {
textView.string = text
}
guard focusOnAppear,
!context.coordinator.didFocus,
textView.window != nil
else {
return
}
context.coordinator.didFocus = true
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
weak var textView: NSTextView?
var didFocus = false
init(text: Binding<String>) {
_text = text
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text = textView.string
}
}
}
private final class CommitTextNSTextView: NSTextView {
var onCommit: (() -> Void)?
override func keyDown(with event: NSEvent) {
guard !hasMarkedText() else {
super.keyDown(with: event)
return
}
if 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: isEditable && !isFieldEditor
) {
onCommit?()
return
}
super.keyDown(with: event)
}
}

View File

@@ -0,0 +1,518 @@
import AppKit
import IHatePDFsCore
import SwiftUI
@main
struct IHatePDFsApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
AppWindowRoot()
}
.windowStyle(.titleBar)
.commands {
AppCommands()
}
Settings {
SettingsView()
}
}
}
@MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
AppStateRegistry.shared.confirmApplicationShouldTerminate()
? .terminateNow
: .terminateCancel
}
}
@MainActor
private final class AppStateRegistry {
static let shared = AppStateRegistry()
private var appStates: [WeakAppState] = []
private(set) var isTerminationApproved = false
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
}
func closeOtherEmptyWindows(keeping activeAppState: AppState) {
prune()
for appState in appStates.compactMap(\.value) where appState !== activeAppState {
guard appState.document == nil,
!appState.hasUnsavedWork,
let window = appState.hostingWindow
else {
continue
}
window.close()
}
}
func appStateForActiveWindow() -> AppState? {
prune()
let candidateWindows = [NSApp.keyWindow, NSApp.mainWindow].compactMap { $0 }
for window in candidateWindows {
if let appState = appStates.compactMap(\.value).first(where: { $0.hostingWindow === window }) {
return appState
}
}
return nil
}
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)
if appState.documentURL == url {
AppStateRegistry.shared.closeOtherEmptyWindows(keeping: appState)
}
}
.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
}
self.window = window
previousDelegate = window?.delegate
appState?.hostingWindow = window
if window?.delegate !== self {
window?.delegate = self
}
updateDocumentState()
}
func updateDocumentState() {
guard let window else { return }
let representedURL = appState?.documentURL
if window.representedURL != representedURL {
window.representedURL = representedURL
}
let isDocumentEdited = appState?.hasUnsavedWork == true
if window.isDocumentEdited != isDocumentEdited {
window.isDocumentEdited = isDocumentEdited
}
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
if previousDelegate?.windowShouldClose?(sender) == false {
AppStateRegistry.shared.cancelTerminationApproval()
return false
}
if AppStateRegistry.shared.isTerminationApproved {
return true
}
return appState?.confirmDocumentWindowClose() ?? true
}
func windowWillClose(_ notification: Notification) {
previousDelegate?.windowWillClose?(notification)
if window?.delegate === self {
window?.delegate = previousDelegate
}
appState?.hostingWindow = nil
window = nil
previousDelegate = nil
}
deinit {
MainActor.assumeIsolated {
if window?.delegate === self {
window?.delegate = previousDelegate
}
}
}
}
}
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 focusedAppState: AppState?
private var appState: AppState? {
focusedAppState ?? AppStateRegistry.shared.appStateForActiveWindow()
}
private var hasDocument: Bool {
appState?.document != nil
}
private var hasTextSelection: Bool {
appState?.hasTextSelection == true
}
private var isHighlighterModeActive: Bool {
appState?.isHighlighterModeActive == true
}
private var canSaveDocument: Bool {
appState?.canSaveDocument == true
}
private var canDeleteSelectedAnnotation: Bool {
appState?.canDeleteSelectedAnnotation == true
}
private var recentDocumentURLs: [URL] {
appState?.recentDocumentURLs ?? []
}
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)
Menu("Open Recent") {
if recentDocumentURLs.isEmpty {
Button("No Recent PDFs") {}
.disabled(true)
} else {
ForEach(recentDocumentURLs, id: \.self) { url in
Button(url.lastPathComponent) {
appState?.openRecentDocument(url)
}
.help(url.path)
}
Divider()
Button("Clear Menu") {
appState?.clearRecentDocuments()
}
}
}
.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()
}
.keyboardShortcut(",")
Divider()
Button(hasDocument ? "Close PDF" : "Close Window") {
if hasDocument {
appState?.closeDocument()
} else {
NSApp.keyWindow?.performClose(nil)
}
}
.keyboardShortcut("w")
.disabled(appState == nil)
}
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)
}
CommandGroup(after: .toolbar) {
Button("Toggle Page Sidebar") {
appState?.togglePageSidebar()
}
.keyboardShortcut("0", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Annotation List") {
appState?.toggleAnnotationSidebar()
}
.keyboardShortcut("2", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Right Sidebar") {
appState?.toggleRightSidebarVisibility()
}
.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("Undo Annotation Change") {
appState?.undoAnnotationChange()
}
.disabled(appState?.canUndoAnnotationChange != true)
Button("Redo Annotation Change") {
appState?.redoAnnotationChange()
}
.disabled(appState?.canRedoAnnotationChange != true)
Divider()
Button("Cancel Annotation Mode") {
appState?.cancelActiveMode()
}
.keyboardShortcut(.cancelAction)
.disabled(appState?.canCancelActiveMode != true)
Divider()
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
appState?.toggleHighlighterMode()
}
.keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(!hasDocument)
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)
Divider()
Button("Delete Selected Annotation") {
appState?.deleteSelectedAnnotation()
}
.disabled(!canDeleteSelectedAnnotation)
}
CommandMenu("Bookmark") {
Button(appState?.bookmarkActionTitle ?? "Add Bookmark") {
appState?.toggleBookmarkForCurrentPage()
}
.keyboardShortcut("b", modifiers: [.command])
.disabled(!hasDocument)
Button("Go to Bookmark") {
appState?.goToSavedBookmark()
}
.keyboardShortcut("b", modifiers: [.command, .option])
.disabled(appState?.savedBookmark == nil)
}
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)
}
}
}

861
sources/app/MainView.swift Normal file
View File

@@ -0,0 +1,861 @@
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct MainView: View {
@EnvironmentObject private var appState: AppState
@State private var leftSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).leftSidebarIdealWidth
@State private var rightSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).rightSidebarIdealWidth
@State private var leftSidebarDragStartWidth: CGFloat?
@State private var rightSidebarDragStartWidth: CGFloat?
var body: some View {
GeometryReader { proxy in
content(availableWidth: proxy.size.width)
.onAppear {
appState.updateWindowWidth(proxy.size.width)
}
.onChange(of: proxy.size.width) { width in
appState.updateWindowWidth(width)
}
}
.navigationTitle(appState.displayTitle)
.frame(
minWidth: ReaderAdaptiveLayout.minimumWindowWidth,
minHeight: ReaderAdaptiveLayout.minimumWindowHeight
)
.toolbar {
ReaderToolbar()
}
}
private func content(availableWidth: CGFloat) -> some View {
let layout = ReaderAdaptiveLayout(width: availableWidth)
let showsRightSidebar = appState.showCommentsSidebar
let showsLeftSidebar = appState.showLeftSidebar && (layout.allowsDualSidebars || !showsRightSidebar)
let sidebarWidths = layout.resolvedSidebarWidths(
availableWidth: availableWidth,
requestedLeft: leftSidebarWidth,
requestedRight: rightSidebarWidth,
showLeft: showsLeftSidebar,
showRight: showsRightSidebar
)
return VStack(spacing: 0) {
if appState.document == nil {
EmptyDocumentView()
} else {
VStack(spacing: 0) {
HStack(spacing: 0) {
if showsLeftSidebar {
LeftSidebarView()
.frame(width: sidebarWidths.left)
.transition(
.asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .opacity
)
)
SidebarResizeHandle()
.gesture(leftSidebarResizeGesture(for: layout))
}
PDFReaderView()
.frame(minWidth: layout.documentMinWidth, maxWidth: .infinity)
if showsRightSidebar {
SidebarResizeHandle()
.gesture(rightSidebarResizeGesture(for: layout))
RightSidebarView()
.frame(width: sidebarWidths.right)
.transition(
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .opacity
)
)
}
}
.animation(.easeInOut(duration: 0.18), value: appState.showLeftSidebar)
.animation(.easeInOut(duration: 0.18), value: appState.showCommentsSidebar)
.animation(.easeInOut(duration: 0.18), value: appState.readerSizeClass)
}
}
StatusBarView()
}
}
private func leftSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if leftSidebarDragStartWidth == nil {
leftSidebarDragStartWidth = leftSidebarWidth
}
let proposedWidth = (leftSidebarDragStartWidth ?? leftSidebarWidth) + value.translation.width
leftSidebarWidth = layout.clampedLeftWidth(proposedWidth)
}
.onEnded { _ in
leftSidebarDragStartWidth = nil
}
}
private func rightSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if rightSidebarDragStartWidth == nil {
rightSidebarDragStartWidth = rightSidebarWidth
}
let proposedWidth = (rightSidebarDragStartWidth ?? rightSidebarWidth) - value.translation.width
rightSidebarWidth = layout.clampedRightWidth(proposedWidth)
}
.onEnded { _ in
rightSidebarDragStartWidth = nil
}
}
}
private struct SidebarResizeHandle: View {
@Environment(\.colorScheme) private var colorScheme
@State private var isHovering = false
var body: some View {
Rectangle()
.fill(Color.clear)
.frame(width: ReaderAdaptiveLayout.resizeHandleWidth)
.frame(maxHeight: .infinity)
.overlay(alignment: .center) {
Capsule()
.fill(InterfacePalette.hairline(for: colorScheme).opacity(isHovering ? 0.95 : 0.58))
.frame(width: isHovering ? 2 : 1, height: isHovering ? 42 : 30)
.animation(.easeInOut(duration: 0.12), value: isHovering)
}
.background {
Rectangle()
.fill(Color.accentColor.opacity(isHovering ? 0.05 : 0))
}
.contentShape(Rectangle())
.onHover { hovering in
isHovering = hovering
if hovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
.onDisappear {
if isHovering {
NSCursor.pop()
}
}
.help("Resize Sidebar")
}
}
private struct PDFReaderView: View {
@EnvironmentObject private var appState: AppState
@State private var isDropTargeted = false
var body: some View {
ZStack(alignment: .bottom) {
PDFKitRepresentedView()
.background(Color(nsColor: .windowBackgroundColor))
if appState.canShowSelectionActions {
SelectionActionBar()
.padding(.bottom, 14)
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
if isDropTargeted {
DropTargetOverlay()
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
}
.onDrop(
of: [UTType.fileURL.identifier],
isTargeted: $isDropTargeted
) { providers in
appState.openDroppedDocument(from: providers)
}
.animation(.easeInOut(duration: 0.14), value: appState.canShowSelectionActions)
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
}
}
private struct SelectionActionBar: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
private var activeHighlightDisplayColor: Color {
Color(nsColor: AppSettings.displayColor(forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)))
}
var body: some View {
HStack(spacing: 2) {
actionButton(
"Highlight (H)",
systemImage: "highlighter",
foregroundStyle: activeHighlightDisplayColor
) {
appState.addHighlight()
}
actionButton(
"Underline (U)",
systemImage: "underline",
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
) {
appState.addUnderline()
}
actionButton(
"Comment (C)",
systemImage: "text.bubble",
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
) {
appState.addComment()
}
}
.controlSize(.small)
.padding(5)
.background(.regularMaterial, in: Capsule())
.overlay {
Capsule()
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.28 : 0.12), radius: 12, y: 5)
}
private func actionButton(
_ title: String,
systemImage: String,
foregroundStyle: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(foregroundStyle)
.frame(width: 30, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(title)
.accessibilityLabel(title)
}
}
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@State private var isDropTargeted = false
var body: some View {
ZStack {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 46, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.frame(width: 62, height: 62)
Text("Open a PDF")
.font(.title2.weight(.semibold))
Button {
appState.openDocument()
} label: {
Label("Open PDF", systemImage: "doc")
}
.keyboardShortcut("o")
.keyboardShortcut(.defaultAction)
.controlSize(.large)
}
RecentPDFsView()
.frame(maxWidth: 420)
}
.padding(.horizontal, 28)
.padding(.vertical, 36)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if isDropTargeted {
DropTargetOverlay()
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
}
.background(Color(nsColor: .windowBackgroundColor))
.onDrop(
of: [UTType.fileURL.identifier],
isTargeted: $isDropTargeted
) { providers in
appState.openDroppedDocument(from: providers)
}
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
.onAppear {
appState.refreshRecentDocuments()
}
}
}
private struct RecentPDFsView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
private var recentPDFs: [RecentDocumentItem] {
Array(appState.recentDocuments.prefix(5))
}
var body: some View {
if !recentPDFs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recent")
.font(.callout.weight(.semibold))
Spacer()
Button {
appState.clearRecentDocuments()
} label: {
Label("Clear Recent PDFs", systemImage: "xmark.circle")
}
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Clear Recent PDFs")
}
ForEach(recentPDFs, id: \.id) { url in
Button {
appState.openRecentDocument(url.url)
} label: {
RecentPDFRow(item: url)
}
.buttonStyle(.plain)
.help(url.url.path)
}
}
.padding(.top, 2)
}
}
}
private struct RecentPDFRow: View {
@Environment(\.colorScheme) private var colorScheme
let item: RecentDocumentItem
private var detailText: String {
let pieces = [item.pageText, item.openedAt.map { Self.relativeDateFormatter.localizedString(for: $0, relativeTo: Date()) }]
.compactMap { $0 }
if pieces.isEmpty {
return item.folderName
}
return pieces.joined(separator: " - ")
}
var body: some View {
HStack(spacing: 10) {
Image(systemName: "doc.text")
.font(.system(size: 16, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.callout)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
Text(detailText)
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
}
.padding(.horizontal, 10)
.frame(height: 46)
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(InterfacePalette.subtleFill(for: colorScheme))
}
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
private static let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
formatter.dateTimeStyle = .named
return formatter
}()
}
private struct DropTargetOverlay: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray.and.arrow.down.fill")
.font(.system(size: 36, weight: .regular))
.symbolRenderingMode(.hierarchical)
Text("Drop to Open")
.font(.title3.weight(.semibold))
}
.foregroundStyle(Color.accentColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor).opacity(colorScheme == .dark ? 0.72 : 0.82))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(
Color.accentColor.opacity(colorScheme == .dark ? 0.68 : 0.58),
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
)
.padding(18)
}
}
}
private struct StatusBarView: View {
@EnvironmentObject private var appState: AppState
private var annotationStatusText: String {
let count = appState.annotations.count
return count == 1 ? "1 annotation" : "\(count) annotations"
}
var body: some View {
if appState.isCompactWindow {
compactStatus
} else {
regularStatus
}
}
private var regularStatus: some View {
HStack(spacing: 12) {
Text(appState.statusMessage)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if appState.document != nil {
if appState.hasUnsentSidebarReplyDraft {
Text("Reply draft")
}
Text(annotationStatusText)
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 12)
.frame(height: 26)
.background(.bar)
}
private var compactStatus: some View {
HStack(spacing: 8) {
Text(appState.statusMessage)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if appState.document != nil {
if appState.hasUnsentSidebarReplyDraft {
Image(systemName: "text.bubble")
.help("Reply draft")
.accessibilityLabel("Reply draft")
}
Text("\(appState.currentPageIndex + 1)/\(max(appState.pageCount, 1))")
.font(.caption.monospacedDigit())
.lineLimit(1)
.fixedSize()
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.frame(height: 24)
.background(.bar)
}
}
private struct ReaderToolbar: ToolbarContent {
@EnvironmentObject private var appState: AppState
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
@State private var showsHighlightPalette = false
@FocusState private var searchFocused: Bool
private var activeHighlightColor: NSColor {
AppSettings.highlightColor(from: storedHighlightColor)
}
private var activeHighlightDisplayColor: Color {
Color(nsColor: AppSettings.displayColor(forHighlightColor: activeHighlightColor))
}
private var pageNumberWidth: CGFloat {
CGFloat(max(2, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 8 : 8.4)
+ (appState.isCompactWindow ? 16 : 20)
}
private var totalPageNumberWidth: CGFloat {
CGFloat(max(1, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 7.8 : 8.2)
+ (appState.isCompactWindow ? 18 : 21)
}
private var pageSeparatorSpacing: CGFloat {
appState.isCompactWindow ? 28 : 46
}
private var pageControlWidth: CGFloat {
pageNumberWidth + totalPageNumberWidth + (appState.isCompactWindow ? 46 : 56)
}
private var compactToolbarControlsEnabled: Bool {
appState.isCompactWindow
}
private var annotationToolsMenu: some View {
Menu {
Button {
appState.toggleHighlighterMode()
} label: {
Label(
appState.isHighlighterModeActive
? "Turn Highlighter Off (H)"
: "Turn Highlighter On (H)",
systemImage: "highlighter"
)
}
.disabled(appState.document == nil)
Button {
appState.addUnderline()
} label: {
Label("Underline Selection (U)", systemImage: "underline")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
Button {
appState.addComment()
} label: {
Label("Comment (C)", systemImage: "text.bubble")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
} label: {
Image(systemName: "pencil.tip.crop.circle")
}
.help("Annotation Tools")
.disabled(appState.document == nil)
}
private var highlightColorButton: some View {
Button {
showsHighlightPalette.toggle()
} label: {
Image(systemName: "circle.fill")
.foregroundStyle(activeHighlightDisplayColor)
}
.popover(isPresented: $showsHighlightPalette, arrowEdge: .bottom) {
HighlightPalettePopover(
storedHighlightColor: $storedHighlightColor,
onSelect: { color in
showsHighlightPalette = false
appState.selectHighlightColor(color, applyToSelection: appState.hasTextSelection)
}
)
}
.help("Highlight Color")
.accessibilityLabel("Highlight Color Palette")
.disabled(appState.document == nil)
}
var body: some ToolbarContent {
if appState.document == nil {
ToolbarItemGroup(placement: .primaryAction) {
Button {
appState.openDocument()
} label: {
Label("Open", systemImage: "doc")
}
.help("Open PDF")
}
} else {
ToolbarItemGroup(placement: .navigation) {
Button {
appState.togglePageSidebar()
} label: {
Image(systemName: "square.grid.2x2")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Toggle Page Thumbnails")
Button {
appState.toggleBookmarkForCurrentPage()
} label: {
Image(systemName: appState.currentPageBookmark == nil ? "bookmark" : "bookmark.fill")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help(appState.bookmarkActionHelpText)
}
ToolbarItem(placement: .principal) {
HStack(spacing: 5) {
Button {
appState.goToPreviousPage()
} label: {
Image(systemName: "chevron.up")
}
.disabled(!appState.canGoToPreviousPage)
.help("Previous Page")
TextField("Page", text: $appState.pageText)
.textFieldStyle(.plain)
.multilineTextAlignment(.center)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.frame(width: pageNumberWidth, height: compactToolbarControlsEnabled ? 21 : 22)
.onSubmit {
appState.goToPageFromField()
}
.disabled(appState.document == nil)
HStack(spacing: pageSeparatorSpacing) {
Text("/")
Text("\(max(appState.pageCount, 1))")
}
.font(.system(size: 13, weight: .regular, design: .rounded).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: totalPageNumberWidth + pageSeparatorSpacing, alignment: .leading)
Button {
appState.goToNextPage()
} label: {
Image(systemName: "chevron.down")
}
.disabled(!appState.canGoToNextPage)
.help("Next Page")
}
.controlSize(.small)
.frame(width: pageControlWidth)
}
ToolbarItemGroup(placement: .primaryAction) {
if appState.showToolbarSearch {
HStack(spacing: 7) {
ZStack(alignment: .trailing) {
TextField("Search", text: $appState.searchText)
.textFieldStyle(.plain)
.font(.system(size: 13))
.padding(.leading, 10)
.padding(.trailing, appState.canClearSearchQuery ? 28 : 10)
.frame(height: compactToolbarControlsEnabled ? 26 : 28)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
}
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(
searchFocused ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: searchFocused ? 2 : 1
)
}
.focused($searchFocused)
.onChange(of: appState.toolbarSearchFocusRequest) { _ in
searchFocused = true
}
.onSubmit {
appState.runSearch()
}
.disabled(appState.document == nil)
if appState.canClearSearchQuery {
Button {
appState.clearSearchQuery()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12, weight: .semibold))
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.padding(.trailing, 9)
.disabled(appState.document == nil)
.help("Clear Search")
.accessibilityLabel("Clear Search")
}
}
.frame(width: compactToolbarControlsEnabled ? 138 : 154, height: compactToolbarControlsEnabled ? 26 : 28)
if let searchSummaryText = appState.searchSummaryText {
Text(searchSummaryText)
.font(.system(size: 12, weight: .medium, design: .rounded).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: searchSummaryText == "No match" ? 58 : 34, alignment: .leading)
.layoutPriority(1)
.accessibilityLabel(searchSummaryText)
}
}
Button {
appState.previousSearchResult()
} label: {
Image(systemName: "chevron.left")
}
.labelStyle(.iconOnly)
.disabled(appState.searchResults.isEmpty)
.help("Previous Search Match")
Button {
appState.nextSearchResult()
} label: {
Image(systemName: "chevron.right")
}
.labelStyle(.iconOnly)
.disabled(appState.searchResults.isEmpty)
.help("Next Search Match")
Button {
appState.hideSearch()
} label: {
Image(systemName: "xmark")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Close Search")
} else {
Button {
appState.showSearch()
} label: {
Image(systemName: "magnifyingglass")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Search")
}
}
ToolbarItem(placement: .primaryAction) {
annotationToolsMenu
}
ToolbarItem(placement: .primaryAction) {
highlightColorButton
}
ToolbarItemGroup(placement: .primaryAction) {
Button {
appState.fitWidth()
} label: {
Image(systemName: "arrow.left.and.right")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Fit to Width")
Button {
appState.toggleRightSidebarVisibility()
} label: {
Image(systemName: "sidebar.right")
.foregroundStyle(appState.showCommentsSidebar ? Color.accentColor : Color.primary)
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help(appState.showCommentsSidebar ? "Hide Right Sidebar" : "Show Right Sidebar")
.accessibilityLabel("Toggle Right Sidebar")
Button {
appState.shareDocument()
} label: {
Image(systemName: "square.and.arrow.up")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Share PDF")
}
}
}
}
private struct HighlightPalettePopover: View {
@Binding var storedHighlightColor: String
let onSelect: (NSColor) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "highlighter")
.foregroundStyle(Color(nsColor: AppSettings.displayColor(
forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)
)))
Text("Highlight")
.font(.headline)
Spacer(minLength: 0)
}
HStack(spacing: 8) {
ForEach(Array(AppSettings.highlightSwatches.enumerated()), id: \.offset) { _, swatch in
Button {
storedHighlightColor = AppSettings.storageString(forHighlightColor: swatch.color)
onSelect(swatch.color)
} label: {
ZStack {
Circle()
.fill(Color(nsColor: AppSettings.displayColor(forHighlightColor: swatch.color)))
.frame(width: 24, height: 24)
.overlay {
Circle()
.stroke(
isSelected(swatch.color) ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: isSelected(swatch.color) ? 2 : 0.8
)
}
if isSelected(swatch.color) {
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Color(nsColor: .labelColor))
}
}
}
.buttonStyle(.plain)
.help(swatch.name)
.accessibilityLabel("Highlight \(swatch.name)")
}
}
}
.padding(14)
.frame(width: 284)
}
private func isSelected(_ color: NSColor) -> Bool {
AppSettings.storageString(forHighlightColor: color) == storedHighlightColor
}
}

View File

@@ -6,19 +6,40 @@ import SwiftUI
final class AcademicPDFView: PDFView { final class AcademicPDFView: PDFView {
var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)? var onAnnotationClick: ((PDFAnnotation, PDFPage) -> Void)?
var onPlacementClick: ((PDFPage, CGPoint) -> Void)? var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onCancelActiveMode: (() -> Void)?
var onSelectionComment: (() -> Void)? var onSelectionComment: (() -> Void)?
var onHighlighterSelection: (() -> Void)?
var onToggleHighlighterKey: (() -> Void)?
var onUnderlineSelectionKey: (() -> Void)?
var onCommentSelectionKey: (() -> Void)?
var onPreviousPageKey: (() -> Void)? var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)? var onNextPageKey: (() -> Void)?
var onDeleteSelectedAnnotationKey: (() -> Void)?
var placementTool: AnnotationPlacementTool? { var placementTool: AnnotationPlacementTool? {
didSet { didSet {
guard oldValue != placementTool else { return } guard oldValue != placementTool else { return }
window?.invalidateCursorRects(for: self) window?.invalidateCursorRects(for: self)
} }
} }
var isHighlighterModeActive = false {
didSet {
guard oldValue != isHighlighterModeActive else { return }
window?.invalidateCursorRects(for: self)
if mouseIsInside {
applyToolCursorIfNeeded()
}
}
}
private var handledAnnotationMouseDown = false private var handledAnnotationMouseDown = false
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
backgroundColor = NSColor.underPageBackgroundColor
needsDisplay = true
}
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
handledAnnotationMouseDown = false handledAnnotationMouseDown = false
let point = convert(event.locationInWindow, from: nil) let point = convert(event.locationInWindow, from: nil)
@@ -26,7 +47,6 @@ final class AcademicPDFView: PDFView {
if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) { if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) {
closeNativePopups(on: page) closeNativePopups(on: page)
let pagePoint = convert(point, to: page) let pagePoint = convert(point, to: page)
if placementTool != nil { if placementTool != nil {
onPlacementClick?(page, pagePoint) onPlacementClick?(page, pagePoint)
return return
@@ -64,6 +84,14 @@ final class AcademicPDFView: PDFView {
super.mouseUp(with: event) super.mouseUp(with: event)
if isHighlighterModeActive, hasCommentableSelection {
DispatchQueue.main.async { [weak self] in
guard self?.hasCommentableSelection == true else { return }
self?.onHighlighterSelection?()
}
return
}
guard let page else { return } guard let page else { return }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
@@ -91,6 +119,35 @@ final class AcademicPDFView: PDFView {
} }
override func keyDown(with event: NSEvent) { override func keyDown(with event: NSEvent) {
if event.keyCode == 53, placementTool != nil || isHighlighterModeActive {
onCancelActiveMode?()
return
}
if [51, 117].contains(event.keyCode),
event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty {
onDeleteSelectedAnnotationKey?()
return
}
if !event.isARepeat,
event.modifierFlags.intersection([.command, .control, .option]).isEmpty,
let key = event.charactersIgnoringModifiers?.lowercased() {
switch key {
case "h":
onToggleHighlighterKey?()
return
case "u":
onUnderlineSelectionKey?()
return
case "c":
onCommentSelectionKey?()
return
default:
break
}
}
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift] let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else { guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event) super.keyDown(with: event)
@@ -110,15 +167,182 @@ final class AcademicPDFView: PDFView {
override func resetCursorRects() { override func resetCursorRects() {
super.resetCursorRects() super.resetCursorRects()
if placementTool != nil { if isHighlighterModeActive {
addCursorRect(bounds, cursor: Self.highlighterCursor)
} else if placementTool != nil {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
} }
override func cursorUpdate(with event: NSEvent) {
if applyToolCursorIfNeeded() {
return
}
super.cursorUpdate(with: event)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
applyToolCursorIfNeeded()
}
override func mouseDragged(with event: NSEvent) {
super.mouseDragged(with: event)
applyToolCursorIfNeeded()
}
override func menu(for event: NSEvent) -> NSMenu? { override func menu(for event: NSEvent) -> NSMenu? {
commentMenu(from: super.menu(for: event)) commentMenu(from: super.menu(for: event))
} }
@discardableResult
private func applyToolCursorIfNeeded() -> Bool {
if isHighlighterModeActive {
Self.highlighterCursor.set()
return true
}
if placementTool != nil {
NSCursor.crosshair.set()
return true
}
return false
}
private var mouseIsInside: Bool {
guard let window else { return false }
return bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
private static let highlighterCursor: NSCursor = {
let size = NSSize(width: 36, height: 36)
let image = highResolutionCursorImage(size: size) {
NSGraphicsContext.current?.imageInterpolation = .high
NSGraphicsContext.current?.shouldAntialias = true
let outline = NSColor.black.withAlphaComponent(0.58)
let bodyTint = NSColor(red: 1.0, green: 0.86, blue: 0.28, alpha: 0.98)
let bodyShade = NSColor(red: 1.0, green: 0.58, blue: 0.12, alpha: 0.98)
let nibColor = NSColor(red: 0.16, green: 0.07, blue: 0.02, alpha: 0.96)
let highlightTrail = NSBezierPath(roundedRect: NSRect(x: 4.5, y: 3.6, width: 17.5, height: 5.2), xRadius: 2.6, yRadius: 2.6)
NSColor(red: 1.0, green: 0.93, blue: 0.28, alpha: 0.34).setFill()
highlightTrail.fill()
let shadow = NSShadow()
shadow.shadowOffset = NSSize(width: 0.7, height: -1.1)
shadow.shadowBlurRadius = 2.4
shadow.shadowColor = NSColor.black.withAlphaComponent(0.24)
let body = NSBezierPath()
body.move(to: NSPoint(x: 10.4, y: 9.2))
body.line(to: NSPoint(x: 21.9, y: 23.5))
body.curve(
to: NSPoint(x: 25.3, y: 23.8),
controlPoint1: NSPoint(x: 22.8, y: 24.4),
controlPoint2: NSPoint(x: 24.2, y: 24.5)
)
body.line(to: NSPoint(x: 28.8, y: 20.8))
body.curve(
to: NSPoint(x: 28.1, y: 17.5),
controlPoint1: NSPoint(x: 29.7, y: 20.0),
controlPoint2: NSPoint(x: 29.3, y: 18.4)
)
body.line(to: NSPoint(x: 15.2, y: 4.1))
body.curve(
to: NSPoint(x: 11.7, y: 4.3),
controlPoint1: NSPoint(x: 13.9, y: 3.1),
controlPoint2: NSPoint(x: 12.4, y: 3.0)
)
body.line(to: NSPoint(x: 8.7, y: 6.8))
body.curve(
to: NSPoint(x: 10.4, y: 9.2),
controlPoint1: NSPoint(x: 8.9, y: 7.7),
controlPoint2: NSPoint(x: 9.5, y: 8.6)
)
body.close()
body.lineJoinStyle = .round
NSGraphicsContext.saveGraphicsState()
shadow.set()
NSGradient(colors: [bodyTint, bodyShade])?.draw(in: body, angle: 38)
NSGraphicsContext.restoreGraphicsState()
outline.setStroke()
body.lineWidth = 1.05
body.stroke()
let grip = NSBezierPath()
grip.move(to: NSPoint(x: 17.2, y: 10.4))
grip.line(to: NSPoint(x: 25.0, y: 18.6))
grip.lineWidth = 1.0
NSColor(red: 0.58, green: 0.27, blue: 0.02, alpha: 0.22).setStroke()
grip.stroke()
let shine = NSBezierPath()
shine.move(to: NSPoint(x: 15.2, y: 9.5))
shine.curve(
to: NSPoint(x: 25.0, y: 20.7),
controlPoint1: NSPoint(x: 18.8, y: 12.6),
controlPoint2: NSPoint(x: 22.1, y: 17.8)
)
shine.lineWidth = 1.05
NSColor.white.withAlphaComponent(0.46).setStroke()
shine.stroke()
let nib = NSBezierPath()
nib.move(to: NSPoint(x: 5.3, y: 6.0))
nib.line(to: NSPoint(x: 10.6, y: 9.2))
nib.curve(
to: NSPoint(x: 14.4, y: 4.6),
controlPoint1: NSPoint(x: 11.8, y: 9.4),
controlPoint2: NSPoint(x: 13.6, y: 4.9)
)
nib.line(to: NSPoint(x: 8.6, y: 3.1))
nib.close()
nibColor.setFill()
nib.fill()
outline.setStroke()
nib.lineWidth = 0.9
nib.stroke()
}
return NSCursor(image: image, hotSpot: NSPoint(x: 8, y: 29))
}()
private static func highResolutionCursorImage(
size: NSSize,
scale: CGFloat = 2,
draw: () -> Void
) -> NSImage {
let image = NSImage(size: size)
guard let representation = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(size.width * scale),
pixelsHigh: Int(size.height * scale),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
),
let context = NSGraphicsContext(bitmapImageRep: representation)
else {
return image
}
representation.size = size
let previousContext = NSGraphicsContext.current
NSGraphicsContext.current = context
NSGraphicsContext.saveGraphicsState()
context.cgContext.scaleBy(x: scale, y: scale)
draw()
NSGraphicsContext.restoreGraphicsState()
NSGraphicsContext.current = previousContext
image.addRepresentation(representation)
return image
}
private var hasCommentableSelection: Bool { private var hasCommentableSelection: Bool {
guard let selection = currentSelection, guard let selection = currentSelection,
!selection.pages.isEmpty, !selection.pages.isEmpty,
@@ -156,24 +380,15 @@ final class AcademicPDFView: PDFView {
private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? { private func editableAnnotation(on page: PDFPage, at point: CGPoint) -> PDFAnnotation? {
if let direct = page.annotation(at: point), 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 return editable
} }
for annotation in page.annotations.reversed() { for annotation in page.annotations.reversed() {
guard let editable = editableParent(for: annotation, on: page) else { continue } guard let editable = editableParent(for: annotation, on: page) else { continue }
if annotation.bounds.insetBy(dx: -8, dy: -8).contains(point) { if isInteractionPoint(point, on: annotation, editable: editable) {
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) {
return editable return editable
} }
} }
@@ -181,6 +396,31 @@ final class AcademicPDFView: PDFView {
return nil 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? { private func editableParent(for annotation: PDFAnnotation, on page: PDFPage) -> PDFAnnotation? {
if let owner = popupOwner(for: annotation, on: page) { if let owner = popupOwner(for: annotation, on: page) {
return isEditableAcademicAnnotation(owner) ? owner : nil return isEditableAcademicAnnotation(owner) ? owner : nil
@@ -228,20 +468,6 @@ final class AcademicPDFView: PDFView {
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline) || 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) { private func closeNativePopups(on page: PDFPage) {
for annotation in page.annotations { for annotation in page.annotations {
if AnnotationKeys.annotation(annotation, hasSubtype: .popup) { if AnnotationKeys.annotation(annotation, hasSubtype: .popup) {
@@ -253,8 +479,16 @@ final class AcademicPDFView: PDFView {
} }
private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool { private func isEditableAcademicAnnotation(_ annotation: PDFAnnotation) -> Bool {
AnnotationKeys.annotation(annotation, hasSubtype: .highlight) if AnnotationKeys.annotation(annotation, hasSubtype: .highlight) {
|| AnnotationKeys.annotation(annotation, hasSubtype: .underline) 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: .text)
|| AnnotationKeys.annotation(annotation, hasSubtype: .freeText) || AnnotationKeys.annotation(annotation, hasSubtype: .freeText)
} }
@@ -279,11 +513,36 @@ struct PDFKitRepresentedView: NSViewRepresentable {
appState.placePendingAnnotation(on: page, near: point) appState.placePendingAnnotation(on: page, near: point)
} }
} }
view.onCancelActiveMode = {
Task { @MainActor in
appState.cancelActiveMode()
}
}
view.onSelectionComment = { view.onSelectionComment = {
Task { @MainActor in Task { @MainActor in
appState.addComment() appState.addComment()
} }
} }
view.onHighlighterSelection = {
Task { @MainActor in
appState.addHighlightFromHighlighterMode()
}
}
view.onToggleHighlighterKey = {
Task { @MainActor in
appState.toggleHighlighterMode()
}
}
view.onUnderlineSelectionKey = {
Task { @MainActor in
appState.addUnderline()
}
}
view.onCommentSelectionKey = {
Task { @MainActor in
appState.addComment()
}
}
view.onPreviousPageKey = { view.onPreviousPageKey = {
Task { @MainActor in Task { @MainActor in
appState.goToPreviousPage() appState.goToPreviousPage()
@@ -294,6 +553,11 @@ struct PDFKitRepresentedView: NSViewRepresentable {
appState.goToNextPage() appState.goToNextPage()
} }
} }
view.onDeleteSelectedAnnotationKey = {
Task { @MainActor in
appState.deleteSelectedAnnotation()
}
}
appState.attachPDFView(view) appState.attachPDFView(view)
return view return view
} }
@@ -303,16 +567,27 @@ struct PDFKitRepresentedView: NSViewRepresentable {
view.document = appState.document view.document = appState.document
} }
view.placementTool = appState.placementTool view.placementTool = appState.placementTool
view.isHighlighterModeActive = appState.isHighlighterModeActive
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
context.coordinator.sync(editor: appState.activeEditor, in: view, appState: appState) context.coordinator.sync(
editor: appState.activeEditor,
in: view,
appState: appState
)
} }
@MainActor @MainActor
final class Coordinator: NSObject, NSPopoverDelegate { final class Coordinator: NSObject, NSPopoverDelegate {
private enum PopoverKind {
case comment
}
private var popover: NSPopover? private var popover: NSPopover?
private var model: CommentPopoverModel? private var model: CommentPopoverModel?
private var editorID: UUID? private var editorID: UUID?
private var popoverKind: PopoverKind?
private var isClosing = false private var isClosing = false
private var commitsCommentOnClose = true
private weak var appState: AppState? private weak var appState: AppState?
func sync( func sync(
@@ -322,19 +597,21 @@ struct PDFKitRepresentedView: NSViewRepresentable {
) { ) {
self.appState = appState self.appState = appState
guard let context else { if let context {
if !isClosing { if popoverKind == .comment,
dismissCurrent(commit: false) editorID == context.id,
popover?.isShown == true {
return
} }
dismissCurrent(commit: true)
show(context, in: view, appState: appState)
return return
} }
if editorID == context.id, popover?.isShown == true { if !isClosing {
return dismissCurrent(commit: false)
} }
dismissCurrent(commit: true)
show(context, in: view, appState: appState)
} }
private func show( private func show(
@@ -356,7 +633,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
self.model = model self.model = model
self.popover = popover self.popover = popover
self.editorID = context.id self.editorID = context.id
self.popoverKind = .comment
self.isClosing = false self.isClosing = false
self.commitsCommentOnClose = true
let anchor = anchorRect(for: context, in: view) let anchor = anchorRect(for: context, in: view)
popover.show( popover.show(
@@ -364,6 +643,45 @@ struct PDFKitRepresentedView: NSViewRepresentable {
of: view, of: view,
preferredEdge: preferredEdge(for: anchor, in: 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) { private func dismissCurrent(commit: Bool) {
@@ -375,6 +693,7 @@ struct PDFKitRepresentedView: NSViewRepresentable {
if commit { if commit {
model?.commit() model?.commit()
} }
commitsCommentOnClose = commit
if popover.isShown { if popover.isShown {
popover.performClose(nil) popover.performClose(nil)
@@ -385,15 +704,19 @@ struct PDFKitRepresentedView: NSViewRepresentable {
func popoverWillClose(_ notification: Notification) { func popoverWillClose(_ notification: Notification) {
isClosing = true isClosing = true
model?.commit() if popoverKind == .comment, commitsCommentOnClose {
model?.commit()
}
} }
func popoverDidClose(_ notification: Notification) { func popoverDidClose(_ notification: Notification) {
let closedEditorID = editorID let closedEditorID = editorID
let closedPopoverKind = popoverKind
let currentAppState = appState let currentAppState = appState
cleanup() cleanup()
if currentAppState?.activeEditor?.id == closedEditorID { if closedPopoverKind == .comment,
currentAppState?.activeEditor?.id == closedEditorID {
currentAppState?.activeEditor = nil currentAppState?.activeEditor = nil
} }
} }
@@ -403,7 +726,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
popover = nil popover = nil
model = nil model = nil
editorID = nil editorID = nil
popoverKind = nil
isClosing = false isClosing = false
commitsCommentOnClose = true
} }
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect { private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {

View File

@@ -0,0 +1,180 @@
import CoreGraphics
struct ReaderAdaptiveLayout: Equatable {
enum SizeClass: String, CaseIterable {
case compact
case regular
case wide
init(width: CGFloat) {
if width < 960 {
self = .compact
} else if width < 1280 {
self = .regular
} else {
self = .wide
}
}
}
struct SidebarWidths: Equatable {
var left: CGFloat
var right: CGFloat
}
static let minimumWindowWidth: CGFloat = 820
static let minimumWindowHeight: CGFloat = 620
static let resizeHandleWidth: CGFloat = 16
let sizeClass: SizeClass
init(width: CGFloat) {
sizeClass = SizeClass(width: width)
}
init(sizeClass: SizeClass) {
self.sizeClass = sizeClass
}
var usesCompactToolbar: Bool {
sizeClass == .compact
}
var allowsDualSidebars: Bool {
sizeClass != .compact
}
var leftSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 208
case .regular:
return 196
case .wide:
return 220
}
}
var leftSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 236
case .regular:
return 215
case .wide:
return 248
}
}
var leftSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 300
case .regular:
return 280
case .wide:
return 340
}
}
var rightSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 280
case .regular:
return 280
case .wide:
return 300
}
}
var rightSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 292
case .regular:
return 300
case .wide:
return 340
}
}
var rightSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 340
case .regular:
return 360
case .wide:
return 420
}
}
var documentMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 320
case .regular:
return 420
case .wide:
return 560
}
}
func clampedLeftWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: leftSidebarMinWidth, upper: leftSidebarMaxWidth)
}
func clampedRightWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: rightSidebarMinWidth, upper: rightSidebarMaxWidth)
}
func resolvedSidebarWidths(
availableWidth: CGFloat,
requestedLeft: CGFloat,
requestedRight: CGFloat,
showLeft: Bool,
showRight: Bool
) -> SidebarWidths {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
let maxSidebarTotal = max(0, availableWidth - documentMinWidth - leftHandle - rightHandle)
var left = showLeft ? clampedLeftWidth(requestedLeft) : 0
var right = showRight ? clampedRightWidth(requestedRight) : 0
guard left + right > maxSidebarTotal else {
return SidebarWidths(left: left, right: right)
}
var overflow = left + right - maxSidebarTotal
if showRight {
let reduction = min(overflow, max(0, right - rightSidebarMinWidth))
right -= reduction
overflow -= reduction
}
if showLeft, overflow > 0 {
let reduction = min(overflow, max(0, left - leftSidebarMinWidth))
left -= reduction
}
return SidebarWidths(left: left, right: right)
}
func visibleContentWidth(
availableWidth: CGFloat,
leftWidth: CGFloat,
rightWidth: CGFloat,
showLeft: Bool,
showRight: Bool
) -> CGFloat {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
return availableWidth - leftWidth - rightWidth - leftHandle - rightHandle
}
private func clamped(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
}

File diff suppressed because it is too large Load Diff

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 enum AcademicAnnotationPalette {
public static let comment = NSColor( public static let comment = NSColor(
calibratedRed: 0.88, calibratedRed: 0.98,
green: 0.72, green: 0.64,
blue: 0.46, blue: 0.16,
alpha: 0.10 alpha: 0.30
) )
public static let highlight = NSColor( public static let highlight = NSColor(
calibratedRed: 0.88, calibratedRed: 1.0,
green: 0.72, green: 0.78,
blue: 0.46, blue: 0.0,
alpha: 0.24 alpha: 0.52
) )
public static let underline = NSColor( public static let underline = NSColor(
calibratedRed: 0.48, 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 { switch self {
case .comment: return AcademicAnnotationPalette.comment case .comment: return commentColor
case .highlight: return AcademicAnnotationPalette.highlight case .highlight: return highlightColor
case .underline: return AcademicAnnotationPalette.underline case .underline: return AcademicAnnotationPalette.underline
} }
} }
@@ -95,20 +98,26 @@ public enum AnnotationFactory {
style: MarkupAnnotationStyle, style: MarkupAnnotationStyle,
comment: String, comment: String,
author: String, author: String,
highlightColor: NSColor = AcademicAnnotationPalette.highlight,
commentColor: NSColor = AcademicAnnotationPalette.comment,
date: Date = Date() date: Date = Date()
) -> [AnnotationInsertion] { ) -> [AnnotationInsertion] {
let lineSelections = selection.selectionsByLine() let lineSelections = selection.selectionsByLine()
var groups: [(page: PDFPage, rects: [CGRect])] = [] var groups: [(page: PDFPage, rects: [CGRect], text: [String])] = []
for lineSelection in lineSelections { for lineSelection in lineSelections {
let lineText = lineSelection.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
for page in lineSelection.pages { for page in lineSelection.pages {
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0) let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue } guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
if let index = groups.firstIndex(where: { $0.page === page }) { if let index = groups.firstIndex(where: { $0.page === page }) {
groups[index].rects.append(rect) groups[index].rects.append(rect)
if !lineText.isEmpty {
groups[index].text.append(lineText)
}
} else { } else {
groups.append((page: page, rects: [rect])) groups.append((page: page, rects: [rect], text: lineText.isEmpty ? [] : [lineText]))
} }
} }
} }
@@ -120,11 +129,17 @@ public enum AnnotationFactory {
} }
let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil) let annotation = PDFAnnotation(bounds: unionRect, forType: style.subtype, withProperties: nil)
annotation.markupType = style.markupType annotation.markupType = style.markupType
annotation.color = style.color annotation.color = style.color(highlightColor: highlightColor, commentColor: commentColor)
annotation.quadrilateralPoints = group.rects.flatMap { rect in annotation.quadrilateralPoints = group.rects.flatMap { rect in
quadPoints(for: rect, relativeTo: unionRect) quadPoints(for: rect, relativeTo: unionRect)
} }
standardize(annotation, comment: comment, author: author, date: date) standardize(annotation, comment: comment, author: author, date: date)
if style == .highlight {
let highlightText = group.text.joined(separator: " ")
if !highlightText.isEmpty {
_ = annotation.setValue(highlightText, forAnnotationKey: AnnotationKeys.appHighlightText)
}
}
if style == .comment { if style == .comment {
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind) _ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
} }
@@ -266,7 +281,9 @@ public enum AnnotationFactory {
date: Date date: Date
) { ) {
AnnotationKeys.setCommentText(comment, for: annotation) AnnotationKeys.setCommentText(comment, for: annotation)
annotation.contents = comment annotation.contents = comment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: comment
annotation.userName = author annotation.userName = author
annotation.modificationDate = date annotation.modificationDate = date
annotation.shouldDisplay = true annotation.shouldDisplay = true
@@ -357,6 +374,51 @@ public enum AnnotationFactory {
@discardableResult @discardableResult
public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool { public static func restoreCommentTextForExport(_ annotation: PDFAnnotation) -> Bool {
let contents = AnnotationKeys.commentText(for: annotation) 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 let exportedContents = contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil ? nil
: contents : 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

@@ -2,7 +2,7 @@ import AppKit
import Foundation import Foundation
import PDFKit import PDFKit
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable { public enum AcademicAnnotationKind: String, CaseIterable {
case comment case comment
case highlight case highlight
case underline case underline
@@ -11,8 +11,6 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
case reply case reply
case other case other
public var id: String { rawValue }
public init(annotation: PDFAnnotation) { public init(annotation: PDFAnnotation) {
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment { if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
self = .comment self = .comment
@@ -62,7 +60,7 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
} }
} }
public struct AnnotationSnapshot: Identifiable, Equatable { public struct AnnotationSnapshot: Identifiable {
public let id: String public let id: String
public let pageIndex: Int public let pageIndex: Int
public let pageLabel: String public let pageLabel: String
@@ -73,6 +71,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
public let modifiedAt: Date? public let modifiedAt: Date?
public let status: String public let status: String
public let contents: String public let contents: String
public let highlightText: String
public let bounds: CGRect public let bounds: CGRect
public let annotation: PDFAnnotation public let annotation: PDFAnnotation
public let page: PDFPage public let page: PDFPage
@@ -89,6 +88,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
modifiedAt: Date?, modifiedAt: Date?,
status: String, status: String,
contents: String, contents: String,
highlightText: String,
bounds: CGRect, bounds: CGRect,
annotation: PDFAnnotation, annotation: PDFAnnotation,
page: PDFPage, page: PDFPage,
@@ -104,6 +104,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
self.modifiedAt = modifiedAt self.modifiedAt = modifiedAt
self.status = status self.status = status
self.contents = contents self.contents = contents
self.highlightText = highlightText
self.bounds = bounds self.bounds = bounds
self.annotation = annotation self.annotation = annotation
self.page = page self.page = page
@@ -126,23 +127,22 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
public var isReply: Bool { public var highlightExcerpt: String {
parentID != nil let stored = highlightText.trimmingCharacters(in: .whitespacesAndNewlines)
if !stored.isEmpty {
return stored
}
let fallback = contents.trimmingCharacters(in: .whitespacesAndNewlines)
if !fallback.isEmpty {
return fallback
}
return "Highlight on page \(pageLabel)"
} }
public static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool { public var isReply: Bool {
lhs.id == rhs.id parentID != nil
&& lhs.pageIndex == rhs.pageIndex
&& lhs.pageLabel == rhs.pageLabel
&& lhs.annotationIndex == rhs.annotationIndex
&& lhs.kind == rhs.kind
&& lhs.author == rhs.author
&& lhs.createdAt == rhs.createdAt
&& lhs.modifiedAt == rhs.modifiedAt
&& lhs.status == rhs.status
&& lhs.contents == rhs.contents
&& lhs.bounds == rhs.bounds
&& lhs.parentID == rhs.parentID
} }
} }
@@ -155,13 +155,19 @@ public enum AnnotationKeys {
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind") public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
public static let appKindComment = "Comment" public static let appKindComment = "Comment"
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText") public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
public static let appHighlightText = PDFAnnotationKey(rawValue: "IHatePDFsHighlightText")
public static func commentText(for annotation: PDFAnnotation) -> String { 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 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) { public static func setCommentText(_ text: String, for annotation: PDFAnnotation) {
@@ -287,54 +293,42 @@ public enum AnnotationKeys {
public enum AnnotationReader { public enum AnnotationReader {
public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] { public static func snapshots(in document: PDFDocument) -> [AnnotationSnapshot] {
var result: [AnnotationSnapshot] = [] var result: [AnnotationSnapshot] = []
var namedAnnotationIDs: [String: String]?
for pageIndex in 0..<document.pageCount { for pageIndex in 0..<document.pageCount {
guard let page = document.page(at: pageIndex) else { continue } guard let page = document.page(at: pageIndex) else { continue }
result.append(contentsOf: snapshots(
for (annotationIndex, annotation) in page.annotations.enumerated() { in: document,
guard !AnnotationKeys.annotation(annotation, hasSubtype: .popup) else { continue } page: page,
pageIndex: pageIndex,
let kind = AcademicAnnotationKind(annotation: annotation) namedAnnotationIDs: &namedAnnotationIDs
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
)
)
}
} }
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 { if left.pageIndex != right.pageIndex {
return left.pageIndex < right.pageIndex return left.pageIndex < right.pageIndex
} }
@@ -344,4 +338,114 @@ public enum AnnotationReader {
return left.bounds.minX < right.bounds.minX 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)
let highlightText = annotation.value(forAnnotationKey: AnnotationKeys.appHighlightText) as? String ?? ""
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,
highlightText: highlightText,
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,61 @@
import Foundation
public struct PDFDocumentBookmark {
public var id: String
public var pageIndex: Int
public var pageLabel: String
public var title: String
public var createdAt: Date
public init(
id: String = UUID().uuidString,
pageIndex: Int,
pageLabel: String,
title: String,
createdAt: Date = Date()
) {
self.id = id
self.pageIndex = pageIndex
self.pageLabel = pageLabel
self.title = title
self.createdAt = createdAt
}
}
public enum PDFDocumentBookmarks {
public static func sorted(_ bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
preferredBookmark(in: bookmarks).map { [$0] } ?? []
}
public static func upsert(
_ bookmark: PDFDocumentBookmark,
in _: [PDFDocumentBookmark]
) -> [PDFDocumentBookmark] {
[bookmark]
}
public static func removing(id: String, from bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
sorted(bookmarks.filter { $0.id != id })
}
public static func bookmark(on pageIndex: Int, in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
sorted(bookmarks).first { $0.pageIndex == pageIndex }
}
public static func clamped(
_ bookmarks: [PDFDocumentBookmark],
pageCount: Int
) -> [PDFDocumentBookmark] {
guard pageCount > 0 else { return [] }
return sorted(bookmarks.filter { (0..<pageCount).contains($0.pageIndex) })
}
private static func preferredBookmark(in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
bookmarks.max {
if $0.createdAt != $1.createdAt {
return $0.createdAt < $1.createdAt
}
return $0.pageIndex < $1.pageIndex
}
}
}

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,82 @@
import Foundation
public struct PDFRecentDocumentProgress {
public var key: String
public var pageIndex: Int
public var openedAt: Date
public init(key: String, pageIndex: Int, openedAt: Date) {
self.key = key
self.pageIndex = pageIndex
self.openedAt = openedAt
}
}
public enum PDFRecentDocuments {
public static func filteredPDFs(
from urls: [URL],
currentURL: URL? = nil,
limit: Int,
fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) }
) -> [URL] {
guard limit > 0 else { return [] }
var result: [URL] = []
var seen = Set<URL>()
let current = currentURL.map(normalized)
for url in urls {
let normalizedURL = normalized(url)
guard normalizedURL != current,
seen.insert(normalizedURL).inserted,
PDFFileSelection.isPDFFileURL(normalizedURL),
fileExists(normalizedURL)
else {
continue
}
result.append(normalizedURL)
if result.count == limit {
break
}
}
return result
}
public static func documentKey(for url: URL) -> String {
normalized(url).path
}
public static func progress(
for url: URL,
in records: [String: PDFRecentDocumentProgress]
) -> PDFRecentDocumentProgress? {
records[documentKey(for: url)]
}
public static func updatedProgress(
_ records: [String: PDFRecentDocumentProgress],
url: URL,
pageIndex: Int,
openedAt: Date
) -> [String: PDFRecentDocumentProgress] {
let key = documentKey(for: url)
var copy = records
copy[key] = PDFRecentDocumentProgress(
key: key,
pageIndex: max(0, pageIndex),
openedAt: openedAt
)
return copy
}
public static func clampedPageIndex(_ pageIndex: Int?, pageCount: Int) -> Int {
guard pageCount > 0, let pageIndex else { return 0 }
return min(max(0, pageIndex), pageCount - 1)
}
static func normalized(_ url: URL) -> URL {
url.standardizedFileURL
}
}

View File

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

View File

@@ -0,0 +1,353 @@
import CoreGraphics
import Foundation
import IHatePDFsCore
import PDFKit
import XCTest
@testable import IHatePDFs
@MainActor
final class AppStateWorkflowTests: XCTestCase {
func testOpeningDocumentStartsInFocusedReadingWorkflow() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.commentSearchText = "draft"
appState.commentFilter = .withComments
appState.selectedKindFilter = .comment
appState.selectedAuthorFilter = "Someone"
appState.selectedStatusFilter = ReviewState.reviewed
appState.collapsedPageIndexes = [0]
appState.loadDocument(from: url)
XCTAssertNotNil(appState.document)
XCTAssertEqual(appState.documentURL, url)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
XCTAssertEqual(appState.commentSearchText, "")
XCTAssertEqual(appState.commentFilter, .all)
XCTAssertNil(appState.selectedKindFilter)
XCTAssertEqual(appState.selectedAuthorFilter, "All Authors")
XCTAssertEqual(appState.selectedStatusFilter, ReviewState.allStatuses)
XCTAssertTrue(appState.collapsedPageIndexes.isEmpty)
XCTAssertFalse(appState.hasUnsavedChanges)
}
func testOpeningDocumentCollapsesSidebars() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.loadDocument(from: url)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertFalse(appState.showCommentsSidebar)
}
func testDroppingPDFWhileDocumentOpenReplacesThroughAppState() async throws {
let firstURL = try makeTemporaryPDF()
let secondURL = try makeTemporaryPDF()
defer {
try? FileManager.default.removeItem(at: firstURL)
try? FileManager.default.removeItem(at: secondURL)
}
let appState = AppState()
appState.loadDocument(from: firstURL)
XCTAssertEqual(appState.documentURL, firstURL)
let provider = try XCTUnwrap(NSItemProvider(contentsOf: secondURL))
XCTAssertTrue(appState.openDroppedDocument(from: [provider]))
try await waitUntil {
appState.documentURL == secondURL
}
XCTAssertEqual(appState.documentURL, secondURL)
XCTAssertNotNil(appState.document)
XCTAssertFalse(appState.hasUnsavedChanges)
}
func testClosingDocumentReturnsToEmptyWindowWorkflow() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.loadDocument(from: url)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.searchText = "draft"
appState.showToolbarSearch = true
appState.collapsedPageIndexes = [0]
appState.closeDocument()
XCTAssertNil(appState.document)
XCTAssertNil(appState.documentURL)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
XCTAssertEqual(appState.searchText, "")
XCTAssertFalse(appState.showToolbarSearch)
XCTAssertTrue(appState.annotations.isEmpty)
XCTAssertTrue(appState.bookmarks.isEmpty)
XCTAssertEqual(appState.currentPageIndex, 0)
XCTAssertEqual(appState.pageText, "1")
XCTAssertFalse(appState.hasUnsavedWork)
XCTAssertEqual(appState.statusMessage, "Closed PDF.")
}
func testCompactWorkflowShowsOnlyOneSidebarAtATime() {
let appState = AppState()
appState.updateWindowWidth(ReaderAdaptiveLayout.minimumWindowWidth)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
XCTAssertFalse(appState.showLeftSidebar)
appState.togglePageSidebar()
XCTAssertTrue(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
XCTAssertFalse(appState.showLeftSidebar)
}
func testPageSidebarToggleClosesLeftSidebarEvenWhenMarksAreSelected() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.togglePageSidebar()
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .annotations)
}
func testRightSidebarToolbarToggleClosesAndReopensCurrentMode() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
}
func testRightSidebarToggleClosesFromDifferentOpenMode() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebar(mode: .annotations)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebar(mode: .annotations)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testRightSidebarToggleFromHighlightsDoesNotSwitchModeWhenClosing() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
}
func testRightSidebarToolbarToggleDefaultsToCommentsWhenNoModeWasChosen() {
let appState = AppState()
appState.updateWindowWidth(1_280)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testRegularWorkflowAllowsNavigationAndReviewSidebarsTogether() {
let appState = AppState()
appState.updateWindowWidth(1_000)
appState.togglePageSidebar()
appState.toggleRightSidebar(mode: .annotations)
XCTAssertTrue(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testSaveAvailabilityTracksReplyDraftAsUnsavedWork() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.loadDocument(from: url)
XCTAssertFalse(appState.hasUnsavedWork)
XCTAssertFalse(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "No unsaved changes.")
appState.sidebarReplyDraft = "Need to verify this quote."
XCTAssertTrue(appState.hasUnsavedWork)
XCTAssertTrue(appState.hasUnsentSidebarReplyDraft)
XCTAssertTrue(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "Send or cancel the reply draft before saving.")
appState.hasUnsavedChanges = true
XCTAssertTrue(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "Save PDF")
}
func testCancelActiveModeClearsHighlighterAndPlacement() {
let appState = AppState()
appState.isHighlighterModeActive = true
appState.placementTool = .freeText
XCTAssertTrue(appState.canCancelActiveMode)
XCTAssertTrue(appState.cancelActiveMode())
XCTAssertFalse(appState.isHighlighterModeActive)
XCTAssertNil(appState.placementTool)
XCTAssertEqual(appState.statusMessage, "Free text placement canceled. Highlighter off.")
XCTAssertFalse(appState.canCancelActiveMode)
}
func testCancelActiveModeReturnsFalseWhenNoModeIsActive() {
let appState = AppState()
XCTAssertFalse(appState.canCancelActiveMode)
XCTAssertFalse(appState.cancelActiveMode())
XCTAssertEqual(appState.statusMessage, "Open a PDF to begin.")
}
func testDeleteSelectedHighlightCanUndoAndRedo() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let window = NSWindow()
let appState = AppState()
appState.hostingWindow = window
appState.loadDocument(from: url)
let page = try XCTUnwrap(appState.document?.page(at: 0))
let highlight = PDFAnnotation(
bounds: CGRect(x: 72, y: 650, width: 180, height: 18),
forType: .highlight,
withProperties: nil
)
highlight.markupType = .highlight
page.addAnnotation(highlight)
appState.refreshAnnotations(on: [page])
let item = try XCTUnwrap(appState.annotations.first { $0.annotation === highlight })
appState.selectHighlightedText(item)
XCTAssertTrue(appState.canDeleteSelectedAnnotation)
appState.deleteSelectedAnnotation()
XCTAssertFalse(page.annotations.contains { $0 === highlight })
XCTAssertTrue(appState.annotations.isEmpty)
XCTAssertTrue(appState.canUndoAnnotationChange)
appState.undoAnnotationChange()
XCTAssertTrue(page.annotations.contains { $0 === highlight })
XCTAssertEqual(appState.annotations.count, 1)
XCTAssertTrue(appState.canRedoAnnotationChange)
appState.redoAnnotationChange()
XCTAssertFalse(page.annotations.contains { $0 === highlight })
XCTAssertTrue(appState.annotations.isEmpty)
}
private func makeTemporaryPDF() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("pdf")
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data) else {
throw TestPDFError.couldNotCreateConsumer
}
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792)
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
throw TestPDFError.couldNotCreateContext
}
context.beginPDFPage(nil)
context.endPDFPage()
context.closePDF()
try data.write(to: url, options: .atomic)
return url
}
private enum TestPDFError: Error {
case couldNotCreateConsumer
case couldNotCreateContext
}
private func waitUntil(
timeout: TimeInterval = 2,
condition: @MainActor @escaping () -> Bool
) async throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return
}
try await Task.sleep(nanoseconds: 20_000_000)
}
XCTFail("Timed out waiting for condition.")
}
}

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import IHatePDFs
final class ReaderAdaptiveLayoutTests: XCTestCase {
func testSizeClassBreakpointsMatchMacWindowProfiles() {
XCTAssertEqual(ReaderAdaptiveLayout(width: 759).sizeClass, .compact)
XCTAssertEqual(ReaderAdaptiveLayout(width: 959).sizeClass, .compact)
XCTAssertEqual(ReaderAdaptiveLayout(width: 960).sizeClass, .regular)
XCTAssertEqual(ReaderAdaptiveLayout(width: 1_279).sizeClass, .regular)
XCTAssertEqual(ReaderAdaptiveLayout(width: 1_280).sizeClass, .wide)
}
func testCompactProfileKeepsSingleSidebarAndDocumentReadableAtMinimumWidth() {
let layout = ReaderAdaptiveLayout(width: ReaderAdaptiveLayout.minimumWindowWidth)
XCTAssertEqual(layout.sizeClass, .compact)
XCTAssertFalse(layout.allowsDualSidebars)
XCTAssertTrue(layout.usesCompactToolbar)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: ReaderAdaptiveLayout.minimumWindowWidth,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: 0,
showLeft: true,
showRight: false
)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: ReaderAdaptiveLayout.minimumWindowWidth,
requestedLeft: 0,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: false,
showRight: true
)
}
func testRegularProfilePreservesDocumentWidthWithBothSidebarsAtBreakpoint() {
let layout = ReaderAdaptiveLayout(width: 960)
XCTAssertEqual(layout.sizeClass, .regular)
XCTAssertTrue(layout.allowsDualSidebars)
XCTAssertFalse(layout.usesCompactToolbar)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 960,
requestedLeft: layout.leftSidebarIdealWidth,
requestedRight: layout.rightSidebarIdealWidth,
showLeft: true,
showRight: true
)
}
func testExpandedRegularSidebarsShrinkBeforeTheDocumentDoes() {
let layout = ReaderAdaptiveLayout(width: 960)
let widths = layout.resolvedSidebarWidths(
availableWidth: 960,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: true,
showRight: true
)
XCTAssertGreaterThanOrEqual(widths.left, layout.leftSidebarMinWidth - 0.001)
XCTAssertGreaterThanOrEqual(widths.right, layout.rightSidebarMinWidth - 0.001)
XCTAssertLessThanOrEqual(widths.left, layout.leftSidebarMaxWidth + 0.001)
XCTAssertLessThanOrEqual(widths.right, layout.rightSidebarMaxWidth + 0.001)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 960,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: true,
showRight: true
)
}
func testWideProfileUsesRoomierSidebarsWhilePreservingDocumentWidth() {
let layout = ReaderAdaptiveLayout(width: 1_280)
XCTAssertEqual(layout.sizeClass, .wide)
XCTAssertGreaterThan(layout.rightSidebarIdealWidth, ReaderAdaptiveLayout(width: 960).rightSidebarIdealWidth)
XCTAssertGreaterThan(layout.documentMinWidth, ReaderAdaptiveLayout(width: 960).documentMinWidth)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 1_280,
requestedLeft: layout.leftSidebarIdealWidth,
requestedRight: layout.rightSidebarIdealWidth,
showLeft: true,
showRight: true
)
}
private func assertDocumentWidthIsPreserved(
layout: ReaderAdaptiveLayout,
availableWidth: CGFloat,
requestedLeft: CGFloat,
requestedRight: CGFloat,
showLeft: Bool,
showRight: Bool,
file: StaticString = #filePath,
line: UInt = #line
) {
let widths = layout.resolvedSidebarWidths(
availableWidth: availableWidth,
requestedLeft: requestedLeft,
requestedRight: requestedRight,
showLeft: showLeft,
showRight: showRight
)
let documentWidth = layout.visibleContentWidth(
availableWidth: availableWidth,
leftWidth: widths.left,
rightWidth: widths.right,
showLeft: showLeft,
showRight: showRight
)
XCTAssertGreaterThanOrEqual(documentWidth, layout.documentMinWidth - 0.001, file: file, line: line)
}
}

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 { func testSelectionBoundCommentRoundTripsAsCommentKind() throws {
let document = try makeSelectableTextDocument() let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0)) 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 { func testAddingAnnotationPreservesPriorAnnotation() throws {
let document = try makeSelectableTextDocument() let document = try makeSelectableTextDocument()
let page = try XCTUnwrap(document.page(at: 0)) let page = try XCTUnwrap(document.page(at: 0))
@@ -379,6 +572,66 @@ final class AnnotationFactoryTests: XCTestCase {
XCTAssertEqual(replySnapshot.parentID, parentSnapshot.id) 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 { func testFreeTextCreatesStandardFreeTextAnnotation() throws {
let page = PDFPage() let page = PDFPage()
let insertion = AnnotationFactory.freeTextInsertion( let insertion = AnnotationFactory.freeTextInsertion(
@@ -439,6 +692,35 @@ final class AnnotationFactoryTests: XCTestCase {
try? FileManager.default.removeItem(at: outputURL) try? FileManager.default.removeItem(at: outputURL)
return reopened 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 { 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,52 @@
import XCTest
@testable import IHatePDFsCore
final class PDFDocumentBookmarksTests: XCTestCase {
func testUpsertReplacesExistingBookmark() {
let first = PDFDocumentBookmark(id: "first", pageIndex: 4, pageLabel: "5", title: "Old")
let second = PDFDocumentBookmark(id: "second", pageIndex: 1, pageLabel: "2", title: "Second")
let replacement = PDFDocumentBookmark(id: "replacement", pageIndex: 4, pageLabel: "5", title: "New")
let result = PDFDocumentBookmarks.upsert(replacement, in: [first, second])
XCTAssertEqual(result.map(\.id), ["replacement"])
XCTAssertEqual(result.first?.title, "New")
}
func testRemovingBookmarkCollapsesDirtyMultipleBookmarkData() {
let first = PDFDocumentBookmark(id: "first", pageIndex: 0, pageLabel: "1", title: "First")
let second = PDFDocumentBookmark(id: "second", pageIndex: 1, pageLabel: "2", title: "Second")
let result = PDFDocumentBookmarks.removing(id: "first", from: [first, second])
XCTAssertEqual(result.map(\.id), ["second"])
}
func testClampedDropsInvalidBookmarksAndKeepsOneBookmark() {
let older = PDFDocumentBookmark(
id: "older",
pageIndex: 0,
pageLabel: "1",
title: "Older",
createdAt: Date(timeIntervalSince1970: 100)
)
let newer = PDFDocumentBookmark(
id: "newer",
pageIndex: 1,
pageLabel: "2",
title: "Newer",
createdAt: Date(timeIntervalSince1970: 200)
)
let invalid = PDFDocumentBookmark(
id: "invalid",
pageIndex: 4,
pageLabel: "5",
title: "Invalid",
createdAt: Date(timeIntervalSince1970: 300)
)
let result = PDFDocumentBookmarks.clamped([invalid, older, newer], pageCount: 2)
XCTAssertEqual(result.map(\.id), ["newer"])
}
}

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,58 @@
import XCTest
@testable import IHatePDFsCore
final class PDFRecentDocumentsTests: XCTestCase {
func testFilteredPDFsKeepsExistingPDFsOnly() {
let pdf = URL(fileURLWithPath: "/Users/test/Documents/reading.pdf")
let upperPDF = URL(fileURLWithPath: "/Users/test/Documents/report.PDF")
let text = URL(fileURLWithPath: "/Users/test/Documents/notes.txt")
let result = PDFRecentDocuments.filteredPDFs(
from: [pdf, text, upperPDF],
limit: 10,
fileExists: { $0 == pdf || $0 == upperPDF }
)
XCTAssertEqual(result, [pdf, upperPDF])
}
func testFilteredPDFsDeduplicatesExcludesCurrentAndHonorsLimit() {
let first = URL(fileURLWithPath: "/Users/test/Documents/first.pdf")
let second = URL(fileURLWithPath: "/Users/test/Documents/second.pdf")
let third = URL(fileURLWithPath: "/Users/test/Documents/third.pdf")
let result = PDFRecentDocuments.filteredPDFs(
from: [first, second, first, third],
currentURL: second,
limit: 1,
fileExists: { _ in true }
)
XCTAssertEqual(result, [first])
}
func testProgressStoresPageByNormalizedDocumentKey() {
let url = URL(fileURLWithPath: "/Users/test/Documents/../Documents/reading.pdf")
let openedAt = Date(timeIntervalSince1970: 42)
let records = PDFRecentDocuments.updatedProgress(
[:],
url: url,
pageIndex: 8,
openedAt: openedAt
)
let progress = PDFRecentDocuments.progress(for: url, in: records)
XCTAssertEqual(progress?.pageIndex, 8)
XCTAssertEqual(progress?.openedAt, openedAt)
XCTAssertEqual(progress?.key, PDFRecentDocuments.documentKey(for: url))
}
func testProgressClampsSavedPageToAvailablePageCount() {
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(nil, pageCount: 20), 0)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(-4, pageCount: 20), 0)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(4, pageCount: 20), 4)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(99, pageCount: 20), 19)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(4, pageCount: 0), 0)
}
}

View File

@@ -0,0 +1,46 @@
import AppKit
import PDFKit
import XCTest
@testable import IHatePDFsCore
final class PerformanceBudgetTests: XCTestCase {
func testLargeDocumentFullAnnotationSnapshotPerformance() {
let document = makeLargeAnnotatedDocument(pageCount: 500, annotationEvery: 10)
measure {
let snapshots = AnnotationReader.snapshots(in: document)
XCTAssertEqual(snapshots.count, 50)
}
}
func testLargeDocumentPageScopedAnnotationRefreshPerformance() throws {
let document = makeLargeAnnotatedDocument(pageCount: 500, annotationEvery: 10)
let targetPage = try XCTUnwrap(document.page(at: 250))
measure {
let snapshots = AnnotationReader.snapshots(in: document, pages: [targetPage])
XCTAssertEqual(snapshots.count, 1)
XCTAssertEqual(snapshots.first?.pageIndex, 250)
}
}
private func makeLargeAnnotatedDocument(pageCount: Int, annotationEvery stride: Int) -> PDFDocument {
let document = PDFDocument()
for pageIndex in 0..<pageCount {
let page = PDFPage()
document.insert(page, at: pageIndex)
guard pageIndex.isMultiple(of: stride) else { continue }
let insertion = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 120, y: 160),
comment: "Note on page \(pageIndex + 1)",
author: "Professor"
)
page.addAnnotation(insertion.annotation)
}
return document
}
}

View File

@@ -0,0 +1,72 @@
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 testCommandReturnCommitsWhenCommandReturnOnlyModeEnabled() {
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: true,
control: false,
isEditableMultilineText: true,
commandReturnOnly: true
))
}
func testPlainReturnDoesNotCommitWhenCommandReturnOnlyModeEnabled() {
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: false,
control: false,
isEditableMultilineText: true,
commandReturnOnly: 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
))
}
}