Bladeren bron

Improve Scan File layout and keep scanner sessions alive for repeat scans.

Parse scanned pages into picture, caption, and editable body text with two-column and centered heading support, and warm-reconnect to the scanner after each scan so users can keep scanning without repeated connection prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 maand geleden
bovenliggende
commit
bfbd3fd232

+ 33 - 15
smart_printer/OCRFileView.swift

@@ -73,21 +73,33 @@ enum OCRFileService {
         return try await recognizeText(in: cgImage)
     }
 
-    /// Recognizes text from multiple scan pages and joins them with blank lines between pages.
-    static func recognizeText(from images: [NSImage]) async -> String {
-        var pageTexts: [String] = []
+    /// Recognizes text from each image independently. Returns an empty string for pages with no text.
+    static func recognizeTextPages(from images: [NSImage]) async -> [String] {
+        var results: [String] = []
         for image in images {
             if let text = try? await recognizeText(from: image) {
-                let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
-                if !trimmed.isEmpty {
-                    pageTexts.append(trimmed)
-                }
+                results.append(text.trimmingCharacters(in: .whitespacesAndNewlines))
+            } else {
+                results.append("")
             }
         }
+        return results
+    }
+
+    /// Recognizes text from multiple scan pages and joins them with blank lines between pages.
+    static func recognizeText(from images: [NSImage]) async -> String {
+        let pageTexts = await recognizeTextPages(from: images).filter { !$0.isEmpty }
         return pageTexts.joined(separator: "\n\n")
     }
 
     static func recognizeText(in cgImage: CGImage) async throws -> String {
+        let observations = try await recognizeTextObservations(in: cgImage)
+        let text = textFromObservations(observations)
+        guard !text.isEmpty else { throw OCRError.noTextFound }
+        return text
+    }
+
+    static func recognizeTextObservations(in cgImage: CGImage) async throws -> [VNRecognizedTextObservation] {
         try await withCheckedThrowingContinuation { continuation in
             let request = VNRecognizeTextRequest { request, error in
                 if let error {
@@ -98,14 +110,7 @@ enum OCRFileService {
                     continuation.resume(throwing: OCRError.recognitionFailed)
                     return
                 }
-
-                let lines = observations.compactMap { $0.topCandidates(1).first?.string }
-                let text = lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
-                guard !text.isEmpty else {
-                    continuation.resume(throwing: OCRError.noTextFound)
-                    return
-                }
-                continuation.resume(returning: text)
+                continuation.resume(returning: observations)
             }
             request.recognitionLevel = .accurate
             request.usesLanguageCorrection = true
@@ -118,6 +123,19 @@ enum OCRFileService {
             }
         }
     }
+
+    static func textFromObservations(_ observations: [VNRecognizedTextObservation]) -> String {
+        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
+        }
+        return sorted
+            .compactMap { $0.topCandidates(1).first?.string }
+            .joined(separator: "\n")
+            .trimmingCharacters(in: .whitespacesAndNewlines)
+    }
 }
 
 // MARK: - Overlay

+ 292 - 0
smart_printer/PrintService.swift

@@ -71,6 +71,96 @@ enum PrintService {
         printPDF(document, title: title, from: window ?? NSApp.keyWindow)
     }
 
+    static func saveAttributedTextAsPDF(
+        _ attributed: NSAttributedString,
+        defaultName: String = "Scan",
+        from window: NSWindow? = nil
+    ) {
+        guard hasPrintableContent(attributed) else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: attributed) else {
+            showSaveFailedAlert()
+            return
+        }
+
+        let panel = NSSavePanel()
+        panel.title = "Save as PDF"
+        panel.message = "Choose where to save your document."
+        panel.prompt = "Save"
+        panel.nameFieldStringValue = "\(defaultName).pdf"
+        panel.allowedContentTypes = [.pdf]
+        panel.canCreateDirectories = true
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+        guard document.write(to: url) else {
+            showSaveFailedAlert()
+            return
+        }
+    }
+
+    static func printAttributedText(
+        _ attributed: NSAttributedString,
+        title: String = "Scan",
+        from window: NSWindow? = nil
+    ) {
+        guard hasPrintableContent(attributed) else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: attributed) else {
+            showPrintFailedAlert()
+            return
+        }
+        printPDF(document, title: title, from: window ?? NSApp.keyWindow)
+    }
+
+    static func saveParsedScansAsPDF(
+        pages: [ParsedScanPage],
+        defaultName: String = "Scan",
+        from window: NSWindow? = nil
+    ) {
+        guard !pages.isEmpty else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: pages) else {
+            showSaveFailedAlert()
+            return
+        }
+
+        let panel = NSSavePanel()
+        panel.title = "Save as PDF"
+        panel.message = "Choose where to save your document."
+        panel.prompt = "Save"
+        panel.nameFieldStringValue = "\(defaultName).pdf"
+        panel.allowedContentTypes = [.pdf]
+        panel.canCreateDirectories = true
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+        guard document.write(to: url) else {
+            showSaveFailedAlert()
+            return
+        }
+    }
+
+    static func printParsedScans(
+        pages: [ParsedScanPage],
+        title: String = "Scan",
+        from window: NSWindow? = nil
+    ) {
+        guard !pages.isEmpty else {
+            showEmptyTextAlert()
+            return
+        }
+        guard let document = pdfDocument(from: pages) else {
+            showPrintFailedAlert()
+            return
+        }
+        printPDF(document, title: title, from: window ?? NSApp.keyWindow)
+    }
+
     static func saveTextAsPDF(
         _ text: String,
         defaultName: String = "Scan",
@@ -286,6 +376,208 @@ enum PrintService {
         return printInfo
     }
 
+    private static func hasPrintableContent(_ attributed: NSAttributedString) -> Bool {
+        if !attributed.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+            return true
+        }
+        var hasAttachment = false
+        attributed.enumerateAttribute(.attachment, in: NSRange(location: 0, length: attributed.length)) { value, _, stop in
+            if value != nil {
+                hasAttachment = true
+                stop.pointee = true
+            }
+        }
+        return hasAttachment
+    }
+
+    private static let scanFigureFraction: CGFloat = 0.52
+
+    private static func pdfDocument(from pages: [ParsedScanPage]) -> PDFDocument? {
+        guard !pages.isEmpty else { return nil }
+
+        let printInfo = configuredPrintInfo()
+        let pageSize = printInfo.paperSize
+        guard pageSize.width > 0, pageSize.height > 0 else { return nil }
+
+        let contentWidth = pageSize.width - printInfo.leftMargin - printInfo.rightMargin
+        let contentHeight = pageSize.height - printInfo.topMargin - printInfo.bottomMargin
+
+        let pdfData = NSMutableData()
+        var mediaBox = CGRect(origin: .zero, size: pageSize)
+        guard let consumer = CGDataConsumer(data: pdfData as CFMutableData),
+              let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
+            return nil
+        }
+
+        for page in pages {
+            appendParsedScanPage(
+                page,
+                to: context,
+                pageSize: pageSize,
+                printInfo: printInfo,
+                contentWidth: contentWidth,
+                contentHeight: contentHeight
+            )
+        }
+
+        context.closePDF()
+        return PDFDocument(data: pdfData as Data)
+    }
+
+    private static func appendParsedScanPage(
+        _ page: ParsedScanPage,
+        to context: CGContext,
+        pageSize: NSSize,
+        printInfo: NSPrintInfo,
+        contentWidth: CGFloat,
+        contentHeight: CGFloat
+    ) {
+        let bodyText = page.body.plainText.trimmingCharacters(in: .whitespacesAndNewlines)
+        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 imageHeight = contentHeight * imageFraction
+        let captionHeight = contentHeight * captionFraction
+        let textHeight = contentHeight * textFraction
+
+        context.beginPDFPage(nil)
+
+        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 hasCaption {
+            cursorY = printInfo.bottomMargin + textHeight
+            let captionRect = CGRect(
+                x: printInfo.leftMargin,
+                y: cursorY,
+                width: contentWidth,
+                height: captionHeight
+            )
+            drawScanText(captionText, in: captionRect, context: context, pageSize: pageSize, alignment: .center)
+        }
+
+        if hasBody {
+            switch page.body {
+            case .single(let text):
+                let textRect = CGRect(
+                    x: printInfo.leftMargin,
+                    y: printInfo.bottomMargin,
+                    width: contentWidth,
+                    height: textHeight
+                )
+                drawScanAttributedText(text, in: textRect, context: context, pageSize: pageSize)
+            case .twoColumn(let left, let right):
+                let columnGap: CGFloat = 14
+                let columnWidth = (contentWidth - columnGap) / 2
+                let leftRect = CGRect(
+                    x: printInfo.leftMargin,
+                    y: printInfo.bottomMargin,
+                    width: columnWidth,
+                    height: textHeight
+                )
+                let rightRect = CGRect(
+                    x: printInfo.leftMargin + columnWidth + columnGap,
+                    y: printInfo.bottomMargin,
+                    width: columnWidth,
+                    height: textHeight
+                )
+                drawScanAttributedText(left, in: leftRect, context: context, pageSize: pageSize)
+                drawScanAttributedText(right, in: rightRect, context: context, pageSize: pageSize)
+            }
+        }
+
+        context.endPDFPage()
+    }
+
+    private static func drawScanImage(_ image: NSImage, in rect: CGRect, context: CGContext) {
+        var proposedRect = NSRect(origin: .zero, size: image.size)
+        guard image.size.width > 0, image.size.height > 0,
+              let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else { return }
+
+        let scale = min(rect.width / image.size.width, rect.height / image.size.height)
+        let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
+        let drawRect = CGRect(
+            x: rect.midX - drawSize.width / 2,
+            y: rect.midY - drawSize.height / 2,
+            width: drawSize.width,
+            height: drawSize.height
+        )
+        context.draw(cgImage, in: drawRect)
+    }
+
+    private static func drawScanText(
+        _ text: String,
+        in rect: CGRect,
+        context: CGContext,
+        pageSize: NSSize,
+        alignment: NSTextAlignment = .left
+    ) {
+        guard !text.isEmpty else { return }
+        let paragraphStyle = NSMutableParagraphStyle()
+        paragraphStyle.alignment = alignment
+        paragraphStyle.lineBreakMode = .byWordWrapping
+        let attributes: [NSAttributedString.Key: Any] = [
+            .font: NSFont.systemFont(ofSize: 11),
+            .foregroundColor: NSColor.black,
+            .paragraphStyle: paragraphStyle,
+        ]
+        drawScanAttributedText(
+            NSAttributedString(string: text, attributes: attributes),
+            in: rect,
+            context: context,
+            pageSize: pageSize
+        )
+    }
+
+    private static func drawScanAttributedText(
+        _ attributed: NSAttributedString,
+        in rect: CGRect,
+        context: CGContext,
+        pageSize: NSSize
+    ) {
+        guard attributed.length > 0 else { return }
+        let normalized = scanAttributedStringForPrinting(attributed)
+
+        context.saveGState()
+        context.translateBy(x: 0, y: pageSize.height)
+        context.scaleBy(x: 1, y: -1)
+
+        let flippedY = pageSize.height - rect.maxY
+        let drawRect = CGRect(x: rect.origin.x, y: flippedY, width: rect.width, height: rect.height)
+
+        NSGraphicsContext.saveGraphicsState()
+        NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
+        normalized.draw(with: drawRect, options: [.usesLineFragmentOrigin, .usesFontLeading])
+        NSGraphicsContext.restoreGraphicsState()
+        context.restoreGState()
+    }
+
+    private static func scanAttributedStringForPrinting(_ source: NSAttributedString) -> NSAttributedString {
+        let mutable = NSMutableAttributedString(attributedString: source)
+        let fullRange = NSRange(location: 0, length: mutable.length)
+        guard fullRange.length > 0 else { return mutable }
+
+        mutable.addAttribute(.foregroundColor, value: NSColor.black, range: fullRange)
+        mutable.enumerateAttribute(.font, in: fullRange) { value, range, _ in
+            let font = (value as? NSFont) ?? NSFont.systemFont(ofSize: 11)
+            let scaled = NSFontManager.shared.convert(font, toSize: 11)
+            mutable.addAttribute(.font, value: scaled, range: range)
+        }
+        return mutable
+    }
+
     private static func pdfDocument(from text: String) -> PDFDocument? {
         let paragraphStyle = NSMutableParagraphStyle()
         paragraphStyle.alignment = .left

+ 776 - 74
smart_printer/ScanFileView.swift

@@ -38,24 +38,24 @@ enum ScanFileService {
             switch source {
             case .scanner:
                 startScannerScan(from: hostWindow)
-            case .importImage:
-                importImage(from: hostWindow)
+            case .importFile:
+                importFile(from: hostWindow)
             }
         }
     }
 
     private enum ScanSource {
         case scanner
-        case importImage
+        case importFile
     }
 
     private static func showSourcePicker(from window: NSWindow?, completion: @escaping (ScanSource?) -> Void) {
         let alert = NSAlert()
         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.addButton(withTitle: "Use Scanner")
-        alert.addButton(withTitle: "Import Image")
+        alert.addButton(withTitle: "Import File")
         alert.addButton(withTitle: "Cancel")
 
         let handler: (NSApplication.ModalResponse) -> Void = { response in
@@ -63,7 +63,7 @@ enum ScanFileService {
             case .alertFirstButtonReturn:
                 completion(.scanner)
             case .alertSecondButtonReturn:
-                completion(.importImage)
+                completion(.importFile)
             default:
                 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()
-        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.canChooseDirectories = false
         panel.canChooseFiles = true
         panel.allowsMultipleSelection = false
         panel.allowsOtherFileTypes = false
         panel.allowedContentTypes = [
-            .jpeg, .png, .heic, .heif, .tiff, .bmp, .gif, .webP,
+            .jpeg, .png, .heic, .heif, .tiff, .bmp, .gif, .webP, .pdf,
         ].compactMap { $0 }
         panel.directoryURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask).first
 
@@ -126,11 +126,25 @@ enum ScanFileService {
             }
             Task { @MainActor in
                 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 {
-                        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 {
                     if let completion {
@@ -180,7 +194,32 @@ enum ScanFileService {
         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 let viewController = mainViewController(from: window) else {
             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
 
-/// 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 {
     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 statusLabel = NSTextField(labelWithString: "Extracting text…")
+    private let statusLabel = NSTextField(labelWithString: "Preparing document…")
 
     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) {
@@ -240,52 +762,106 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
 
     func refreshAppearance() {
         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?()
+            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
-            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?()
         }
     }
 
+    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() {
-        window?.makeFirstResponder(textView)
+        let target = columnRow.isHidden ? singleColumnText : leftColumnText
+        window?.makeFirstResponder(target)
     }
 
     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.controlSize = .regular
@@ -297,15 +873,37 @@ final class EditableScanContentView: NSView, AppearanceRefreshable {
         statusLabel.maximumNumberOfLines = 3
         statusLabel.translatesAutoresizingMaskIntoConstraints = false
 
-        addSubview(textScrollView)
+        addSubview(contentScrollView)
         addSubview(loadingIndicator)
         addSubview(statusLabel)
 
+        pictureHeightConstraint = pictureView.heightAnchor.constraint(equalToConstant: 200)
+
         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.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) {
         statusLabel.stringValue = message
         statusLabel.isHidden = false
         loadingIndicator.isHidden = false
         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.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? {
         await withCheckedContinuation { continuation 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))
                     return
                 }
                 let corrected = perspectiveCorrectedImage(from: cgImage, observation: observation)
-                continuation.resume(returning: corrected)
+                continuation.resume(returning: corrected ?? imageFromCGImage(cgImage))
             }
             request.minimumConfidence = 0.55
             request.maximumObservations = 1
@@ -381,6 +1073,16 @@ enum ScanDocumentProcessor {
         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? {
         let ciImage = CIImage(cgImage: cgImage)
         let extent = ciImage.extent
@@ -478,7 +1180,7 @@ private extension ScanResolution {
 final class ScanFileOverlayView: NSView, AppearanceRefreshable {
     var onDismiss: (() -> Void)?
 
-    private var pages: [NSImage]
+    private var pages: [ScanPage]
 
     private let backButton = ScanFileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
     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 printButton = ScanFileSecondaryButton(title: "Print", symbolName: "printer.fill")
 
-    init(pages: [NSImage]) {
+    init(pages: [ScanPage]) {
         self.pages = pages
         super.init(frame: .zero)
         translatesAutoresizingMaskIntoConstraints = false
         setup()
         refreshAppearance()
         updatePageCount()
-        editorView.recognizeText(from: pages)
+        editorView.loadDocument(pages: pages)
         NotificationCenter.default.addObserver(
             self,
             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 }
-        editorView.appendAndRecognize(newPages, existingImages: pages)
+        editorView.appendAndRecognize(newPages.map(\.image), existingPages: pages)
         pages.append(contentsOf: newPages)
         updatePageCount()
     }
 
     private func updatePageCount() {
         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 {
-            counterLabel.stringValue = "Edit recognized text below"
+            counterLabel.stringValue = "Picture on top, editable text below"
         }
         counterLabel.isHidden = false
     }
 
     private func saveAsPDF() {
-        PrintService.saveTextAsPDF(editorView.text, defaultName: "Scan", from: window)
+        PrintService.saveParsedScansAsPDF(pages: editorView.exportPages(), defaultName: "Scan", from: window)
     }
 
     private func printScan() {
-        PrintService.printText(editorView.text, title: "Scan", from: window)
+        PrintService.printParsedScans(pages: editorView.exportPages(), title: "Scan", from: window)
     }
 }
 

+ 21 - 11
smart_printer/ScannerOverlayView.swift

@@ -533,11 +533,17 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
             progressIndicator.stopAnimation(nil)
             scanButton.isEnabled = scanner.selectedDevice != nil
         case .connecting:
-            statusLabel.stringValue = "Connecting to scanner…"
-            progressIndicator.startAnimation(nil)
-            scanButton.isEnabled = false
+            if scanner.isWarmReconnecting {
+                statusLabel.stringValue = hasResult ? "Ready for next scan" : "Preparing scanner…"
+                progressIndicator.stopAnimation(nil)
+                scanButton.isEnabled = scanner.selectedDevice != nil
+            } else {
+                statusLabel.stringValue = "Connecting to scanner…"
+                progressIndicator.startAnimation(nil)
+                scanButton.isEnabled = false
+            }
         case .ready:
-            statusLabel.stringValue = "Ready to scan"
+            statusLabel.stringValue = hasResult ? "Ready for next scan" : "Ready to scan"
             progressIndicator.stopAnimation(nil)
             scanButton.isEnabled = true
             if pendingScan {
@@ -584,9 +590,10 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         editorView.isHidden = false
         resultEditorView.isHidden = false
         setPreviewActionsVisible(true)
-        editorView.recognizeText(from: scannedPages)
-        resultEditorView.recognizeText(from: scannedPages)
-        statusLabel.stringValue = "Edit recognized text, then save or print"
+        let pages = scannedPages.map { (image: $0, text: "") }
+        editorView.loadDocument(pages: pages)
+        resultEditorView.loadDocument(pages: pages)
+        statusLabel.stringValue = "Ready for next scan — edit text below, then scan again or save"
         previewContainer.layoutSubtreeIfNeeded()
         resultPreviewContainer.layoutSubtreeIfNeeded()
     }
@@ -596,6 +603,7 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         switch result {
         case .success(let image):
             processScannedImage(image)
+            updateStateUI(scanner.state)
             onScanComplete?(image)
         case .failure(let error):
             if case ScanError.cancelled = error { return }
@@ -604,14 +612,16 @@ final class ScannerOverlayView: NSView, AppearanceRefreshable {
         }
     }
 
+    private func activeEditor() -> EditableScanContentView {
+        editorView.isHidden ? resultEditorView : editorView
+    }
+
     private func saveAsPDF() {
-        let text = editorView.isHidden ? resultEditorView.text : editorView.text
-        PrintService.saveTextAsPDF(text, defaultName: "Scan", from: window)
+        PrintService.saveParsedScansAsPDF(pages: activeEditor().exportPages(), defaultName: "Scan", from: window)
     }
 
     private func printScan() {
-        let text = editorView.isHidden ? resultEditorView.text : editorView.text
-        PrintService.printText(text, title: "Scan", from: window)
+        PrintService.printParsedScans(pages: activeEditor().exportPages(), title: "Scan", from: window)
     }
 
     @objc private func paperSizeChanged() {

+ 68 - 18
smart_printer/ScannerService.swift

@@ -48,6 +48,8 @@ final class ScannerService: NSObject {
     private var accumulatedBands: [ICScannerBandData] = []
     private var isClosingSession = false
     private var isOpeningSession = false
+    private var isMaintainingSession = false
+    private var hasEstablishedSession = false
     private var lastSelectedDeviceName: String?
     private var pendingPreferredDeviceName: String?
     private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
@@ -75,19 +77,35 @@ final class ScannerService: NSObject {
         scannedImageURL = nil
         accumulatedBands = []
 
-        if let device = selectedDevice, device.hasOpenSession {
+        if let device = selectedDevice {
             device.delegate = self
-            state = .ready
-            ensureBrowsing()
-            return
+            if device.hasOpenSession {
+                state = .ready
+                ensureBrowsing()
+                return
+            }
+            if hasEstablishedSession {
+                state = .ready
+                ensureBrowsing()
+                maintainSession(for: device)
+                return
+            }
         }
 
-        if let device = deviceMatchingLastSelection(), device.hasOpenSession {
+        if let device = deviceMatchingLastSelection() {
             selectedDevice = device
             device.delegate = self
-            state = .ready
-            ensureBrowsing()
-            return
+            if device.hasOpenSession {
+                state = .ready
+                ensureBrowsing()
+                return
+            }
+            if hasEstablishedSession {
+                state = .ready
+                ensureBrowsing()
+                maintainSession(for: device)
+                return
+            }
         }
 
         pendingPreferredDeviceName = lastSelectedDeviceName
@@ -118,6 +136,11 @@ final class ScannerService: NSObject {
         resetAfterFullShutdown()
     }
 
+    /// True when reconnecting after a previous successful scan in this session.
+    var isWarmReconnecting: Bool {
+        isMaintainingSession || (isOpeningSession && hasEstablishedSession)
+    }
+
     func selectDevice(_ device: ICScannerDevice) {
         let name = device.name ?? device.locationDescription
         if let name { lastSelectedDeviceName = name }
@@ -130,7 +153,7 @@ final class ScannerService: NSObject {
             } else if case .connecting = state {
                 return
             } else {
-                Task { await openSession(on: device) }
+                Task { await openSession(on: device, warm: hasEstablishedSession) }
             }
             return
         }
@@ -141,7 +164,7 @@ final class ScannerService: NSObject {
             device.delegate = self
             state = .ready
         } else {
-            Task { await openSession(on: device) }
+            Task { await openSession(on: device, warm: hasEstablishedSession) }
         }
     }
 
@@ -164,7 +187,7 @@ final class ScannerService: NSObject {
             device.requestScan()
         } else {
             shouldScanWhenReady = true
-            Task { await openSession(on: device) }
+            Task { await openSession(on: device, warm: hasEstablishedSession) }
         }
     }
 
@@ -194,6 +217,8 @@ final class ScannerService: NSObject {
     private func resetAfterFullShutdown() {
         isClosingSession = false
         isOpeningSession = false
+        isMaintainingSession = false
+        hasEstablishedSession = false
         pendingPreferredDeviceName = nil
         selectedDevice = nil
         localDevices = []
@@ -244,7 +269,17 @@ final class ScannerService: NSObject {
         continuation.resume()
     }
 
-    private func openSession(on device: ICScannerDevice) async {
+    private func maintainSession(for device: ICScannerDevice) {
+        guard selectedDevice === device, !device.hasOpenSession else { return }
+        guard !isOpeningSession, !isMaintainingSession, !isClosingSession else { return }
+        isMaintainingSession = true
+        Task {
+            await openSession(on: device, warm: true)
+            isMaintainingSession = false
+        }
+    }
+
+    private func openSession(on device: ICScannerDevice, warm: Bool = false) async {
         guard selectedDevice === device else { return }
 
         clearConnectionTimeout()
@@ -263,7 +298,7 @@ final class ScannerService: NSObject {
         }
 
         isOpeningSession = true
-        state = .connecting
+        state = (warm && hasEstablishedSession) ? .ready : .connecting
         scheduleConnectionTimeout(for: device)
 
         do {
@@ -288,6 +323,7 @@ final class ScannerService: NSObject {
         }
 
         isClosingSession = false
+        hasEstablishedSession = true
         scanner.delegate = self
         if shouldScanWhenReady {
             shouldScanWhenReady = false
@@ -302,10 +338,18 @@ final class ScannerService: NSObject {
     private func processSessionClosed(on device: ICDevice, error: Error?) {
         let wasClosing = isClosingSession
         resumeSessionCloseWait()
-        guard wasClosing else { return }
-        if selectedDevice === device, state != .idle {
-            state = .browsing
+
+        guard let scanner = device as? ICScannerDevice,
+              selectedDevice === scanner else { return }
+
+        if wasClosing {
+            if state != .idle { state = .browsing }
+            return
         }
+
+        // Many scanners close the session after each scan — reopen quietly for the next one.
+        guard state != .idle else { return }
+        maintainSession(for: scanner)
     }
 
     /// Keeps the scanner session open after each scan for the next one.
@@ -318,9 +362,14 @@ final class ScannerService: NSObject {
         shouldScanWhenReady = false
         isOpeningSession = false
 
-        if let device = selectedDevice, device.hasOpenSession {
+        if let device = selectedDevice {
             device.delegate = self
-            state = .ready
+            if device.hasOpenSession {
+                state = .ready
+            } else {
+                state = .ready
+                maintainSession(for: device)
+            }
         }
     }
 
@@ -547,6 +596,7 @@ extension ScannerService: ICDeviceDelegate, ICScannerDeviceDelegate {
             clearConnectionTimeout()
             isOpeningSession = false
             isClosingSession = false
+            hasEstablishedSession = true
             scanner.delegate = self
             if shouldScanWhenReady {
                 shouldScanWhenReady = false

+ 18 - 4
smart_printer/ViewController.swift

@@ -107,9 +107,26 @@ class ViewController: NSViewController {
 
         scannerDidComplete = false
 
+        if let existing = scannerOverlay, existing.superview != nil {
+            configureScannerOverlay(existing, showScanPreview: showScanPreview, onComplete: onComplete)
+            Task { await existing.activate() }
+            return
+        }
+
         dismissScannerOverlay()
 
         let overlay = ScannerOverlayView()
+        configureScannerOverlay(overlay, showScanPreview: showScanPreview, onComplete: onComplete)
+        scannerOverlay = overlay
+        overlay.present(in: mainContentView)
+        Task { await overlay.activate() }
+    }
+
+    private func configureScannerOverlay(
+        _ overlay: ScannerOverlayView,
+        showScanPreview: Bool,
+        onComplete: @escaping (Result<NSImage, Error>) -> Void
+    ) {
         overlay.onDismiss = { [weak self] in
             guard let self else { return }
             let cancelled = !self.scannerDidComplete && self.scannerOverlay?.hasResult != true
@@ -137,9 +154,6 @@ class ViewController: NSViewController {
                 }
             }
         }
-        scannerOverlay = overlay
-        overlay.present(in: mainContentView)
-        Task { await overlay.activate() }
     }
 
     func presentPrintText() {
@@ -201,7 +215,7 @@ class ViewController: NSViewController {
         overlay.present(in: mainContentView)
     }
 
-    func presentScanFile(pages: [NSImage]) {
+    func presentScanFile(pages: [ScanPage]) {
         photoPreviewOverlay?.dismiss(animated: false)
         filePreviewOverlay?.dismiss(animated: false)
         printTextOverlay?.dismiss(animated: false)