|
|
@@ -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
|
|
|
}
|