|
@@ -38,24 +38,24 @@ enum ScanFileService {
|
|
|
switch source {
|
|
switch source {
|
|
|
case .scanner:
|
|
case .scanner:
|
|
|
startScannerScan(from: hostWindow)
|
|
startScannerScan(from: hostWindow)
|
|
|
- case .importImage:
|
|
|
|
|
- importImage(from: hostWindow)
|
|
|
|
|
|
|
+ case .importFile:
|
|
|
|
|
+ importFile(from: hostWindow)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private enum ScanSource {
|
|
private enum ScanSource {
|
|
|
case scanner
|
|
case scanner
|
|
|
- case importImage
|
|
|
|
|
|
|
+ case importFile
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
|
|
private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
|
|
|
let alert = NSAlert()
|
|
let alert = NSAlert()
|
|
|
alert.messageText = "Scan Document"
|
|
alert.messageText = "Scan Document"
|
|
|
- alert.informativeText = "Scan with a connected scanner, or import an existing image."
|
|
|
|
|
|
|
+ alert.informativeText = "Scan with a connected scanner, or import an image or PDF."
|
|
|
alert.alertStyle = .informational
|
|
alert.alertStyle = .informational
|
|
|
alert.addButton(withTitle: "Use Scanner")
|
|
alert.addButton(withTitle: "Use Scanner")
|
|
|
- alert.addButton(withTitle: "Import Image")
|
|
|
|
|
|
|
+ alert.addButton(withTitle: "Import File")
|
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.addButton(withTitle: "Cancel")
|
|
|
|
|
|
|
|
let handler: (NSApplication.ModalResponse) -> Void = { response in
|
|
let handler: (NSApplication.ModalResponse) -> Void = { response in
|
|
@@ -63,7 +63,7 @@ enum ScanFileService {
|
|
|
case .alertFirstButtonReturn:
|
|
case .alertFirstButtonReturn:
|
|
|
completion(.scanner)
|
|
completion(.scanner)
|
|
|
case .alertSecondButtonReturn:
|
|
case .alertSecondButtonReturn:
|
|
|
- completion(.importImage)
|
|
|
|
|
|
|
+ completion(.importFile)
|
|
|
default:
|
|
default:
|
|
|
completion(nil)
|
|
completion(nil)
|
|
|
}
|
|
}
|
|
@@ -104,17 +104,17 @@ enum ScanFileService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private static func importImage(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
|
|
|
|
|
|
|
+ private static func importFile(from window: NSWindow?, completion: ((Result<NSImage, Error>) -> Void)? = nil) {
|
|
|
let panel = NSOpenPanel()
|
|
let panel = NSOpenPanel()
|
|
|
- panel.title = "Import Document Image"
|
|
|
|
|
- panel.message = "Select a photo or scan of your document."
|
|
|
|
|
|
|
+ panel.title = "Import Document"
|
|
|
|
|
+ panel.message = "Select a photo, scan, or PDF with images and text."
|
|
|
panel.prompt = "Import"
|
|
panel.prompt = "Import"
|
|
|
panel.canChooseDirectories = false
|
|
panel.canChooseDirectories = false
|
|
|
panel.canChooseFiles = true
|
|
panel.canChooseFiles = true
|
|
|
panel.allowsMultipleSelection = false
|
|
panel.allowsMultipleSelection = false
|
|
|
panel.allowsOtherFileTypes = false
|
|
panel.allowsOtherFileTypes = false
|
|
|
panel.allowedContentTypes = [
|
|
panel.allowedContentTypes = [
|
|
|
- .jpeg, .png, .heic, .heif, .tiff, .bmp, .gif, .webP,
|
|
|
|
|
|
|
+ .jpeg, .png, .heic, .heif, .tiff, .bmp, .gif, .webP, .pdf,
|
|
|
].compactMap { $0 }
|
|
].compactMap { $0 }
|
|
|
panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
|
panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
|
|
|
|
|
|
|
@@ -126,11 +126,25 @@ enum ScanFileService {
|
|
|
}
|
|
}
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
do {
|
|
do {
|
|
|
- let image = try await processImage(at: url)
|
|
|
|
|
- if let completion {
|
|
|
|
|
- completion(.success(image))
|
|
|
|
|
|
|
+ let type = DocumentContentService.contentType(for: url)
|
|
|
|
|
+ if type?.conforms(to: .pdf) == true {
|
|
|
|
|
+ let pages = try await processPDF(at: url)
|
|
|
|
|
+ if let completion {
|
|
|
|
|
+ guard let first = pages.first?.image else {
|
|
|
|
|
+ completion(.failure(ScanError.invalidImage))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ completion(.success(first))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ presentScanPreview(pages: pages, from: window)
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
- presentScanPreview(pages: [image], from: window)
|
|
|
|
|
|
|
+ let image = try await processImage(at: url)
|
|
|
|
|
+ if let completion {
|
|
|
|
|
+ completion(.success(image))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ presentScanPreview(pages: [(image, "")], from: window)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
} catch {
|
|
} catch {
|
|
|
if let completion {
|
|
if let completion {
|
|
@@ -180,7 +194,32 @@ enum ScanFileService {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private static func presentScanPreview(pages: [NSImage], from window: NSWindow?) {
|
|
|
|
|
|
|
+ private static func processPDF(at url: URL) async throws -> [(image: NSImage, text: String)] {
|
|
|
|
|
+ let accessed = url.startAccessingSecurityScopedResource()
|
|
|
|
|
+ defer {
|
|
|
|
|
+ if accessed { url.stopAccessingSecurityScopedResource() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard let document = PDFDocument(url: url), document.pageCount > 0 else {
|
|
|
|
|
+ throw ScanError.invalidImage
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var pages: [(image: NSImage, text: String)] = []
|
|
|
|
|
+ for index in 0..<document.pageCount {
|
|
|
|
|
+ guard let page = document.page(at: index),
|
|
|
|
|
+ let image = ScanDocumentBuilder.image(from: page) else { continue }
|
|
|
|
|
+ var text = page.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ if text.isEmpty {
|
|
|
|
|
+ text = (try? await OCRFileService.recognizeText(from: image)) ?? ""
|
|
|
|
|
+ }
|
|
|
|
|
+ pages.append((image, text))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ guard !pages.isEmpty else { throw ScanError.invalidImage }
|
|
|
|
|
+ return pages
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func presentScanPreview(pages: [(image: NSImage, text: String)], from window: NSWindow?) {
|
|
|
guard !pages.isEmpty else { return }
|
|
guard !pages.isEmpty else { return }
|
|
|
guard let viewController = mainViewController(from: window) else {
|
|
guard let viewController = mainViewController(from: window) else {
|
|
|
showScanError(ScanError.scanFailed, from: window)
|
|
showScanError(ScanError.scanFailed, from: window)
|
|
@@ -212,20 +251,503 @@ final class ScanPreviewImageView: NSImageView {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// MARK: - Scan Document Builder
|
|
|
|
|
+
|
|
|
|
|
+typealias ScanPage = (image: NSImage, text: String)
|
|
|
|
|
+
|
|
|
|
|
+enum ScanBodyLayout {
|
|
|
|
|
+ case single(NSAttributedString)
|
|
|
|
|
+ case twoColumn(left: NSAttributedString, right: NSAttributedString)
|
|
|
|
|
+
|
|
|
|
|
+ var plainText: String {
|
|
|
|
|
+ switch self {
|
|
|
|
|
+ case .single(let text): return text.string
|
|
|
|
|
+ case .twoColumn(let left, let right):
|
|
|
|
|
+ return [left.string, right.string].filter { !$0.isEmpty }.joined(separator: "\n\n")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+struct ParsedScanPage {
|
|
|
|
|
+ let picture: NSImage
|
|
|
|
|
+ let caption: String?
|
|
|
|
|
+ let body: ScanBodyLayout
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+enum ScanDocumentBuilder {
|
|
|
|
|
+ static let imageContentWidth: CGFloat = 520
|
|
|
|
|
+ private static let figureCaptionPattern = #"(?:Fig\.|Figure)\s*\d+"#
|
|
|
|
|
+
|
|
|
|
|
+ /// Splits a scanned page into a picture region and editable body text below it.
|
|
|
|
|
+ static func parsePage(from image: NSImage) async -> ParsedScanPage {
|
|
|
|
|
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
|
|
|
|
+ return ParsedScanPage(picture: image.normalizedForDisplay(), caption: nil, body: .single(NSAttributedString()))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let observations = (try? await OCRFileService.recognizeTextObservations(in: cgImage)) ?? []
|
|
|
|
|
+ let pictureRegion = await resolvePictureRegion(in: cgImage, observations: observations)
|
|
|
|
|
+
|
|
|
|
|
+ if let split = figureCaptionSplit(from: observations),
|
|
|
|
|
+ let cropped = cropImage(cgImage, normalizedRect: split.pictureRegion) {
|
|
|
|
|
+ let bodyObservations = bodyObservations(
|
|
|
|
|
+ from: observations,
|
|
|
|
|
+ below: split.captionObservation
|
|
|
|
|
+ )
|
|
|
|
|
+ let body = parseBodyLayout(
|
|
|
|
|
+ from: bodyObservations,
|
|
|
|
|
+ excludingCaption: split.captionText
|
|
|
|
|
+ )
|
|
|
|
|
+ return ParsedScanPage(picture: cropped, caption: split.captionText, body: body)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let region = await resolvePictureRegion(in: cgImage, observations: observations),
|
|
|
|
|
+ let cropped = cropImage(cgImage, normalizedRect: region) {
|
|
|
|
|
+ let bodyObservations = observations.filter { $0.boundingBox.midY < region.minY - 0.01 }
|
|
|
|
|
+ let body = parseBodyLayout(from: bodyObservations)
|
|
|
|
|
+ return ParsedScanPage(picture: cropped, caption: nil, body: body)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return ParsedScanPage(
|
|
|
|
|
+ picture: image.normalizedForDisplay(),
|
|
|
|
|
+ caption: nil,
|
|
|
|
|
+ body: .single(layoutAttributedText(from: observations))
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func parsedPage(from page: ScanPage) -> ParsedScanPage {
|
|
|
|
|
+ if page.text.isEmpty {
|
|
|
|
|
+ return ParsedScanPage(picture: page.image, caption: nil, body: .single(NSAttributedString()))
|
|
|
|
|
+ }
|
|
|
|
|
+ return ParsedScanPage(picture: page.image, caption: nil, body: .single(plainBodyText(page.text)))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func plainBodyText(_ text: String) -> NSAttributedString {
|
|
|
|
|
+ let style = NSMutableParagraphStyle()
|
|
|
|
|
+ style.alignment = .left
|
|
|
|
|
+ return NSAttributedString(string: text, attributes: [
|
|
|
|
|
+ .font: AppTheme.regularFont(size: 14),
|
|
|
|
|
+ .foregroundColor: NSColor.textColor,
|
|
|
|
|
+ .paragraphStyle: style,
|
|
|
|
|
+ ])
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func bodyObservations(
|
|
|
|
|
+ from observations: [VNRecognizedTextObservation],
|
|
|
|
|
+ below caption: VNRecognizedTextObservation
|
|
|
|
|
+ ) -> [VNRecognizedTextObservation] {
|
|
|
|
|
+ let captionText = caption.topCandidates(1).first?.string
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ return observations.filter { observation in
|
|
|
|
|
+ guard observation.boundingBox.maxY < caption.boundingBox.minY - 0.006 else { return false }
|
|
|
|
|
+ let text = observation.topCandidates(1).first?.string
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ guard !text.isEmpty else { return false }
|
|
|
|
|
+ if isFigureCaptionLine(text) { return false }
|
|
|
|
|
+ if !captionText.isEmpty, textsMatchCaption(text, captionText) { return false }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func textsMatchCaption(_ text: String, _ caption: String) -> Bool {
|
|
|
|
|
+ let lhs = text.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let rhs = caption.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ if lhs == rhs { return true }
|
|
|
|
|
+ if lhs.contains(rhs) || rhs.contains(lhs) { return true }
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func isFigureCaptionLine(_ text: String) -> Bool {
|
|
|
|
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard !trimmed.isEmpty else { return false }
|
|
|
|
|
+ return trimmed.range(of: figureCaptionPattern, options: .regularExpression) != nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private struct FigureCaptionSplit {
|
|
|
|
|
+ let pictureRegion: CGRect
|
|
|
|
|
+ let captionText: String
|
|
|
|
|
+ let captionObservation: VNRecognizedTextObservation
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func figureCaptionSplit(from observations: [VNRecognizedTextObservation]) -> FigureCaptionSplit? {
|
|
|
|
|
+ let captions = observations.filter { observation in
|
|
|
|
|
+ guard let text = observation.topCandidates(1).first?.string else { return false }
|
|
|
|
|
+ return text.range(of: figureCaptionPattern, options: .regularExpression) != nil
|
|
|
|
|
+ && text.count >= 12
|
|
|
|
|
+ && observation.boundingBox.midY < 0.70
|
|
|
|
|
+ && observation.boundingBox.midY > 0.20
|
|
|
|
|
+ }
|
|
|
|
|
+ .sorted { $0.boundingBox.midY > $1.boundingBox.midY }
|
|
|
|
|
+
|
|
|
|
|
+ guard let caption = captions.first(where: { ($0.topCandidates(1).first?.string ?? "").count >= 20 })
|
|
|
|
|
+ ?? captions.first,
|
|
|
|
|
+ let captionText = caption.topCandidates(1).first?.string else {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let bottomY = caption.boundingBox.minY - 0.008
|
|
|
|
|
+ let pictureHeight = 1 - bottomY
|
|
|
|
|
+ guard pictureHeight >= 0.20, pictureHeight <= 0.82 else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ return FigureCaptionSplit(
|
|
|
|
|
+ pictureRegion: CGRect(x: 0, y: bottomY, width: 1, height: pictureHeight),
|
|
|
|
|
+ captionText: captionText.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ captionObservation: caption
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func resolvePictureRegion(
|
|
|
|
|
+ in cgImage: CGImage,
|
|
|
|
|
+ observations: [VNRecognizedTextObservation]
|
|
|
|
|
+ ) async -> CGRect? {
|
|
|
|
|
+ if figureCaptionSplit(from: observations) != nil {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if let rectangle = await detectEmbeddedPicture(in: cgImage) {
|
|
|
|
|
+ let area = rectangle.width * rectangle.height
|
|
|
|
|
+ if area >= 0.08, area <= 0.52 {
|
|
|
|
|
+ return rectangle
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return pictureSplitFromOCR(observations)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// News/article images: find where prose text begins below a photo.
|
|
|
|
|
+ private static func pictureSplitFromOCR(_ observations: [VNRecognizedTextObservation]) -> CGRect? {
|
|
|
|
|
+ let proseLines = observations.filter {
|
|
|
|
|
+ ($0.topCandidates(1).first?.string ?? "").count > 25
|
|
|
|
|
+ }
|
|
|
|
|
+ let lowerHalf = proseLines.filter { $0.boundingBox.midY < 0.52 }
|
|
|
|
|
+ guard lowerHalf.count >= 2,
|
|
|
|
|
+ let topLine = lowerHalf.max(by: { $0.boundingBox.midY < $1.boundingBox.midY }) else {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let splitY = topLine.boundingBox.maxY + 0.015
|
|
|
|
|
+ let pictureHeight = 1 - splitY
|
|
|
|
|
+ guard pictureHeight >= 0.25, pictureHeight <= 0.75 else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ return CGRect(x: 0, y: splitY, width: 1, height: pictureHeight)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func parseBodyLayout(
|
|
|
|
|
+ from observations: [VNRecognizedTextObservation],
|
|
|
|
|
+ excludingCaption: String? = nil
|
|
|
|
|
+ ) -> ScanBodyLayout {
|
|
|
|
|
+ let left = observations.filter { $0.boundingBox.midX < 0.48 }
|
|
|
|
|
+ let right = observations.filter { $0.boundingBox.midX >= 0.48 }
|
|
|
|
|
+
|
|
|
|
|
+ if left.count >= 3, right.count >= 3 {
|
|
|
|
|
+ let leftAverage = left.map(\.boundingBox.midX).reduce(0, +) / CGFloat(left.count)
|
|
|
|
|
+ let rightAverage = right.map(\.boundingBox.midX).reduce(0, +) / CGFloat(right.count)
|
|
|
|
|
+ if rightAverage - leftAverage > 0.22 {
|
|
|
|
|
+ return .twoColumn(
|
|
|
|
|
+ left: layoutAttributedText(from: left, excludingCaption: excludingCaption),
|
|
|
|
|
+ right: layoutAttributedText(from: right, excludingCaption: excludingCaption)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return .single(layoutAttributedText(from: observations, excludingCaption: excludingCaption))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Groups OCR lines into paragraphs; section headings are centered on their own lines.
|
|
|
|
|
+ private static func layoutAttributedText(
|
|
|
|
|
+ from observations: [VNRecognizedTextObservation],
|
|
|
|
|
+ excludingCaption: String? = nil
|
|
|
|
|
+ ) -> NSAttributedString {
|
|
|
|
|
+ guard !observations.isEmpty else { return NSAttributedString() }
|
|
|
|
|
+
|
|
|
|
|
+ let bodyStyle = NSMutableParagraphStyle()
|
|
|
|
|
+ bodyStyle.alignment = .left
|
|
|
|
|
+ bodyStyle.lineBreakMode = .byWordWrapping
|
|
|
|
|
+
|
|
|
|
|
+ let headingStyle = NSMutableParagraphStyle()
|
|
|
|
|
+ headingStyle.alignment = .center
|
|
|
|
|
+ headingStyle.lineBreakMode = .byWordWrapping
|
|
|
|
|
+
|
|
|
|
|
+ let bodyAttributes: [NSAttributedString.Key: Any] = [
|
|
|
|
|
+ .font: AppTheme.regularFont(size: 14),
|
|
|
|
|
+ .foregroundColor: NSColor.textColor,
|
|
|
|
|
+ .paragraphStyle: bodyStyle,
|
|
|
|
|
+ ]
|
|
|
|
|
+ let headingAttributes: [NSAttributedString.Key: Any] = [
|
|
|
|
|
+ .font: AppTheme.semiboldFont(size: 14),
|
|
|
|
|
+ .foregroundColor: NSColor.textColor,
|
|
|
|
|
+ .paragraphStyle: headingStyle,
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ let sorted = observations.sorted { lhs, rhs in
|
|
|
|
|
+ let leftY = 1 - lhs.boundingBox.midY
|
|
|
|
|
+ let rightY = 1 - rhs.boundingBox.midY
|
|
|
|
|
+ if abs(leftY - rightY) > 0.02 { return leftY < rightY }
|
|
|
|
|
+ return lhs.boundingBox.minX < rhs.boundingBox.minX
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let result = NSMutableAttributedString()
|
|
|
|
|
+ var currentLines: [String] = []
|
|
|
|
|
+ var previous: VNRecognizedTextObservation?
|
|
|
|
|
+
|
|
|
|
|
+ func appendParagraph(_ text: String, attributes: [NSAttributedString.Key: Any]) {
|
|
|
|
|
+ guard !text.isEmpty else { return }
|
|
|
|
|
+ if result.length > 0 {
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ }
|
|
|
|
|
+ result.append(NSAttributedString(string: text, attributes: attributes))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for observation in sorted {
|
|
|
|
|
+ let text = observation.topCandidates(1).first?.string
|
|
|
|
|
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ guard !text.isEmpty, !isFigureCaptionLine(text) else { continue }
|
|
|
|
|
+ if let caption = excludingCaption, textsMatchCaption(text, caption) { continue }
|
|
|
|
|
+
|
|
|
|
|
+ if isSectionHeading(text) {
|
|
|
|
|
+ if !currentLines.isEmpty {
|
|
|
|
|
+ appendParagraph(currentLines.joined(separator: " "), attributes: bodyAttributes)
|
|
|
|
|
+ currentLines = []
|
|
|
|
|
+ }
|
|
|
|
|
+ appendParagraph(text, attributes: headingAttributes)
|
|
|
|
|
+ previous = observation
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if let prev = previous {
|
|
|
|
|
+ let gap = prev.boundingBox.minY - observation.boundingBox.maxY
|
|
|
|
|
+ let startsNewParagraph = gap > 0.020
|
|
|
|
|
+ || (endsSentence(prev) && gap > 0.010)
|
|
|
|
|
+ if startsNewParagraph, !currentLines.isEmpty {
|
|
|
|
|
+ appendParagraph(currentLines.joined(separator: " "), attributes: bodyAttributes)
|
|
|
|
|
+ currentLines = []
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ currentLines.append(text)
|
|
|
|
|
+ previous = observation
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if !currentLines.isEmpty {
|
|
|
|
|
+ appendParagraph(currentLines.joined(separator: " "), attributes: bodyAttributes)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return stripLeadingCaptionParagraph(from: result, caption: excludingCaption)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func stripLeadingCaptionParagraph(
|
|
|
|
|
+ from attributed: NSAttributedString,
|
|
|
|
|
+ caption: String?
|
|
|
|
|
+ ) -> NSAttributedString {
|
|
|
|
|
+ guard let caption, !caption.isEmpty else { return attributed }
|
|
|
|
|
+ let plain = attributed.string
|
|
|
|
|
+ guard !plain.isEmpty else { return attributed }
|
|
|
|
|
+
|
|
|
|
|
+ let paragraphs = plain.components(separatedBy: "\n\n")
|
|
|
|
|
+ guard let first = paragraphs.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
|
|
|
+ !first.isEmpty,
|
|
|
|
|
+ isFigureCaptionLine(first) || textsMatchCaption(first, caption) else {
|
|
|
|
|
+ return attributed
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var cutLength = first.count
|
|
|
|
|
+ while cutLength < plain.count {
|
|
|
|
|
+ let index = plain.index(plain.startIndex, offsetBy: cutLength)
|
|
|
|
|
+ if plain[index] == "\n" {
|
|
|
|
|
+ cutLength += 1
|
|
|
|
|
+ } else {
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let range = NSRange(location: 0, length: min(cutLength, attributed.length))
|
|
|
|
|
+ guard range.length < attributed.length else { return NSAttributedString() }
|
|
|
|
|
+
|
|
|
|
|
+ let remainder = attributed.attributedSubstring(
|
|
|
|
|
+ from: NSRange(location: range.length, length: attributed.length - range.length)
|
|
|
|
|
+ )
|
|
|
|
|
+ return remainder.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
|
|
|
+ ? NSAttributedString()
|
|
|
|
|
+ : remainder
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func isSectionHeading(_ text: String) -> Bool {
|
|
|
|
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ guard trimmed.count >= 4, trimmed.count <= 70 else { return false }
|
|
|
|
|
+ if trimmed.range(of: figureCaptionPattern, options: .regularExpression) != nil { return false }
|
|
|
|
|
+ if trimmed.hasSuffix(",") || trimmed.hasSuffix(";") { return false }
|
|
|
|
|
+
|
|
|
|
|
+ let words = trimmed.split(whereSeparator: \.isWhitespace)
|
|
|
|
|
+ guard words.count >= 2, words.count <= 10 else { return false }
|
|
|
|
|
+
|
|
|
|
|
+ let titleCaseCount = words.filter { word in
|
|
|
|
|
+ guard let first = word.first else { return false }
|
|
|
|
|
+ return first.isUppercase
|
|
|
|
|
+ }.count
|
|
|
|
|
+ return titleCaseCount >= words.count - 1
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func endsSentence(_ observation: VNRecognizedTextObservation) -> Bool {
|
|
|
|
|
+ guard let text = observation.topCandidates(1).first?.string else { return false }
|
|
|
|
|
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ return trimmed.hasSuffix(".") || trimmed.hasSuffix(":")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func buildExportDocument(pages: [ParsedScanPage]) -> NSAttributedString {
|
|
|
|
|
+ let result = NSMutableAttributedString()
|
|
|
|
|
+ let bodyFont = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ let bodyAttributes: [NSAttributedString.Key: Any] = [
|
|
|
|
|
+ .font: bodyFont,
|
|
|
|
|
+ .foregroundColor: NSColor.textColor,
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ for (index, page) in pages.enumerated() {
|
|
|
|
|
+ if index > 0 {
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let attachment = NSTextAttachment()
|
|
|
|
|
+ attachment.image = page.picture
|
|
|
|
|
+ attachment.bounds = attachmentBounds(for: page.picture)
|
|
|
|
|
+ result.append(NSAttributedString(attachment: attachment))
|
|
|
|
|
+
|
|
|
|
|
+ if let caption = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines), !caption.isEmpty {
|
|
|
|
|
+ let captionStyle = NSMutableParagraphStyle()
|
|
|
|
|
+ captionStyle.alignment = .center
|
|
|
|
|
+ var captionAttributes = bodyAttributes
|
|
|
|
|
+ captionAttributes[.paragraphStyle] = captionStyle
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ result.append(NSAttributedString(string: caption, attributes: captionAttributes))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch page.body {
|
|
|
|
|
+ case .single(let body):
|
|
|
|
|
+ guard body.length > 0 else { continue }
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ result.append(body)
|
|
|
|
|
+ case .twoColumn(let left, let right):
|
|
|
|
|
+ if left.length > 0 {
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ result.append(left)
|
|
|
|
|
+ }
|
|
|
|
|
+ if right.length > 0 {
|
|
|
|
|
+ result.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
|
|
|
|
|
+ result.append(right)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func image(from page: PDFPage) -> NSImage? {
|
|
|
|
|
+ let bounds = page.bounds(for: .mediaBox)
|
|
|
|
|
+ guard bounds.width > 0, bounds.height > 0 else { return nil }
|
|
|
|
|
+
|
|
|
|
|
+ let scale: CGFloat = 2
|
|
|
|
|
+ let size = NSSize(width: bounds.width * scale, height: bounds.height * scale)
|
|
|
|
|
+ let image = NSImage(size: size)
|
|
|
|
|
+ image.lockFocus()
|
|
|
|
|
+ guard let context = NSGraphicsContext.current?.cgContext else {
|
|
|
|
|
+ image.unlockFocus()
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ context.setFillColor(NSColor.white.cgColor)
|
|
|
|
|
+ context.fill(CGRect(origin: .zero, size: size))
|
|
|
|
|
+ context.saveGState()
|
|
|
|
|
+ context.scaleBy(x: scale, y: scale)
|
|
|
|
|
+ page.draw(with: .mediaBox, to: context)
|
|
|
|
|
+ context.restoreGState()
|
|
|
|
|
+ image.unlockFocus()
|
|
|
|
|
+ return image
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func attachmentBounds(for image: NSImage) -> CGRect {
|
|
|
|
|
+ let width = max(image.size.width, 1)
|
|
|
|
|
+ let height = max(image.size.height, 1)
|
|
|
|
|
+ let scale = min(1, imageContentWidth / width)
|
|
|
|
|
+ return CGRect(x: 0, y: 0, width: width * scale, height: height * scale)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func detectEmbeddedPicture(in cgImage: CGImage) async -> CGRect? {
|
|
|
|
|
+ await withCheckedContinuation { continuation in
|
|
|
|
|
+ let request = VNDetectRectanglesRequest { request, _ in
|
|
|
|
|
+ let observations = (request.results as? [VNRectangleObservation]) ?? []
|
|
|
|
|
+ let candidate = observations
|
|
|
|
|
+ .filter { observation in
|
|
|
|
|
+ let box = observation.boundingBox
|
|
|
|
|
+ let area = box.width * box.height
|
|
|
|
|
+ guard area >= 0.08, area <= 0.52 else { return false }
|
|
|
|
|
+ guard box.maxY > 0.78 else { return false }
|
|
|
|
|
+ guard box.minY > 0.28 else { return false }
|
|
|
|
|
+ let aspect = box.width / max(box.height, 0.01)
|
|
|
|
|
+ guard aspect >= 0.35, aspect <= 3.0 else { return false }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ .max { lhs, rhs in
|
|
|
|
|
+ lhs.boundingBox.width * lhs.boundingBox.height
|
|
|
|
|
+ < rhs.boundingBox.width * rhs.boundingBox.height
|
|
|
|
|
+ }
|
|
|
|
|
+ continuation.resume(returning: candidate?.boundingBox)
|
|
|
|
|
+ }
|
|
|
|
|
+ request.minimumConfidence = 0.5
|
|
|
|
|
+ request.maximumObservations = 5
|
|
|
|
|
+ request.minimumAspectRatio = 0.3
|
|
|
|
|
+ request.maximumAspectRatio = 3.0
|
|
|
|
|
+ request.minimumSize = 0.08
|
|
|
|
|
+
|
|
|
|
|
+ let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
|
|
|
|
+ do {
|
|
|
|
|
+ try handler.perform([request])
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ continuation.resume(returning: nil)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static func cropImage(_ cgImage: CGImage, normalizedRect: CGRect) -> NSImage? {
|
|
|
|
|
+ let width = CGFloat(cgImage.width)
|
|
|
|
|
+ let height = CGFloat(cgImage.height)
|
|
|
|
|
+ let cropRect = CGRect(
|
|
|
|
|
+ x: normalizedRect.minX * width,
|
|
|
|
|
+ y: (1 - normalizedRect.maxY) * height,
|
|
|
|
|
+ width: normalizedRect.width * width,
|
|
|
|
|
+ height: normalizedRect.height * height
|
|
|
|
|
+ ).integral
|
|
|
|
|
+
|
|
|
|
|
+ guard cropRect.width > 0, cropRect.height > 0,
|
|
|
|
|
+ let cropped = cgImage.cropping(to: cropRect) else { return nil }
|
|
|
|
|
+ return NSImage.normalizedForDisplay(cgImage: cropped)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - Editable Scan Content
|
|
// MARK: - Editable Scan Content
|
|
|
|
|
|
|
|
-/// Runs OCR on scanned pages and presents the recognized text in an editable editor.
|
|
|
|
|
|
|
+/// Picture on top, editable text below — matching the original document layout.
|
|
|
final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
var onRecognitionComplete: (() -> Void)?
|
|
var onRecognitionComplete: (() -> Void)?
|
|
|
|
|
|
|
|
- private let textScrollView = NSScrollView()
|
|
|
|
|
- private let textView = NSTextView()
|
|
|
|
|
|
|
+ private var parsedPages: [ParsedScanPage] = []
|
|
|
|
|
+
|
|
|
|
|
+ private let contentScrollView = NSScrollView()
|
|
|
|
|
+ private let documentStack = NSStackView()
|
|
|
|
|
+ private let pictureView = ScanPreviewImageView()
|
|
|
|
|
+ private let captionScroll = NSScrollView()
|
|
|
|
|
+ private let captionText = NSTextView()
|
|
|
|
|
+ private let singleColumnScroll = NSScrollView()
|
|
|
|
|
+ private let singleColumnText = NSTextView()
|
|
|
|
|
+ private let columnRow = NSStackView()
|
|
|
|
|
+ private let leftColumnScroll = NSScrollView()
|
|
|
|
|
+ private let leftColumnText = NSTextView()
|
|
|
|
|
+ private let rightColumnScroll = NSScrollView()
|
|
|
|
|
+ private let rightColumnText = NSTextView()
|
|
|
|
|
+ private var pictureHeightConstraint: NSLayoutConstraint!
|
|
|
private let loadingIndicator = NSProgressIndicator()
|
|
private let loadingIndicator = NSProgressIndicator()
|
|
|
- private let statusLabel = NSTextField(labelWithString: "Extracting text…")
|
|
|
|
|
|
|
+ private let statusLabel = NSTextField(labelWithString: "Preparing document…")
|
|
|
|
|
|
|
|
var text: String {
|
|
var text: String {
|
|
|
- get { textView.string }
|
|
|
|
|
- set { textView.string = newValue }
|
|
|
|
|
|
|
+ exportPages().map(\.body.plainText).joined(separator: "\n\n")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var attributedText: NSAttributedString {
|
|
|
|
|
+ ScanDocumentBuilder.buildExportDocument(pages: exportPages())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
override init(frame frameRect: NSRect) {
|
|
@@ -240,52 +762,106 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
|
|
func refreshAppearance() {
|
|
func refreshAppearance() {
|
|
|
statusLabel.textColor = AppTheme.textSecondary
|
|
statusLabel.textColor = AppTheme.textSecondary
|
|
|
- textView.textColor = AppTheme.textPrimary
|
|
|
|
|
- textView.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
|
|
+ for textView in [captionText, singleColumnText, leftColumnText, rightColumnText] {
|
|
|
|
|
+ textView.textColor = AppTheme.textPrimary
|
|
|
|
|
+ textView.font = AppTheme.regularFont(size: 14)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func recognizeText(from images: [NSImage]) {
|
|
|
|
|
- showLoading("Extracting text…")
|
|
|
|
|
- Task { @MainActor in
|
|
|
|
|
- let recognized = await OCRFileService.recognizeText(from: images)
|
|
|
|
|
- showRecognizedText(recognized)
|
|
|
|
|
|
|
+ func loadDocument(pages: [ScanPage]) {
|
|
|
|
|
+ if pages.allSatisfy({ !$0.text.isEmpty }) {
|
|
|
|
|
+ let parsed = pages.map(ScanDocumentBuilder.parsedPage(from:))
|
|
|
|
|
+ showPages(parsed)
|
|
|
onRecognitionComplete?()
|
|
onRecognitionComplete?()
|
|
|
|
|
+ return
|
|
|
}
|
|
}
|
|
|
|
|
+ recognizeText(from: pages)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func appendAndRecognize(_ newImages: [NSImage], existingImages: [NSImage]) {
|
|
|
|
|
- showLoading("Extracting text from new pages…")
|
|
|
|
|
|
|
+ func recognizeText(from images: [NSImage]) {
|
|
|
|
|
+ recognizeText(from: images.map { ($0, "") })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func appendAndRecognize(_ newImages: [NSImage], existingPages: [ScanPage]) {
|
|
|
|
|
+ let newPages = newImages.map { (image: $0, text: "") }
|
|
|
|
|
+ recognizeText(from: existingPages + newPages)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func recognizeText(from pages: [ScanPage]) {
|
|
|
|
|
+ showLoading("Preparing document…")
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
- let recognized = await OCRFileService.recognizeText(from: existingImages + newImages)
|
|
|
|
|
- showRecognizedText(recognized)
|
|
|
|
|
|
|
+ var parsed: [ParsedScanPage] = []
|
|
|
|
|
+ for page in pages {
|
|
|
|
|
+ if !page.text.isEmpty {
|
|
|
|
|
+ parsed.append(ScanDocumentBuilder.parsedPage(from: page))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ parsed.append(await ScanDocumentBuilder.parsePage(from: page.image))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ showPages(parsed)
|
|
|
onRecognitionComplete?()
|
|
onRecognitionComplete?()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ func exportPages() -> [ParsedScanPage] {
|
|
|
|
|
+ guard let first = parsedPages.first else { return [] }
|
|
|
|
|
+ let body: ScanBodyLayout
|
|
|
|
|
+ if !columnRow.isHidden {
|
|
|
|
|
+ body = .twoColumn(left: leftColumnText.attributedString(), right: rightColumnText.attributedString())
|
|
|
|
|
+ } else {
|
|
|
|
|
+ body = .single(singleColumnText.attributedString())
|
|
|
|
|
+ }
|
|
|
|
|
+ let caption = captionScroll.isHidden ? nil : captionText.string.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ return [ParsedScanPage(picture: first.picture, caption: caption?.isEmpty == false ? caption : nil, body: body)]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
func makeFirstResponder() {
|
|
func makeFirstResponder() {
|
|
|
- window?.makeFirstResponder(textView)
|
|
|
|
|
|
|
+ let target = columnRow.isHidden ? singleColumnText : leftColumnText
|
|
|
|
|
+ window?.makeFirstResponder(target)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func setup() {
|
|
private func setup() {
|
|
|
- textScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- textScrollView.hasVerticalScroller = true
|
|
|
|
|
- textScrollView.hasHorizontalScroller = false
|
|
|
|
|
- textScrollView.autohidesScrollers = true
|
|
|
|
|
- textScrollView.borderType = .noBorder
|
|
|
|
|
- textScrollView.drawsBackground = false
|
|
|
|
|
- textScrollView.isHidden = true
|
|
|
|
|
-
|
|
|
|
|
- textView.isRichText = false
|
|
|
|
|
- textView.isEditable = true
|
|
|
|
|
- textView.isSelectable = true
|
|
|
|
|
- textView.drawsBackground = false
|
|
|
|
|
- textView.textContainerInset = NSSize(width: 8, height: 8)
|
|
|
|
|
- textView.isAutomaticQuoteSubstitutionEnabled = true
|
|
|
|
|
- textView.isAutomaticDashSubstitutionEnabled = true
|
|
|
|
|
- textView.isAutomaticTextReplacementEnabled = true
|
|
|
|
|
- textView.isHidden = true
|
|
|
|
|
-
|
|
|
|
|
- textScrollView.documentView = textView
|
|
|
|
|
|
|
+ contentScrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contentScrollView.hasVerticalScroller = true
|
|
|
|
|
+ contentScrollView.hasHorizontalScroller = false
|
|
|
|
|
+ contentScrollView.autohidesScrollers = true
|
|
|
|
|
+ contentScrollView.borderType = .noBorder
|
|
|
|
|
+ contentScrollView.drawsBackground = false
|
|
|
|
|
+ contentScrollView.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ documentStack.orientation = .vertical
|
|
|
|
|
+ documentStack.alignment = .leading
|
|
|
|
|
+ documentStack.spacing = 16
|
|
|
|
|
+ documentStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ pictureView.imageScaling = .scaleProportionallyDown
|
|
|
|
|
+ pictureView.imageAlignment = .alignCenter
|
|
|
|
|
+ pictureView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ columnRow.orientation = .horizontal
|
|
|
|
|
+ columnRow.alignment = .top
|
|
|
|
|
+ columnRow.distribution = .fillEqually
|
|
|
|
|
+ columnRow.spacing = 16
|
|
|
|
|
+ columnRow.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ columnRow.isHidden = true
|
|
|
|
|
+
|
|
|
|
|
+ configureTextView(captionText, in: captionScroll, centered: true)
|
|
|
|
|
+ configureTextView(singleColumnText, in: singleColumnScroll, richText: true)
|
|
|
|
|
+ configureTextView(leftColumnText, in: leftColumnScroll, richText: true)
|
|
|
|
|
+ configureTextView(rightColumnText, in: rightColumnScroll, richText: true)
|
|
|
|
|
+
|
|
|
|
|
+ columnRow.addArrangedSubview(leftColumnScroll)
|
|
|
|
|
+ columnRow.addArrangedSubview(rightColumnScroll)
|
|
|
|
|
+
|
|
|
|
|
+ documentStack.addArrangedSubview(pictureView)
|
|
|
|
|
+ documentStack.addArrangedSubview(captionScroll)
|
|
|
|
|
+ documentStack.addArrangedSubview(singleColumnScroll)
|
|
|
|
|
+ documentStack.addArrangedSubview(columnRow)
|
|
|
|
|
+
|
|
|
|
|
+ let documentContainer = NSView()
|
|
|
|
|
+ documentContainer.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ documentContainer.addSubview(documentStack)
|
|
|
|
|
+ contentScrollView.documentView = documentContainer
|
|
|
|
|
|
|
|
loadingIndicator.style = .spinning
|
|
loadingIndicator.style = .spinning
|
|
|
loadingIndicator.controlSize = .regular
|
|
loadingIndicator.controlSize = .regular
|
|
@@ -297,15 +873,37 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
statusLabel.maximumNumberOfLines = 3
|
|
statusLabel.maximumNumberOfLines = 3
|
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
- addSubview(textScrollView)
|
|
|
|
|
|
|
+ addSubview(contentScrollView)
|
|
|
addSubview(loadingIndicator)
|
|
addSubview(loadingIndicator)
|
|
|
addSubview(statusLabel)
|
|
addSubview(statusLabel)
|
|
|
|
|
|
|
|
|
|
+ pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
|
|
|
|
|
+
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- textScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
- textScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
- textScrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
- textScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
|
|
+ contentScrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
|
|
|
+ contentScrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
|
|
|
+ contentScrollView.topAnchor.constraint(equalTo: topAnchor),
|
|
|
|
|
+ contentScrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ documentStack.leadingAnchor.constraint(equalTo: documentContainer.leadingAnchor),
|
|
|
|
|
+ documentStack.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
|
|
|
|
|
+ documentStack.topAnchor.constraint(equalTo: documentContainer.topAnchor, constant: 8),
|
|
|
|
|
+ documentStack.bottomAnchor.constraint(equalTo: documentContainer.bottomAnchor, constant: -8),
|
|
|
|
|
+ documentContainer.widthAnchor.constraint(equalTo: contentScrollView.widthAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ pictureView.widthAnchor.constraint(equalTo: documentStack.widthAnchor),
|
|
|
|
|
+ pictureHeightConstraint,
|
|
|
|
|
+
|
|
|
|
|
+ captionScroll.widthAnchor.constraint(equalTo: documentStack.widthAnchor),
|
|
|
|
|
+ captionScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 36),
|
|
|
|
|
+ captionScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 72),
|
|
|
|
|
+
|
|
|
|
|
+ singleColumnScroll.widthAnchor.constraint(equalTo: documentStack.widthAnchor),
|
|
|
|
|
+ singleColumnScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
|
|
+
|
|
|
|
|
+ columnRow.widthAnchor.constraint(equalTo: documentStack.widthAnchor),
|
|
|
|
|
+ leftColumnScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
|
|
+ rightColumnScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
|
|
|
|
|
loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
loadingIndicator.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -12),
|
|
loadingIndicator.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -12),
|
|
@@ -316,22 +914,115 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private func configureTextView(
|
|
|
|
|
+ _ textView: NSTextView,
|
|
|
|
|
+ in scrollView: NSScrollView,
|
|
|
|
|
+ centered: Bool = false,
|
|
|
|
|
+ richText: Bool = false
|
|
|
|
|
+ ) {
|
|
|
|
|
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ scrollView.hasVerticalScroller = !centered
|
|
|
|
|
+ scrollView.hasHorizontalScroller = false
|
|
|
|
|
+ scrollView.autohidesScrollers = true
|
|
|
|
|
+ scrollView.borderType = .noBorder
|
|
|
|
|
+ scrollView.drawsBackground = false
|
|
|
|
|
+
|
|
|
|
|
+ textView.isRichText = richText
|
|
|
|
|
+ textView.isEditable = true
|
|
|
|
|
+ textView.isSelectable = true
|
|
|
|
|
+ textView.drawsBackground = false
|
|
|
|
|
+ textView.textColor = AppTheme.textPrimary
|
|
|
|
|
+ textView.font = AppTheme.regularFont(size: centered ? 13 : 14)
|
|
|
|
|
+ textView.alignment = centered ? .center : .left
|
|
|
|
|
+ textView.textContainerInset = NSSize(width: 4, height: centered ? 4 : 8)
|
|
|
|
|
+ textView.isVerticallyResizable = true
|
|
|
|
|
+ textView.isHorizontallyResizable = false
|
|
|
|
|
+ textView.autoresizingMask = [.width]
|
|
|
|
|
+ textView.minSize = NSSize(width: 0, height: 0)
|
|
|
|
|
+ textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
+ textView.textContainer?.widthTracksTextView = true
|
|
|
|
|
+ textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
+
|
|
|
|
|
+ scrollView.documentView = textView
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private func showLoading(_ message: String) {
|
|
private func showLoading(_ message: String) {
|
|
|
statusLabel.stringValue = message
|
|
statusLabel.stringValue = message
|
|
|
statusLabel.isHidden = false
|
|
statusLabel.isHidden = false
|
|
|
loadingIndicator.isHidden = false
|
|
loadingIndicator.isHidden = false
|
|
|
loadingIndicator.startAnimation(nil)
|
|
loadingIndicator.startAnimation(nil)
|
|
|
- textScrollView.isHidden = true
|
|
|
|
|
- textView.isHidden = true
|
|
|
|
|
|
|
+ contentScrollView.isHidden = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func showRecognizedText(_ text: String) {
|
|
|
|
|
|
|
+ private func showPages(_ pages: [ParsedScanPage]) {
|
|
|
|
|
+ parsedPages = pages
|
|
|
|
|
+ guard let page = pages.first else { return }
|
|
|
|
|
+
|
|
|
loadingIndicator.stopAnimation(nil)
|
|
loadingIndicator.stopAnimation(nil)
|
|
|
loadingIndicator.isHidden = true
|
|
loadingIndicator.isHidden = true
|
|
|
statusLabel.isHidden = true
|
|
statusLabel.isHidden = true
|
|
|
- textView.string = text
|
|
|
|
|
- textView.isHidden = false
|
|
|
|
|
- textScrollView.isHidden = false
|
|
|
|
|
|
|
+
|
|
|
|
|
+ pictureView.image = page.picture
|
|
|
|
|
+ updatePictureHeight(for: page.picture)
|
|
|
|
|
+
|
|
|
|
|
+ if let caption = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines), !caption.isEmpty {
|
|
|
|
|
+ captionText.string = caption
|
|
|
|
|
+ captionScroll.isHidden = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ captionText.string = ""
|
|
|
|
|
+ captionScroll.isHidden = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ switch page.body {
|
|
|
|
|
+ case .single(let text):
|
|
|
|
|
+ columnRow.isHidden = true
|
|
|
|
|
+ singleColumnScroll.isHidden = false
|
|
|
|
|
+ singleColumnText.textStorage?.setAttributedString(text)
|
|
|
|
|
+ case .twoColumn(let left, let right):
|
|
|
|
|
+ singleColumnScroll.isHidden = true
|
|
|
|
|
+ columnRow.isHidden = false
|
|
|
|
|
+ leftColumnText.textStorage?.setAttributedString(left)
|
|
|
|
|
+ rightColumnText.textStorage?.setAttributedString(right)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ contentScrollView.isHidden = false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updatePictureHeight(for image: NSImage) {
|
|
|
|
|
+ let width = max(bounds.width, ScanDocumentBuilder.imageContentWidth)
|
|
|
|
|
+ let scale = min(1, width / max(image.size.width, 1))
|
|
|
|
|
+ pictureHeightConstraint.constant = max(image.size.height * scale, 120)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func layout() {
|
|
|
|
|
+ super.layout()
|
|
|
|
|
+ if let image = pictureView.image {
|
|
|
|
|
+ updatePictureHeight(for: image)
|
|
|
|
|
+ }
|
|
|
|
|
+ updateTextWidths()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateTextWidths() {
|
|
|
|
|
+ let totalWidth = max(contentScrollView.contentSize.width, contentScrollView.bounds.width)
|
|
|
|
|
+ guard totalWidth > 0 else { return }
|
|
|
|
|
+
|
|
|
|
|
+ captionText.textContainer?.containerSize = NSSize(width: totalWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
+ if abs(captionText.frame.width - totalWidth) > 1 {
|
|
|
|
|
+ captionText.setFrameSize(NSSize(width: totalWidth, height: captionText.frame.height))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ singleColumnText.textContainer?.containerSize = NSSize(width: totalWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
+ if abs(singleColumnText.frame.width - totalWidth) > 1 {
|
|
|
|
|
+ singleColumnText.setFrameSize(NSSize(width: totalWidth, height: singleColumnText.frame.height))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let columnWidth = max((totalWidth - columnRow.spacing) / 2, 120)
|
|
|
|
|
+ for textView in [leftColumnText, rightColumnText] {
|
|
|
|
|
+ textView.textContainer?.containerSize = NSSize(width: columnWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
|
|
|
+ if abs(textView.frame.width - columnWidth) > 1 {
|
|
|
|
|
+ textView.setFrameSize(NSSize(width: columnWidth, height: textView.frame.height))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -355,12 +1046,13 @@ enum ScanDocumentProcessor {
|
|
|
static func process(_ cgImage: CGImage) async -> NSImage? {
|
|
static func process(_ cgImage: CGImage) async -> NSImage? {
|
|
|
await withCheckedContinuation { continuation in
|
|
await withCheckedContinuation { continuation in
|
|
|
let request = VNDetectRectanglesRequest { request, _ in
|
|
let request = VNDetectRectanglesRequest { request, _ in
|
|
|
- guard let observation = (request.results as? [VNRectangleObservation])?.first else {
|
|
|
|
|
|
|
+ guard let observation = (request.results as? [VNRectangleObservation])?.first,
|
|
|
|
|
+ shouldCorrect(observation: observation) else {
|
|
|
continuation.resume(returning: imageFromCGImage(cgImage))
|
|
continuation.resume(returning: imageFromCGImage(cgImage))
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
let corrected = perspectiveCorrectedImage(from: cgImage, observation: observation)
|
|
let corrected = perspectiveCorrectedImage(from: cgImage, observation: observation)
|
|
|
- continuation.resume(returning: corrected)
|
|
|
|
|
|
|
+ continuation.resume(returning: corrected ?? imageFromCGImage(cgImage))
|
|
|
}
|
|
}
|
|
|
request.minimumConfidence = 0.55
|
|
request.minimumConfidence = 0.55
|
|
|
request.maximumObservations = 1
|
|
request.maximumObservations = 1
|
|
@@ -381,6 +1073,16 @@ enum ScanDocumentProcessor {
|
|
|
NSImage.normalizedForDisplay(cgImage: cgImage)
|
|
NSImage.normalizedForDisplay(cgImage: cgImage)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Only deskew when the detected rectangle covers most of the frame (a photographed page).
|
|
|
|
|
+ private static func shouldCorrect(observation: VNRectangleObservation) -> Bool {
|
|
|
|
|
+ let box = observation.boundingBox
|
|
|
|
|
+ let area = box.width * box.height
|
|
|
|
|
+ guard area >= 0.85 else { return false }
|
|
|
|
|
+ guard box.minX <= 0.05, box.minY <= 0.05 else { return false }
|
|
|
|
|
+ guard box.maxX >= 0.95, box.maxY >= 0.95 else { return false }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private static func perspectiveCorrectedImage(from cgImage: CGImage, observation: VNRectangleObservation) -> NSImage? {
|
|
private static func perspectiveCorrectedImage(from cgImage: CGImage, observation: VNRectangleObservation) -> NSImage? {
|
|
|
let ciImage = CIImage(cgImage: cgImage)
|
|
let ciImage = CIImage(cgImage: cgImage)
|
|
|
let extent = ciImage.extent
|
|
let extent = ciImage.extent
|
|
@@ -478,7 +1180,7 @@ private extension ScanResolution {
|
|
|
final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
var onDismiss: (() -> Void)?
|
|
var onDismiss: (() -> Void)?
|
|
|
|
|
|
|
|
- private var pages: [NSImage]
|
|
|
|
|
|
|
+ private var pages: [ScanPage]
|
|
|
|
|
|
|
|
private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
|
private let titleLabel = NSTextField(labelWithString: "Scan File")
|
|
private let titleLabel = NSTextField(labelWithString: "Scan File")
|
|
@@ -489,14 +1191,14 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
private let savePDFButton = ScanFileSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
|
|
private let savePDFButton = ScanFileSecondaryButton(title: "Save as PDF", symbolName: "square.and.arrow.down")
|
|
|
private let printButton = ScanFileSecondaryButton(title: "Print", symbolName: "printer.fill")
|
|
private let printButton = ScanFileSecondaryButton(title: "Print", symbolName: "printer.fill")
|
|
|
|
|
|
|
|
- init(pages: [NSImage]) {
|
|
|
|
|
|
|
+ init(pages: [ScanPage]) {
|
|
|
self.pages = pages
|
|
self.pages = pages
|
|
|
super.init(frame: .zero)
|
|
super.init(frame: .zero)
|
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
translatesAutoresizingMaskIntoConstraints = false
|
|
|
setup()
|
|
setup()
|
|
|
refreshAppearance()
|
|
refreshAppearance()
|
|
|
updatePageCount()
|
|
updatePageCount()
|
|
|
- editorView.recognizeText(from: pages)
|
|
|
|
|
|
|
+ editorView.loadDocument(pages: pages)
|
|
|
NotificationCenter.default.addObserver(
|
|
NotificationCenter.default.addObserver(
|
|
|
self,
|
|
self,
|
|
|
selector: #selector(appearanceDidChange),
|
|
selector: #selector(appearanceDidChange),
|
|
@@ -637,28 +1339,28 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
|
|
|
])
|
|
])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func appendPages(_ newPages: [NSImage]) {
|
|
|
|
|
|
|
+ func appendPages(_ newPages: [ScanPage]) {
|
|
|
guard !newPages.isEmpty else { return }
|
|
guard !newPages.isEmpty else { return }
|
|
|
- editorView.appendAndRecognize(newPages, existingImages: pages)
|
|
|
|
|
|
|
+ editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
|
|
|
pages.append(contentsOf: newPages)
|
|
pages.append(contentsOf: newPages)
|
|
|
updatePageCount()
|
|
updatePageCount()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func updatePageCount() {
|
|
private func updatePageCount() {
|
|
|
if pages.count > 1 {
|
|
if pages.count > 1 {
|
|
|
- counterLabel.stringValue = "\(pages.count) pages — edit recognized text below"
|
|
|
|
|
|
|
+ counterLabel.stringValue = "\(pages.count) pages — picture on top, editable text below"
|
|
|
} else {
|
|
} else {
|
|
|
- counterLabel.stringValue = "Edit recognized text below"
|
|
|
|
|
|
|
+ counterLabel.stringValue = "Picture on top, editable text below"
|
|
|
}
|
|
}
|
|
|
counterLabel.isHidden = false
|
|
counterLabel.isHidden = false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func saveAsPDF() {
|
|
private func saveAsPDF() {
|
|
|
- PrintService.saveTextAsPDF(editorView.text, defaultName: "Scan", from: window)
|
|
|
|
|
|
|
+ PrintService.saveParsedScansAsPDF(pages: editorView.exportPages(), defaultName: "Scan", from: window)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func printScan() {
|
|
private func printScan() {
|
|
|
- PrintService.printText(editorView.text, title: "Scan", from: window)
|
|
|
|
|
|
|
+ PrintService.printParsedScans(pages: editorView.exportPages(), title: "Scan", from: window)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|