Bladeren bron

Show editable OCR text only for text scans, keeping images for embedded photos.

Plain text documents no longer display a duplicate scan preview above the editor; embedded photos with captions still show the image, and print/export skip the scan image when appropriate.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 maand geleden
bovenliggende
commit
86d68422ef
3 gewijzigde bestanden met toevoegingen van 137 en 70 verwijderingen
  1. 23 11
      smart_printer/PrintService.swift
  2. 113 58
      smart_printer/ScanFileView.swift
  3. 1 1
      smart_printer/ScannerOverlayView.swift

+ 23 - 11
smart_printer/PrintService.swift

@@ -453,10 +453,20 @@ enum PrintService {
         let captionText = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
         let hasCaption = !captionText.isEmpty
         let hasBody = !bodyText.isEmpty
-
-        let imageFraction: CGFloat = hasBody || hasCaption ? 0.48 : 1.0
-        let captionFraction: CGFloat = hasCaption ? 0.05 : 0
-        let textFraction: CGFloat = hasBody ? max(0, 1 - imageFraction - captionFraction) : 0
+        let showsImage = page.showsPicture
+
+        let imageFraction: CGFloat
+        let captionFraction: CGFloat
+        let textFraction: CGFloat
+        if showsImage {
+            imageFraction = hasBody || hasCaption ? 0.48 : 1.0
+            captionFraction = hasCaption ? 0.05 : 0
+            textFraction = hasBody ? max(0, 1 - imageFraction - captionFraction) : 0
+        } else {
+            imageFraction = 0
+            captionFraction = hasCaption ? 0.05 : 0
+            textFraction = hasBody ? max(0, 1 - captionFraction) : 0
+        }
 
         let imageHeight = contentHeight * imageFraction
         let captionHeight = contentHeight * captionFraction
@@ -466,13 +476,15 @@ enum PrintService {
 
         var cursorY = printInfo.bottomMargin + captionHeight + textHeight
 
-        let imageRect = CGRect(
-            x: printInfo.leftMargin,
-            y: cursorY,
-            width: contentWidth,
-            height: imageHeight
-        )
-        drawScanImage(page.picture, in: imageRect, context: context)
+        if showsImage {
+            let imageRect = CGRect(
+                x: printInfo.leftMargin,
+                y: cursorY,
+                width: contentWidth,
+                height: imageHeight
+            )
+            drawScanImage(page.picture, in: imageRect, context: context)
+        }
 
         if hasCaption {
             cursorY = printInfo.bottomMargin + textHeight

+ 113 - 58
smart_printer/ScanFileView.swift

@@ -272,20 +272,27 @@ struct ParsedScanPage {
     let picture: NSImage
     let caption: String?
     let body: ScanBodyLayout
+    /// When false, the page is text-only — show editable OCR text without a scan preview image.
+    var showsPicture: Bool
 }
 
 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.
+    /// Recognizes text from a scanned page. Embedded photos with captions keep a picture preview;
+    /// plain text documents show only editable OCR text.
     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()))
+            return ParsedScanPage(
+                picture: image.normalizedForDisplay(),
+                caption: nil,
+                body: .single(NSAttributedString()),
+                showsPicture: false
+            )
         }
 
         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) {
@@ -297,28 +304,44 @@ enum ScanDocumentBuilder {
                 from: bodyObservations,
                 excludingCaption: split.captionText
             )
-            return ParsedScanPage(picture: cropped, caption: split.captionText, body: body)
+            return ParsedScanPage(
+                picture: cropped,
+                caption: split.captionText,
+                body: body,
+                showsPicture: true
+            )
         }
 
-        if let region = await resolvePictureRegion(in: cgImage, observations: observations),
+        if let region = await detectEmbeddedPictureRegion(in: cgImage),
            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: cropped, caption: nil, body: body, showsPicture: true)
         }
 
         return ParsedScanPage(
             picture: image.normalizedForDisplay(),
             caption: nil,
-            body: .single(layoutAttributedText(from: observations))
+            body: .single(layoutAttributedText(from: observations)),
+            showsPicture: false
         )
     }
 
     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(NSAttributedString()),
+                showsPicture: false
+            )
         }
-        return ParsedScanPage(picture: page.image, caption: nil, body: .single(plainBodyText(page.text)))
+        return ParsedScanPage(
+            picture: page.image,
+            caption: nil,
+            body: .single(plainBodyText(page.text)),
+            showsPicture: false
+        )
     }
 
     private static func plainBodyText(_ text: String) -> NSAttributedString {
@@ -395,38 +418,11 @@ enum ScanDocumentBuilder {
         )
     }
 
-    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 detectEmbeddedPictureRegion(in cgImage: CGImage) async -> CGRect? {
+        guard let rectangle = await detectEmbeddedPicture(in: cgImage) else { return nil }
+        let area = rectangle.width * rectangle.height
+        guard area >= 0.08, area <= 0.52 else { return nil }
+        return rectangle
     }
 
     private static func parseBodyLayout(
@@ -603,10 +599,12 @@ enum ScanDocumentBuilder {
                 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 page.showsPicture {
+                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()
@@ -814,7 +812,7 @@ private final class ScanPageThumbnailView: NSControl, AppearanceRefreshable {
 
 // MARK: - Editable Scan Content
 
-/// Picture on top, editable text below — matching the original document layout.
+/// Editable OCR text for scanned documents; embedded photos with captions keep a picture preview.
 final class EditableScanContentView: NSView, AppearanceRefreshable {
     var onRecognitionComplete: (() -> Void)?
 
@@ -842,6 +840,8 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
     private let rightColumnScroll = NSScrollView()
     private let rightColumnText = NSTextView()
     private var pictureHeightConstraint: NSLayoutConstraint!
+    private var singleColumnMinHeightConstraint: NSLayoutConstraint!
+    private var documentStackTopConstraint: NSLayoutConstraint!
     private let loadingIndicator = NSProgressIndicator()
     private let statusLabel = NSTextField(labelWithString: "Preparing document…")
     private let pageSelectorContainer = NSView()
@@ -983,7 +983,8 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             ParsedScanPage(
                 picture: state.fullImage,
                 caption: state.parsed.caption,
-                body: state.parsed.body
+                body: state.parsed.body,
+                showsPicture: state.parsed.showsPicture
             )
         }
     }
@@ -995,7 +996,8 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             return ParsedScanPage(
                 picture: state.fullImage,
                 caption: state.parsed.caption,
-                body: state.parsed.body
+                body: state.parsed.body,
+                showsPicture: state.parsed.showsPicture
             )
         }
     }
@@ -1128,7 +1130,6 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
 
             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),
 
@@ -1140,7 +1141,6 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             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),
@@ -1153,6 +1153,17 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             statusLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
             statusLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
         ])
+
+        documentStackTopConstraint = documentStack.topAnchor.constraint(
+            equalTo: documentContainer.topAnchor,
+            constant: 8
+        )
+        documentStackTopConstraint.isActive = true
+
+        singleColumnMinHeightConstraint = singleColumnScroll.heightAnchor.constraint(
+            greaterThanOrEqualToConstant: 220
+        )
+        singleColumnMinHeightConstraint.isActive = true
     }
 
     private func configureTextView(
@@ -1219,10 +1230,16 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         statusLabel.isHidden = true
         contentScrollView.isHidden = false
 
-        pictureView.image = page.picture
-        updatePictureHeight(for: page.picture)
+        pictureView.isHidden = !page.showsPicture
+        if page.showsPicture {
+            pictureView.image = page.picture
+        } else {
+            pictureView.image = nil
+        }
 
-        if let caption = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines), !caption.isEmpty {
+        if page.showsPicture,
+           let caption = page.caption?.trimmingCharacters(in: .whitespacesAndNewlines),
+           !caption.isEmpty {
             captionText.string = caption
             captionScroll.isHidden = false
         } else {
@@ -1242,9 +1259,40 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
             rightColumnText.textStorage?.setAttributedString(right)
         }
 
+        updateContentLayout(for: page)
         refreshThumbnailSelection()
     }
 
+    private func updateContentLayout(for page: ParsedScanPage) {
+        let textOnly = !page.showsPicture
+
+        pictureView.isHidden = textOnly
+        if textOnly {
+            pictureView.image = nil
+            pictureHeightConstraint.constant = 0
+        } else {
+            updatePictureHeight(for: page.picture)
+        }
+
+        documentStackTopConstraint.constant = textOnly ? 0 : 8
+        documentStack.spacing = textOnly ? 0 : 16
+        singleColumnScroll.hasVerticalScroller = !textOnly
+
+        let viewportHeight = contentScrollView.contentView.bounds.height
+        singleColumnMinHeightConstraint.constant = textOnly
+            ? max(viewportHeight - 8, 220)
+            : 220
+
+        if textOnly {
+            scrollContentToTop()
+        }
+    }
+
+    private func scrollContentToTop() {
+        contentScrollView.contentView.scroll(to: NSPoint(x: 0, y: 0))
+        singleColumnScroll.contentView.scroll(to: NSPoint(x: 0, y: 0))
+    }
+
     private func commitCurrentPageEdits() {
         guard pageStates.indices.contains(currentPageIndex) else { return }
 
@@ -1258,7 +1306,8 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         pageStates[currentPageIndex].parsed = ParsedScanPage(
             picture: pageStates[currentPageIndex].parsed.picture,
             caption: caption?.isEmpty == false ? caption : nil,
-            body: body
+            body: body,
+            showsPicture: pageStates[currentPageIndex].parsed.showsPicture
         )
         pageStates[currentPageIndex].isSelectedForPrint = printSelections.indices.contains(currentPageIndex)
             ? printSelections[currentPageIndex]
@@ -1279,7 +1328,7 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         pageSelectorLabel.stringValue = "\(pageStates.count) documents — \(selectedCount) selected for print"
 
         for (index, state) in pageStates.enumerated() {
-            let thumbnail = ScanPageThumbnailView(image: state.parsed.picture, index: index)
+            let thumbnail = ScanPageThumbnailView(image: state.fullImage, index: index)
             thumbnail.isCurrentPage = index == currentPageIndex
             thumbnail.isSelectedForPrint = printSelections.indices.contains(index) ? printSelections[index] : true
             thumbnail.onSelectPage = { [weak self] pageIndex in
@@ -1321,8 +1370,14 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
 
     override func layout() {
         super.layout()
-        if let image = pictureView.image {
-            updatePictureHeight(for: image)
+        if pageStates.indices.contains(currentPageIndex) {
+            let page = pageStates[currentPageIndex].parsed
+            if page.showsPicture, let image = pictureView.image {
+                updatePictureHeight(for: image)
+            } else if !page.showsPicture {
+                let viewportHeight = contentScrollView.contentView.bounds.height
+                singleColumnMinHeightConstraint.constant = max(viewportHeight - 8, 220)
+            }
         }
         updateTextWidths()
     }
@@ -1680,7 +1735,7 @@ final class ScanFileOverlayView: NSView, AppearanceRefreshable {
         if pages.count > 1 {
             counterLabel.stringValue = "\(pages.count) documents — select pages below, then print or save"
         } else {
-            counterLabel.stringValue = "Picture on top, editable text below"
+            counterLabel.stringValue = "Edit recognized text below"
         }
         counterLabel.isHidden = false
     }

+ 1 - 1
smart_printer/ScannerOverlayView.swift

@@ -686,7 +686,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         }
         if !scannedPages.isEmpty {
             return scannedPages.map {
-                ParsedScanPage(picture: $0, caption: nil, body: .single(NSAttributedString()))
+                ParsedScanPage(picture: $0, caption: nil, body: .single(NSAttributedString()), showsPicture: false)
             }
         }
         return []