A native macOS app that scans documents on your Epson (or any ICA-registered) scanner, OCRs them on-device with Apple's Vision framework, and exports searchable PDFs, plain text, Markdown, and JSON — with zero external dependencies. It also OCRs PDFs and images you already have, and can watch a folder to auto-process anything dropped in.
Built entirely on system frameworks: ImageCaptureCore (scanning), Vision (OCR + barcode detection + document segmentation), Core Image (preprocessing), PDFKit (reading PDFs), and CoreGraphics/CoreText (searchable-PDF output).
- Scan from a flatbed or document feeder (ADF), with duplex, selectable DPI, and color/grayscale — multi-page jobs land as one document.
- OCR existing files — PDFs (rasterized per page) and images (PNG/JPEG/TIFF/HEIC/…).
- Searchable PDF export: the original image with an invisible, selectable, aligned text layer (the ocrmypdf technique).
- Text / Markdown / JSON export — JSON includes per-line & per-word boxes and confidence.
- Barcode / QR detection (payload + symbology + location).
- Image preprocessing to boost accuracy: enhance contrast, denoise, grayscale, and optional document auto-crop/deskew (Vision segmentation + Core Image perspective correction).
- Watch folder: drop a file in, get a searchable PDF + text sidecar out. Outputs include the source extension and a source-path digest to prevent name collisions.
- macOS 14+ (developed/tested on macOS 26, Apple Silicon).
- Xcode command-line tools / Swift 6 toolchain (already present on this machine).
# Build a real .app bundle and sign it
./scripts/make-app.sh release
open "dist/OCR Studio.app"The bundle is ad-hoc signed by default — it runs fine locally, but macOS may re-prompt for scanner permission after each rebuild. To make the permission grant stick, create a stable signing identity once:
./scripts/create-signing-cert.sh # optional; see the script header if it can't auto-create./scripts/make-pkg.sh # dist/OCR Studio.pkg: installs into /Applications
./scripts/make-dmg.sh # dist/OCR Studio.dmg: drag the app to ApplicationsThe package installs OCR Studio.app into the system /Applications folder.
It does not relocate installation to copies elsewhere on disk.
swift build # debug build at .build/debug/OCRStudio
swift test # regression suite
swift run OCRStudio # launch the GUI directlyThe same pipeline runs without the GUI or a scanner — handy for automation and verification:
.build/debug/OCRStudio --ocr page1.png page2.pdf --out result.pdf
# writes result.pdf (searchable), result.txt, and result.json
# exits nonzero if any input or output fails
# add --crop to trim scanner-bed margins around recognized contentSource (scan | file) → Preprocess (Core Image / Vision) → OCR (Vision)
│
┌────────────────────────────────────────┤
▼ ▼
Searchable PDF (CoreGraphics + invisible CoreText) Text / Markdown / JSON
Architecture (see Sources/OCRStudio/):
| Module | Role |
|---|---|
Scanner/ScannerService |
ImageCaptureCore device browse + scan (runs on the main thread) |
Ingest/FileIngestor |
Rasterize PDFs (PDFKit) and decode images (ImageIO) |
Imaging/Preprocessor |
Core Image cleanup + Vision document segmentation |
OCR/OCRService |
Vision text + barcode recognition → Sendable value models |
PDF/PDFComposer |
Searchable PDF (image + invisible aligned text layer) |
Export/Exporters |
Text / Markdown / JSON |
Watch/WatchFolderService |
Polls a folder, debounces, emits stable new files |
Pipeline/JobManager |
Orchestrates ingest → preprocess → OCR → export |
Models/*, Views/* |
Sendable models, coordinate helpers, SwiftUI UI |
Coordinate spaces (Vision normalized/bottom-left ↔ image pixels/top-left ↔ PDF
points/bottom-left) are all converted in one place: Models/Geometry.swift.
The in-app Scan button uses Apple's ImageCaptureCore — the same engine as
Apple's Image Capture app. Some scanners (notably many Epson units) are only
reachable through Epson's proprietary driver and never appear to that framework.
Quick test: if Image Capture (in /Applications) doesn't list your scanner,
OCR Studio's native scan can't see it either. Diagnose with:
.build/debug/OCRStudio --list-scannersWhen macOS can't see the scanner, use the Epson fallback the app surfaces:
press Scan with Epson… (launches Epson ScanSmart / Scan 2), save scans into a
Watch Folder (Settings ▸ Output), and OCR Studio auto-OCRs each one into a
searchable …-ocr.pdf + text sidecar. Failed or cancelled native captures are retained in ~/Library/Application Support/OCRStudio/RecoveredScans; use Show Recoverable Scans to reveal and reopen them.
- The live scanner path requires the Epson powered on and not held open by Epson Scan 2; it is driven via ImageCaptureCore and gated on the device becoming available.
- The watch folder uses periodic polling with file identity, size and timestamp stability checks. It ignores existing versions when watching starts, then processes new and replaced/modified files. Changes during processing are retried. A failed text sidecar is reported and retried without rewriting its successful PDF while the app remains open.
- Ingest decodes one page at a time. Retained original and prepared rasters share a 256 MiB document budget, including earlier GUI imports and CLI inputs. Pages that exceed the remaining budget are reported explicitly; lower PDF DPI or split a large batch. Framework working buffers require additional memory.
- Partial imports retain readable pages and report page-level failures. The CLI
exits nonzero on partial success; watch-folder jobs do not acknowledge incomplete
documents.
--outmust end in.pdf. - Automatic scan cropping preserves visible ink and all recognized boxes, including figures, signatures and faint text. Noise or scanner-bed marks may therefore prevent cropping. Geometric deskewing always refreshes text geometry through OCR.
- PDF exports are validated before atomic replacement of the destination. Cancellation is checked between processing stages/pages and before publication; an active system-framework call may finish before cancellation takes effect.
- For born-digital PDFs that already contain text, the default policy re-OCRs only when the existing text looks sparse (configurable in Settings).