From 58a38db16a9936fc982f4d0ba7fb72e2cd1c19ce Mon Sep 17 00:00:00 2001 From: luw2007 Date: Thu, 10 Sep 2026 20:52:53 +0800 Subject: [PATCH 1/3] Add Monospace output preset with bounding-box-based alignment Add a new .monospace CaptureOutputPreset that uses OCR block boundingBox positions to produce monospace-aligned text: - y-clustering groups blocks into lines - x-sorting orders blocks within each line - x-differences convert to spaces to preserve horizontal position - y-differences convert to blank lines using median line height Interface changes: - CaptureOutputFormatter.format() / clipboardPayload() accept optional OCRResult parameter - CaptureOutputWriter.copyCapturedText accepts optional OCRResult - CaptureCoordinator protocol and implementations pass OCRResult through - Main capture flow and watch mode pass the actual OCRResult - History repaste / menu bar / table review pass nil (no block data) UI: - Monospace appears automatically in output preset menus (allCases) - Menu bar icon: textformat - Localized titles in Turkish and English Tests: - OCRResultTests: 8 new tests covering horizontal alignment, multi-line, blank line insertion, empty/single block, x-sorting, explicit columns - CaptureOutputFormatterTests: 2 new tests for monospace preset with and without OCRResult --- ScreenTextGrab/Models/AppModels.swift | 9 ++ .../Models/CaptureOutputFormatter.swift | 11 +- ScreenTextGrab/Models/OCRResult.swift | 119 ++++++++++++++++++ .../Services/CaptureCoordinator.swift | 14 ++- .../Services/CaptureOutputWriter.swift | 4 +- ScreenTextGrab/Views/MenuBarView.swift | 5 +- ScreenTextGrab/Views/SettingsView.swift | 3 +- ScreenTextGrab/Views/TableReviewView.swift | 3 +- .../CaptureOutputFormatterTests.swift | 34 +++++ ScreenTextGrabTests/OCRResultTests.swift | 106 ++++++++++++++++ 10 files changed, 297 insertions(+), 11 deletions(-) diff --git a/ScreenTextGrab/Models/AppModels.swift b/ScreenTextGrab/Models/AppModels.swift index 2b2638e..1c05dd5 100644 --- a/ScreenTextGrab/Models/AppModels.swift +++ b/ScreenTextGrab/Models/AppModels.swift @@ -233,6 +233,7 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id case office case markdown case json + case monospace var id: String { rawValue } @@ -250,6 +251,8 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return "Markdown" case .json: return "JSON" + case .monospace: + return L10n.pair("Eş Aralıklı", "Monospace") } } @@ -267,6 +270,8 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return "MD" case .json: return "JSON" + case .monospace: + return L10n.pair("Mono", "Mono") } } @@ -284,6 +289,8 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return L10n.pair("Kod ve tablo için hazır", "Ready for code and tables") case .json: return L10n.pair("Otomasyon için yapılandırılmış", "Structured for automation") + case .monospace: + return L10n.pair("Konumu boşluklarla korur", "Preserves layout with spaces") } } @@ -301,6 +308,8 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return L10n.pair("Kod ve tablo gibi içerikleri Markdown uyumlu biçimde hazırlar.", "Formats content like code and tables for Markdown.") case .json: return L10n.pair("Otomasyon ve entegrasyonlar için yapılandırılmış çıktı üretir.", "Produces structured output for automation and integrations.") + case .monospace: + return L10n.pair("Metin bloklarının ekran konumını kullanarak eş aralıklı hizalama üretir; terminal ve tablo görünümleri için ideal.", "Uses block positions to produce monospace-aligned text; ideal for terminal and table layouts.") } } } diff --git a/ScreenTextGrab/Models/CaptureOutputFormatter.swift b/ScreenTextGrab/Models/CaptureOutputFormatter.swift index ed2cec7..5c44a8e 100644 --- a/ScreenTextGrab/Models/CaptureOutputFormatter.swift +++ b/ScreenTextGrab/Models/CaptureOutputFormatter.swift @@ -6,7 +6,8 @@ enum CaptureOutputFormatter { captureMode: CaptureMode, contentKind: ClipboardHistoryEntry.ContentKind, preset: CaptureOutputPreset, - source: ClipboardHistoryEntry.SourceContext? = nil + source: ClipboardHistoryEntry.SourceContext? = nil, + ocrResult: OCRResult? = nil ) -> String { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -31,6 +32,8 @@ enum CaptureOutputFormatter { contentKind: contentKind, source: source ) + case .monospace: + return ocrResult?.monospaceAlignedText() ?? trimmed } } @@ -40,14 +43,16 @@ enum CaptureOutputFormatter { contentKind: ClipboardHistoryEntry.ContentKind, preset: CaptureOutputPreset, source: ClipboardHistoryEntry.SourceContext? = nil, - targetBundleIdentifier: String? = nil + targetBundleIdentifier: String? = nil, + ocrResult: OCRResult? = nil ) -> ClipboardPayload { let formattedText = format( rawText: rawText, captureMode: captureMode, contentKind: contentKind, preset: preset, - source: source + source: source, + ocrResult: ocrResult ) guard !formattedText.isEmpty else { diff --git a/ScreenTextGrab/Models/OCRResult.swift b/ScreenTextGrab/Models/OCRResult.swift index bf6ba73..42d5c55 100644 --- a/ScreenTextGrab/Models/OCRResult.swift +++ b/ScreenTextGrab/Models/OCRResult.swift @@ -43,6 +43,125 @@ struct OCRResult: Sendable { } } + /// Eş aralıklı (monospace) hizalı metin üretir. + /// Metin bloklarının boundingBox konumlarını kullanarak satırlara ayırır, + /// yatay boşlukları space karakterleriyle ve dikey boşlukları boş satırlarla korur. + /// - Parameter columns: İsteğe bağlı karakter sütunu sayısı. Verilirse karakter genişliği + /// bu değere göre hesaplanır; verilmezse bloklardan otomatik kestirilir. + func monospaceAlignedText(columns: Int? = nil) -> String { + guard !blocks.isEmpty else { + return "" + } + + let charWidth = resolveCharacterWidth(columns: columns) + guard charWidth > 0 else { + return fullText + } + + let lines = monospaceClusterLines() + guard !lines.isEmpty else { + return "" + } + + let medianLineHeight = monospaceMedianLineHeight(lines: lines) + + var output: [String] = [] + var previousLineMinY: CGFloat? + + for line in lines { + let sortedLine = line.sorted { $0.boundingBox.minX < $1.boundingBox.minX } + let lineMinY = sortedLine.map { $0.boundingBox.minY }.min() ?? 0 + let lineMaxY = sortedLine.map { $0.boundingBox.maxY }.max() ?? 0 + + if let prevMinY = previousLineMinY, medianLineHeight > 0 { + let verticalGap = prevMinY - lineMaxY + let blankLineCount = max(0, Int((verticalGap / medianLineHeight).rounded()) - 1) + if blankLineCount > 0 { + output.append(contentsOf: Array(repeating: "", count: blankLineCount)) + } + } + + output.append(monospaceLineText(sortedLine, charWidth: charWidth)) + previousLineMinY = lineMinY + } + + return output.joined(separator: "\n") + } + + private func resolveCharacterWidth(columns: Int?) -> CGFloat { + if let columns, columns > 0 { + let minX = blocks.map { $0.boundingBox.minX }.min() ?? 0 + let maxX = blocks.map { $0.boundingBox.maxX }.max() ?? 1 + return max(0.001, (maxX - minX) / CGFloat(columns)) + } + + let widths = blocks + .filter { !$0.text.isEmpty } + .map { $0.boundingBox.width / CGFloat($0.text.count) } + .filter { $0 > 0 } + .sorted() + + guard !widths.isEmpty else { + return 0.02 + } + + return widths[widths.count / 2] + } + + private func monospaceClusterLines() -> [[OCRTextBlock]] { + let sortedByY = blocks.sorted { $0.boundingBox.midY > $1.boundingBox.midY } + let lineThreshold: CGFloat = 0.028 + + var lines: [[OCRTextBlock]] = [] + for block in sortedByY { + if let lastMidY = lines.last?.first?.boundingBox.midY, + abs(block.boundingBox.midY - lastMidY) < lineThreshold { + lines[lines.count - 1].append(block) + } else { + lines.append([block]) + } + } + return lines + } + + private func monospaceMedianLineHeight(lines: [[OCRTextBlock]]) -> CGFloat { + let heights = lines + .map { line in + let minY = line.map { $0.boundingBox.minY }.min() ?? 0 + let maxY = line.map { $0.boundingBox.maxY }.max() ?? 0 + return maxY - minY + } + .filter { $0 > 0 } + .sorted() + + guard !heights.isEmpty else { + return 0.04 + } + + return heights[heights.count / 2] + } + + private func monospaceLineText(_ line: [OCRTextBlock], charWidth: CGFloat) -> String { + guard let first = line.first else { + return "" + } + + var result = "" + var cursorX = first.boundingBox.minX + + for block in line { + let horizontalGap = block.boundingBox.minX - cursorX + if horizontalGap > 0 { + let spaceCount = max(1, Int((horizontalGap / charWidth).rounded())) + result.append(String(repeating: " ", count: spaceCount)) + } + result.append(block.text) + cursorX = block.boundingBox.maxX + } + + return result + } + private var standardFormattedText: String { groupedLines(lineThreshold: 0.03) .map { line in diff --git a/ScreenTextGrab/Services/CaptureCoordinator.swift b/ScreenTextGrab/Services/CaptureCoordinator.swift index bb51a72..73e6509 100644 --- a/ScreenTextGrab/Services/CaptureCoordinator.swift +++ b/ScreenTextGrab/Services/CaptureCoordinator.swift @@ -38,7 +38,8 @@ protocol CaptureCoordinating: AnyObject { contentKind: ClipboardHistoryEntry.ContentKind, source: ClipboardHistoryEntry.SourceContext?, outputPreset: CaptureOutputPreset, - targetBundleIdentifier: String? + targetBundleIdentifier: String?, + ocrResult: OCRResult? ) -> ClipboardWriteResult } @@ -56,7 +57,8 @@ extension CaptureCoordinating { contentKind: contentKind, source: source, outputPreset: outputPreset, - targetBundleIdentifier: nil + targetBundleIdentifier: nil, + ocrResult: nil ) } } @@ -649,7 +651,8 @@ final class CaptureCoordinator: CaptureCoordinating { contentKind: ClipboardHistoryEntry.ContentKind, source: ClipboardHistoryEntry.SourceContext?, outputPreset: CaptureOutputPreset, - targetBundleIdentifier: String? = nil + targetBundleIdentifier: String? = nil, + ocrResult: OCRResult? = nil ) -> ClipboardWriteResult { outputWriter.copyCapturedText( rawText: rawText, @@ -657,7 +660,8 @@ final class CaptureCoordinator: CaptureCoordinating { contentKind: contentKind, source: source, outputPreset: outputPreset, - targetBundleIdentifier: targetBundleIdentifier + targetBundleIdentifier: targetBundleIdentifier, + ocrResult: ocrResult ) } @@ -1180,6 +1184,7 @@ final class CaptureCoordinator: CaptureCoordinating { source: source, outputPreset: outputPreset, ocrConfidence: best.ocrResult.averageConfidence, + ocrResult: best.ocrResult, notificationDisplayFrame: notificationDisplayFrame, successStatusMessage: CaptureRecognitionHeuristics.successStatusMessage(for: best) ) @@ -1510,6 +1515,7 @@ final class CaptureCoordinator: CaptureCoordinating { contentKind: historyContentKind(for: selection), source: currentWatchSession?.source, outputPreset: currentWatchSession?.outputPreset ?? appState.captureOutputPreset, + ocrResult: selection.ocrResult, notificationDisplayFrame: preferredDisplay?.frame, successStatusMessage: "👁 \(CaptureRecognitionHeuristics.successStatusMessage(for: selection))" ) diff --git a/ScreenTextGrab/Services/CaptureOutputWriter.swift b/ScreenTextGrab/Services/CaptureOutputWriter.swift index 46f4174..8170f7f 100644 --- a/ScreenTextGrab/Services/CaptureOutputWriter.swift +++ b/ScreenTextGrab/Services/CaptureOutputWriter.swift @@ -37,6 +37,7 @@ final class CaptureOutputWriter { outputPreset: CaptureOutputPreset, targetBundleIdentifier: String? = nil, ocrConfidence: Float? = nil, + ocrResult: OCRResult? = nil, notificationDisplayFrame: CGRect? = nil, successStatusMessage: String? = nil ) -> ClipboardWriteResult { @@ -46,7 +47,8 @@ final class CaptureOutputWriter { contentKind: contentKind, preset: outputPreset, source: source, - targetBundleIdentifier: targetBundleIdentifier + targetBundleIdentifier: targetBundleIdentifier, + ocrResult: ocrResult ) let formattedText = payload.string diff --git a/ScreenTextGrab/Views/MenuBarView.swift b/ScreenTextGrab/Views/MenuBarView.swift index 99743ad..7c2dc0f 100644 --- a/ScreenTextGrab/Views/MenuBarView.swift +++ b/ScreenTextGrab/Views/MenuBarView.swift @@ -609,6 +609,8 @@ struct MenuBarView: View { return "curlybraces.square" case .json: return "curlybraces" + case .monospace: + return "textformat" } } @@ -1355,7 +1357,8 @@ struct MenuBarView: View { contentKind: entry.contentKind, source: entry.source, outputPreset: preset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier + targetBundleIdentifier: appState.activeTargetBundleIdentifier, + ocrResult: nil ) else { smartActionFeedback = InlineFeedback( message: "Kopyalama servisi şu anda hazır değil.", diff --git a/ScreenTextGrab/Views/SettingsView.swift b/ScreenTextGrab/Views/SettingsView.swift index 04e5dd6..fc036b6 100644 --- a/ScreenTextGrab/Views/SettingsView.swift +++ b/ScreenTextGrab/Views/SettingsView.swift @@ -1442,7 +1442,8 @@ struct SettingsView: View { contentKind: entry.contentKind, source: entry.source, outputPreset: effectiveOutputPreset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier + targetBundleIdentifier: appState.activeTargetBundleIdentifier, + ocrResult: nil ) else { historyFeedback = InlineFeedback( message: "Kopyalama servisi şu anda hazır değil.", diff --git a/ScreenTextGrab/Views/TableReviewView.swift b/ScreenTextGrab/Views/TableReviewView.swift index 87ceb6e..cf9b4cf 100644 --- a/ScreenTextGrab/Views/TableReviewView.swift +++ b/ScreenTextGrab/Views/TableReviewView.swift @@ -351,7 +351,8 @@ struct TableReviewView: View { contentKind: session.entry.contentKind, source: session.entry.source, outputPreset: preset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier + targetBundleIdentifier: appState.activeTargetBundleIdentifier, + ocrResult: nil ) else { feedback = InlineFeedback( message: L10n.pair("Kopyalama servisi su anda hazir degil.", "The copy service is not ready right now."), diff --git a/ScreenTextGrabTests/CaptureOutputFormatterTests.swift b/ScreenTextGrabTests/CaptureOutputFormatterTests.swift index b136ce9..a8ca583 100644 --- a/ScreenTextGrabTests/CaptureOutputFormatterTests.swift +++ b/ScreenTextGrabTests/CaptureOutputFormatterTests.swift @@ -250,4 +250,38 @@ final class CaptureOutputFormatterTests: XCTestCase { "Urun\tFiyat\r\nElma\t12.99" ) } + + func testMonospacePresetUsesOcrResultAlignment() { + let ocrResult = OCRResult( + blocks: [ + OCRTextBlock(text: "Name", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)), + OCRTextBlock(text: "Value", confidence: 0.95, boundingBox: CGRect(x: 0.50, y: 0.80, width: 0.10, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = CaptureOutputFormatter.format( + rawText: "Name Value", + captureMode: .standard, + contentKind: .text, + preset: .monospace, + ocrResult: ocrResult + ) + + XCTAssertTrue(output.contains("Name")) + XCTAssertTrue(output.contains("Value")) + XCTAssertGreaterThan(output.count, "Name Value".count) + } + + func testMonospacePresetFallsBackToRawTextWithoutOcrResult() { + let output = CaptureOutputFormatter.format( + rawText: "Hello World", + captureMode: .standard, + contentKind: .text, + preset: .monospace + ) + + XCTAssertEqual(output, "Hello World") + } } diff --git a/ScreenTextGrabTests/OCRResultTests.swift b/ScreenTextGrabTests/OCRResultTests.swift index 1909a76..d5b9326 100644 --- a/ScreenTextGrabTests/OCRResultTests.swift +++ b/ScreenTextGrabTests/OCRResultTests.swift @@ -187,4 +187,110 @@ final class OCRResultTests: XCTestCase { "Kategori\tOcak\tSubat\tMart\nIcekler\t12\t14\t16\nToplam\t\t\t42" ) } + + // MARK: - Monospace Alignment + + func testMonospaceAlignedTextPreservesHorizontalGap() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Name", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)), + OCRTextBlock(text: "Value", confidence: 0.95, boundingBox: CGRect(x: 0.50, y: 0.80, width: 0.10, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText() + XCTAssertTrue(output.hasPrefix("Name")) + XCTAssertTrue(output.hasSuffix("Value")) + XCTAssertGreaterThan(output.count, "Name Value".count) + XCTAssertEqual(output.components(separatedBy: "\n").count, 1) + } + + func testMonospaceAlignedTextProducesMultipleLines() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Hello", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.10, height: 0.04)), + OCRTextBlock(text: "World", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.72, width: 0.10, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText() + let lines = output.components(separatedBy: "\n") + XCTAssertEqual(lines.count, 2) + XCTAssertEqual(lines[0], "Hello") + XCTAssertEqual(lines[1], "World") + } + + func testMonospaceAlignedTextInsertsBlankLinesForLargeVerticalGap() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Top", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.06, height: 0.04)), + OCRTextBlock(text: "Bottom", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.60, width: 0.12, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText() + let lines = output.components(separatedBy: "\n") + XCTAssertEqual(lines.first, "Top") + XCTAssertEqual(lines.last, "Bottom") + XCTAssertGreaterThan(lines.count, 2) + XCTAssertTrue(lines.dropFirst().dropLast().allSatisfy { $0.isEmpty }) + } + + func testMonospaceAlignedTextEmptyBlocksReturnsEmpty() { + let result = OCRResult( + blocks: [], + captureDate: Date(), + sourceRect: .zero + ) + + XCTAssertEqual(result.monospaceAlignedText(), "") + } + + func testMonospaceAlignedTextSingleBlock() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Hello", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.10, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + XCTAssertEqual(result.monospaceAlignedText(), "Hello") + } + + func testMonospaceAlignedTextSortsBlocksByXWithinLine() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Right", confidence: 0.95, boundingBox: CGRect(x: 0.50, y: 0.80, width: 0.10, height: 0.04)), + OCRTextBlock(text: "Left", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText() + XCTAssertTrue(output.hasPrefix("Left")) + XCTAssertTrue(output.hasSuffix("Right")) + } + + func testMonospaceAlignedTextWithExplicitColumns() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "A", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.02, height: 0.04)), + OCRTextBlock(text: "B", confidence: 0.95, boundingBox: CGRect(x: 0.50, y: 0.80, width: 0.02, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText(columns: 40) + XCTAssertTrue(output.hasPrefix("A")) + XCTAssertTrue(output.hasSuffix("B")) + } } From 1f31dc88a1df6ba8b9b6881cba9eb0152069e32f Mon Sep 17 00:00:00 2001 From: luw2007 Date: Thu, 10 Sep 2026 21:01:47 +0800 Subject: [PATCH 2/3] Add Monospace Layout toggle for position-preserving text output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the standalone .monospace output preset with a toggle-based feature. When enabled, the capture pipeline uses OCR block boundingBox positions to produce monospace-aligned text that preserves horizontal and vertical layout. Algorithm (ported from ocr-layout.swift): - Fixed-column horizontal alignment: col = Int(x * columns), pad to col - y-clustering into rows (threshold 0.02) - Median row height for blank line estimation - Vertical gaps convert to blank lines (capped at 6) Changes: - AppModels: MonospaceLayoutSettings struct + UserDefaults store (isEnabled Bool, columns Int default 120, clamped 20-300) - AppState: @Published monospaceLayoutSettings, setters, persist - OCRResult: monospaceAlignedText(columns:) using fixed-column algorithm - CaptureCoordinator: resolvedRawText(for:) applies monospace when toggle ON; works for both normal capture and watch mode - Settings UI: Toggle + column slider (40-200) in General tab, under Output Format section - Tests: 9 new OCRResultTests covering horizontal position, multi-line, blank lines, empty/single block, x-sorting, explicit columns, clamping No changes to CaptureOutputFormatter/CaptureOutputWriter interfaces — the toggle is applied at the coordinator level before text reaches the formatter, so it composes with any existing output preset. --- ScreenTextGrab/AppState.swift | 25 +++ ScreenTextGrab/Models/AppModels.swift | 53 +++++-- .../Models/CaptureOutputFormatter.swift | 11 +- ScreenTextGrab/Models/OCRResult.swift | 150 +++++++----------- .../Services/CaptureCoordinator.swift | 41 +++-- .../Services/CaptureOutputWriter.swift | 4 +- ScreenTextGrab/Views/MenuBarView.swift | 5 +- .../Views/Settings/SettingsTabViews.swift | 38 +++++ ScreenTextGrab/Views/SettingsView.swift | 9 +- ScreenTextGrab/Views/TableReviewView.swift | 3 +- .../CaptureOutputFormatterTests.swift | 34 ---- ScreenTextGrabTests/OCRResultTests.swift | 55 +++++-- 12 files changed, 248 insertions(+), 180 deletions(-) diff --git a/ScreenTextGrab/AppState.swift b/ScreenTextGrab/AppState.swift index fbd1491..c2df05e 100644 --- a/ScreenTextGrab/AppState.swift +++ b/ScreenTextGrab/AppState.swift @@ -18,6 +18,7 @@ final class AppState: ObservableObject { @Published var speechState: SpeechPlaybackState @Published var ocrLanguageSelection: OCRLanguageSelection @Published var captureOutputPreset: CaptureOutputPreset + @Published var monospaceLayoutSettings: MonospaceLayoutSettings @Published var interfaceLanguage: InterfaceLanguage @Published var watchConfiguration: WatchConfiguration @Published var appProfiles: [AppCaptureProfile] @@ -76,6 +77,9 @@ final class AppState: ObservableObject { self.captureOutputPreset = persistsUserPreferences ? CaptureOutputPresetStore.load(defaults: defaults) : .smart + self.monospaceLayoutSettings = persistsUserPreferences + ? MonospaceLayoutSettingsStore.load(defaults: defaults) + : .defaultValue self.interfaceLanguage = persistsUserPreferences ? InterfaceLanguageStore.load(defaults: defaults) : .system @@ -249,6 +253,22 @@ final class AppState: ObservableObject { persistCaptureOutputPresetIfNeeded() } + func setMonospaceLayoutEnabled(_ enabled: Bool) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: enabled, + columns: monospaceLayoutSettings.columns + ) + persistMonospaceLayoutSettingsIfNeeded() + } + + func setMonospaceLayoutColumns(_ columns: Int) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: monospaceLayoutSettings.isEnabled, + columns: columns + ) + persistMonospaceLayoutSettingsIfNeeded() + } + func setInterfaceLanguage(_ language: InterfaceLanguage) { interfaceLanguage = language persistInterfaceLanguageIfNeeded() @@ -647,6 +667,11 @@ final class AppState: ObservableObject { CaptureOutputPresetStore.save(captureOutputPreset, defaults: defaults) } + private func persistMonospaceLayoutSettingsIfNeeded() { + guard persistsUserPreferences else { return } + MonospaceLayoutSettingsStore.save(monospaceLayoutSettings, defaults: defaults) + } + private func persistInterfaceLanguageIfNeeded() { guard persistsUserPreferences else { return } InterfaceLanguageStore.save(interfaceLanguage, defaults: defaults) diff --git a/ScreenTextGrab/Models/AppModels.swift b/ScreenTextGrab/Models/AppModels.swift index 1c05dd5..2707036 100644 --- a/ScreenTextGrab/Models/AppModels.swift +++ b/ScreenTextGrab/Models/AppModels.swift @@ -233,7 +233,6 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id case office case markdown case json - case monospace var id: String { rawValue } @@ -251,8 +250,6 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return "Markdown" case .json: return "JSON" - case .monospace: - return L10n.pair("Eş Aralıklı", "Monospace") } } @@ -270,8 +267,6 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return "MD" case .json: return "JSON" - case .monospace: - return L10n.pair("Mono", "Mono") } } @@ -289,8 +284,6 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return L10n.pair("Kod ve tablo için hazır", "Ready for code and tables") case .json: return L10n.pair("Otomasyon için yapılandırılmış", "Structured for automation") - case .monospace: - return L10n.pair("Konumu boşluklarla korur", "Preserves layout with spaces") } } @@ -308,8 +301,6 @@ enum CaptureOutputPreset: String, CaseIterable, Codable, Equatable, Sendable, Id return L10n.pair("Kod ve tablo gibi içerikleri Markdown uyumlu biçimde hazırlar.", "Formats content like code and tables for Markdown.") case .json: return L10n.pair("Otomasyon ve entegrasyonlar için yapılandırılmış çıktı üretir.", "Produces structured output for automation and integrations.") - case .monospace: - return L10n.pair("Metin bloklarının ekran konumını kullanarak eş aralıklı hizalama üretir; terminal ve tablo görünümleri için ideal.", "Uses block positions to produce monospace-aligned text; ideal for terminal and table layouts.") } } } @@ -663,6 +654,50 @@ enum CaptureOutputPresetStore { } } +struct MonospaceLayoutSettings: Codable, Equatable, Sendable { + var isEnabled: Bool + var columns: Int + + static let defaultColumns = 120 + static let defaultValue = MonospaceLayoutSettings(isEnabled: false, columns: defaultColumns) + + init(isEnabled: Bool = false, columns: Int = defaultColumns) { + self.isEnabled = isEnabled + self.columns = max(20, min(300, columns)) + } + + private enum CodingKeys: String, CodingKey { + case isEnabled + case columns + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false + let columns = try container.decodeIfPresent(Int.self, forKey: .columns) ?? Self.defaultColumns + self.init(isEnabled: isEnabled, columns: columns) + } +} + +enum MonospaceLayoutSettingsStore { + static let key = "screenTextGrab.monospaceLayoutSettings" + + static func load(defaults: UserDefaults = .standard) -> MonospaceLayoutSettings { + guard let data = defaults.data(forKey: key), + let settings = try? JSONDecoder().decode(MonospaceLayoutSettings.self, from: data) else { + return .defaultValue + } + return settings + } + + static func save(_ settings: MonospaceLayoutSettings, defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(settings) else { + return + } + defaults.set(data, forKey: key) + } +} + struct ClipboardHistoryEntry: Identifiable, Equatable, Sendable, Codable { enum ContentKind: String, CaseIterable, Identifiable, Codable, Sendable { case text diff --git a/ScreenTextGrab/Models/CaptureOutputFormatter.swift b/ScreenTextGrab/Models/CaptureOutputFormatter.swift index 5c44a8e..ed2cec7 100644 --- a/ScreenTextGrab/Models/CaptureOutputFormatter.swift +++ b/ScreenTextGrab/Models/CaptureOutputFormatter.swift @@ -6,8 +6,7 @@ enum CaptureOutputFormatter { captureMode: CaptureMode, contentKind: ClipboardHistoryEntry.ContentKind, preset: CaptureOutputPreset, - source: ClipboardHistoryEntry.SourceContext? = nil, - ocrResult: OCRResult? = nil + source: ClipboardHistoryEntry.SourceContext? = nil ) -> String { let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -32,8 +31,6 @@ enum CaptureOutputFormatter { contentKind: contentKind, source: source ) - case .monospace: - return ocrResult?.monospaceAlignedText() ?? trimmed } } @@ -43,16 +40,14 @@ enum CaptureOutputFormatter { contentKind: ClipboardHistoryEntry.ContentKind, preset: CaptureOutputPreset, source: ClipboardHistoryEntry.SourceContext? = nil, - targetBundleIdentifier: String? = nil, - ocrResult: OCRResult? = nil + targetBundleIdentifier: String? = nil ) -> ClipboardPayload { let formattedText = format( rawText: rawText, captureMode: captureMode, contentKind: contentKind, preset: preset, - source: source, - ocrResult: ocrResult + source: source ) guard !formattedText.isEmpty else { diff --git a/ScreenTextGrab/Models/OCRResult.swift b/ScreenTextGrab/Models/OCRResult.swift index 42d5c55..a155fd2 100644 --- a/ScreenTextGrab/Models/OCRResult.swift +++ b/ScreenTextGrab/Models/OCRResult.swift @@ -45,121 +45,87 @@ struct OCRResult: Sendable { /// Eş aralıklı (monospace) hizalı metin üretir. /// Metin bloklarının boundingBox konumlarını kullanarak satırlara ayırır, - /// yatay boşlukları space karakterleriyle ve dikey boşlukları boş satırlarla korur. - /// - Parameter columns: İsteğe bağlı karakter sütunu sayısı. Verilirse karakter genişliği - /// bu değere göre hesaplanır; verilmezse bloklardan otomatik kestirilir. - func monospaceAlignedText(columns: Int? = nil) -> String { + /// yatay konumu sabit sütun sayısına göre padding ile korur, + /// dikey boşlukları boş satırlarla ifade eder. + /// - Parameter columns: Çıktı toplam sütun sayısı (varsayılan 120). + /// Yatay hizalama doğruluğunu kontrol eder. + func monospaceAlignedText(columns: Int = 120) -> String { guard !blocks.isEmpty else { return "" } - let charWidth = resolveCharacterWidth(columns: columns) - guard charWidth > 0 else { - return fullText - } + let resolvedColumns = max(20, min(300, columns)) - let lines = monospaceClusterLines() - guard !lines.isEmpty else { - return "" + // y'ye göre sırala (büyük y = ekranın üstü, y yakınsa x'e göre) + let sorted = blocks.sorted { a, b in + if abs(a.boundingBox.midY - b.boundingBox.midY) < 0.02 { + return a.boundingBox.minX < b.boundingBox.minX + } + return a.boundingBox.midY > b.boundingBox.midY } - let medianLineHeight = monospaceMedianLineHeight(lines: lines) - - var output: [String] = [] - var previousLineMinY: CGFloat? - - for line in lines { - let sortedLine = line.sorted { $0.boundingBox.minX < $1.boundingBox.minX } - let lineMinY = sortedLine.map { $0.boundingBox.minY }.min() ?? 0 - let lineMaxY = sortedLine.map { $0.boundingBox.maxY }.max() ?? 0 - - if let prevMinY = previousLineMinY, medianLineHeight > 0 { - let verticalGap = prevMinY - lineMaxY - let blankLineCount = max(0, Int((verticalGap / medianLineHeight).rounded()) - 1) - if blankLineCount > 0 { - output.append(contentsOf: Array(repeating: "", count: blankLineCount)) + // Satır kümeleme + var rows: [[OCRTextBlock]] = [] + var currentRow: [OCRTextBlock] = [] + for block in sorted { + if let first = currentRow.first { + if abs(block.boundingBox.midY - first.boundingBox.midY) < 0.02 { + currentRow.append(block) + } else { + rows.append(currentRow) + currentRow = [block] } + } else { + currentRow = [block] } - - output.append(monospaceLineText(sortedLine, charWidth: charWidth)) - previousLineMinY = lineMinY } - - return output.joined(separator: "\n") - } - - private func resolveCharacterWidth(columns: Int?) -> CGFloat { - if let columns, columns > 0 { - let minX = blocks.map { $0.boundingBox.minX }.min() ?? 0 - let maxX = blocks.map { $0.boundingBox.maxX }.max() ?? 1 - return max(0.001, (maxX - minX) / CGFloat(columns)) + if !currentRow.isEmpty { + rows.append(currentRow) } - let widths = blocks - .filter { !$0.text.isEmpty } - .map { $0.boundingBox.width / CGFloat($0.text.count) } + // Medyan satır yüksekliği — boş satır hesaplaması için + let rowHeights = rows + .compactMap { $0.first?.boundingBox.height } .filter { $0 > 0 } .sorted() + let medianRowHeight = rowHeights.isEmpty ? 0.02 : rowHeights[rowHeights.count / 2] - guard !widths.isEmpty else { - return 0.02 - } - - return widths[widths.count / 2] - } + var output = "" + var lastRowTopY: CGFloat? - private func monospaceClusterLines() -> [[OCRTextBlock]] { - let sortedByY = blocks.sorted { $0.boundingBox.midY > $1.boundingBox.midY } - let lineThreshold: CGFloat = 0.028 - - var lines: [[OCRTextBlock]] = [] - for block in sortedByY { - if let lastMidY = lines.last?.first?.boundingBox.midY, - abs(block.boundingBox.midY - lastMidY) < lineThreshold { - lines[lines.count - 1].append(block) - } else { - lines.append([block]) + for row in rows { + let sortedRow = row.sorted { $0.boundingBox.minX < $1.boundingBox.minX } + let rowTopY = sortedRow.map { $0.boundingBox.maxY }.max() ?? 0 + + // Dikey boşluk → boş satır + if let ly = lastRowTopY { + let gap = ly - rowTopY + let blankLines = Int(gap / max(medianRowHeight * 1.5, 0.015)) + if blankLines > 0 { + output += String(repeating: "\n", count: min(blankLines, 6)) + } } - } - return lines - } - private func monospaceMedianLineHeight(lines: [[OCRTextBlock]]) -> CGFloat { - let heights = lines - .map { line in - let minY = line.map { $0.boundingBox.minY }.min() ?? 0 - let maxY = line.map { $0.boundingBox.maxY }.max() ?? 0 - return maxY - minY + // Yatay: sütun konumuna padding ile hizala + var line = "" + for block in sortedRow { + let col = Int(block.boundingBox.minX * CGFloat(resolvedColumns)) + if col > line.count { + line += String(repeating: " ", count: col - line.count) + } else if !line.isEmpty { + line += " " + } + line += block.text } - .filter { $0 > 0 } - .sorted() - - guard !heights.isEmpty else { - return 0.04 + output += line + "\n" + lastRowTopY = rowTopY } - return heights[heights.count / 2] - } - - private func monospaceLineText(_ line: [OCRTextBlock], charWidth: CGFloat) -> String { - guard let first = line.first else { - return "" - } - - var result = "" - var cursorX = first.boundingBox.minX - - for block in line { - let horizontalGap = block.boundingBox.minX - cursorX - if horizontalGap > 0 { - let spaceCount = max(1, Int((horizontalGap / charWidth).rounded())) - result.append(String(repeating: " ", count: spaceCount)) - } - result.append(block.text) - cursorX = block.boundingBox.maxX + if output.hasSuffix("\n") { + output.removeLast() } - return result + return output } private var standardFormattedText: String { diff --git a/ScreenTextGrab/Services/CaptureCoordinator.swift b/ScreenTextGrab/Services/CaptureCoordinator.swift index 73e6509..01704c2 100644 --- a/ScreenTextGrab/Services/CaptureCoordinator.swift +++ b/ScreenTextGrab/Services/CaptureCoordinator.swift @@ -38,8 +38,7 @@ protocol CaptureCoordinating: AnyObject { contentKind: ClipboardHistoryEntry.ContentKind, source: ClipboardHistoryEntry.SourceContext?, outputPreset: CaptureOutputPreset, - targetBundleIdentifier: String?, - ocrResult: OCRResult? + targetBundleIdentifier: String? ) -> ClipboardWriteResult } @@ -57,8 +56,7 @@ extension CaptureCoordinating { contentKind: contentKind, source: source, outputPreset: outputPreset, - targetBundleIdentifier: nil, - ocrResult: nil + targetBundleIdentifier: nil ) } } @@ -651,8 +649,7 @@ final class CaptureCoordinator: CaptureCoordinating { contentKind: ClipboardHistoryEntry.ContentKind, source: ClipboardHistoryEntry.SourceContext?, outputPreset: CaptureOutputPreset, - targetBundleIdentifier: String? = nil, - ocrResult: OCRResult? = nil + targetBundleIdentifier: String? = nil ) -> ClipboardWriteResult { outputWriter.copyCapturedText( rawText: rawText, @@ -660,8 +657,7 @@ final class CaptureCoordinator: CaptureCoordinating { contentKind: contentKind, source: source, outputPreset: outputPreset, - targetBundleIdentifier: targetBundleIdentifier, - ocrResult: ocrResult + targetBundleIdentifier: targetBundleIdentifier ) } @@ -1177,14 +1173,15 @@ final class CaptureCoordinator: CaptureCoordinating { appState.statusMessage = "Panoya kopyalanıyor..." STGLog.pipeline.info("State → copying") + let effectiveRawText = resolvedRawText(for: best) + let clipboardResult = outputWriter.copyCapturedText( - rawText: best.text, + rawText: effectiveRawText, captureMode: captureMode, contentKind: historyContentKind(for: best), source: source, outputPreset: outputPreset, ocrConfidence: best.ocrResult.averageConfidence, - ocrResult: best.ocrResult, notificationDisplayFrame: notificationDisplayFrame, successStatusMessage: CaptureRecognitionHeuristics.successStatusMessage(for: best) ) @@ -1198,6 +1195,17 @@ final class CaptureCoordinator: CaptureCoordinating { } } + private func resolvedRawText(for selection: CaptureOCRSelection) -> String { + guard appState.monospaceLayoutSettings.isEnabled else { + return selection.text + } + + let monospaceText = selection.ocrResult.monospaceAlignedText( + columns: appState.monospaceLayoutSettings.columns + ) + return monospaceText.isEmpty ? selection.text : monospaceText + } + nonisolated private static func evaluateCandidatesAsync( ocrService: any OCRProviding, @@ -1509,13 +1517,22 @@ final class CaptureCoordinator: CaptureCoordinating { ) async -> Bool { guard appState.watchState == .active else { return false } + let effectiveRawText: String + if appState.monospaceLayoutSettings.isEnabled { + let monospaceText = selection.ocrResult.monospaceAlignedText( + columns: appState.monospaceLayoutSettings.columns + ) + effectiveRawText = monospaceText.isEmpty ? rawText : monospaceText + } else { + effectiveRawText = rawText + } + let clipboardResult = outputWriter.copyCapturedText( - rawText: rawText, + rawText: effectiveRawText, captureMode: currentWatchSession?.captureMode ?? appState.captureMode, contentKind: historyContentKind(for: selection), source: currentWatchSession?.source, outputPreset: currentWatchSession?.outputPreset ?? appState.captureOutputPreset, - ocrResult: selection.ocrResult, notificationDisplayFrame: preferredDisplay?.frame, successStatusMessage: "👁 \(CaptureRecognitionHeuristics.successStatusMessage(for: selection))" ) diff --git a/ScreenTextGrab/Services/CaptureOutputWriter.swift b/ScreenTextGrab/Services/CaptureOutputWriter.swift index 8170f7f..46f4174 100644 --- a/ScreenTextGrab/Services/CaptureOutputWriter.swift +++ b/ScreenTextGrab/Services/CaptureOutputWriter.swift @@ -37,7 +37,6 @@ final class CaptureOutputWriter { outputPreset: CaptureOutputPreset, targetBundleIdentifier: String? = nil, ocrConfidence: Float? = nil, - ocrResult: OCRResult? = nil, notificationDisplayFrame: CGRect? = nil, successStatusMessage: String? = nil ) -> ClipboardWriteResult { @@ -47,8 +46,7 @@ final class CaptureOutputWriter { contentKind: contentKind, preset: outputPreset, source: source, - targetBundleIdentifier: targetBundleIdentifier, - ocrResult: ocrResult + targetBundleIdentifier: targetBundleIdentifier ) let formattedText = payload.string diff --git a/ScreenTextGrab/Views/MenuBarView.swift b/ScreenTextGrab/Views/MenuBarView.swift index 7c2dc0f..99743ad 100644 --- a/ScreenTextGrab/Views/MenuBarView.swift +++ b/ScreenTextGrab/Views/MenuBarView.swift @@ -609,8 +609,6 @@ struct MenuBarView: View { return "curlybraces.square" case .json: return "curlybraces" - case .monospace: - return "textformat" } } @@ -1357,8 +1355,7 @@ struct MenuBarView: View { contentKind: entry.contentKind, source: entry.source, outputPreset: preset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier, - ocrResult: nil + targetBundleIdentifier: appState.activeTargetBundleIdentifier ) else { smartActionFeedback = InlineFeedback( message: "Kopyalama servisi şu anda hazır değil.", diff --git a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift index 3e3d601..46c2aaf 100644 --- a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift +++ b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift @@ -8,6 +8,9 @@ struct SettingsGeneralTabView: View { let captureModeFeedback: InlineFeedback? let outputPresetDetail: String let outputPresetFeedback: InlineFeedback? + let monospaceLayoutEnabledBinding: Binding + let monospaceLayoutColumns: Int + let onMonospaceLayoutColumnsChange: (Int) -> Void let isRecordingHotkey: Bool let hotkeyDisplayLabel: String let hotkeyFeedback: HotkeyFeedback? @@ -126,6 +129,41 @@ struct SettingsGeneralTabView: View { } } + SettingsSectionCard( + title: L10n.pair("Eş Aralıklı Düzen", "Monospace Layout"), + subtitle: L10n.pair("OCR bloklarının ekran konumını kullanarak metni eş aralıklı hizalar. Terminal ve tablo görünümleri için uygun.", "Aligns text using OCR block positions. Suitable for terminal and table layouts.") + ) { + Toggle(isOn: monospaceLayoutEnabledBinding) { + Text(L10n.pair("Eş aralıklı hizalamayı kullan", "Use monospace alignment")) + .font(.system(size: 12.5, weight: .semibold, design: .rounded)) + } + .toggleStyle(.switch) + .tint(.accentMint) + + if monospaceLayoutEnabledBinding.wrappedValue { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.pair("Sütun sayısı: \(monospaceLayoutColumns)", "Columns: \(monospaceLayoutColumns)")) + .font(.system(size: 11.5, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + + Slider( + value: Binding( + get: { Double(monospaceLayoutColumns) }, + set: { onMonospaceLayoutColumnsChange(Int($0.rounded())) } + ), + in: 40...200, + step: 10 + ) + .tint(.accentMint) + + Text(L10n.pair("Daha fazla sütun = daha hassas yatay hizalama.", "More columns = more precise horizontal alignment.")) + .font(.system(size: 10.5, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } + } + SettingsSectionCard( title: L10n.pair("Global Kısayol", "Global Shortcut"), subtitle: isRecordingHotkey diff --git a/ScreenTextGrab/Views/SettingsView.swift b/ScreenTextGrab/Views/SettingsView.swift index fc036b6..d92ca8a 100644 --- a/ScreenTextGrab/Views/SettingsView.swift +++ b/ScreenTextGrab/Views/SettingsView.swift @@ -150,6 +150,12 @@ struct SettingsView: View { captureModeFeedback: captureModeFeedback, outputPresetDetail: appState.captureOutputPreset.detail, outputPresetFeedback: outputPresetFeedback, + monospaceLayoutEnabledBinding: Binding( + get: { appState.monospaceLayoutSettings.isEnabled }, + set: { appState.setMonospaceLayoutEnabled($0) } + ), + monospaceLayoutColumns: appState.monospaceLayoutSettings.columns, + onMonospaceLayoutColumnsChange: { appState.setMonospaceLayoutColumns($0) }, isRecordingHotkey: isRecordingHotkey, hotkeyDisplayLabel: appState.hotkeyDisplayLabel, hotkeyFeedback: hotkeyFeedback, @@ -1442,8 +1448,7 @@ struct SettingsView: View { contentKind: entry.contentKind, source: entry.source, outputPreset: effectiveOutputPreset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier, - ocrResult: nil + targetBundleIdentifier: appState.activeTargetBundleIdentifier ) else { historyFeedback = InlineFeedback( message: "Kopyalama servisi şu anda hazır değil.", diff --git a/ScreenTextGrab/Views/TableReviewView.swift b/ScreenTextGrab/Views/TableReviewView.swift index cf9b4cf..87ceb6e 100644 --- a/ScreenTextGrab/Views/TableReviewView.swift +++ b/ScreenTextGrab/Views/TableReviewView.swift @@ -351,8 +351,7 @@ struct TableReviewView: View { contentKind: session.entry.contentKind, source: session.entry.source, outputPreset: preset, - targetBundleIdentifier: appState.activeTargetBundleIdentifier, - ocrResult: nil + targetBundleIdentifier: appState.activeTargetBundleIdentifier ) else { feedback = InlineFeedback( message: L10n.pair("Kopyalama servisi su anda hazir degil.", "The copy service is not ready right now."), diff --git a/ScreenTextGrabTests/CaptureOutputFormatterTests.swift b/ScreenTextGrabTests/CaptureOutputFormatterTests.swift index a8ca583..b136ce9 100644 --- a/ScreenTextGrabTests/CaptureOutputFormatterTests.swift +++ b/ScreenTextGrabTests/CaptureOutputFormatterTests.swift @@ -250,38 +250,4 @@ final class CaptureOutputFormatterTests: XCTestCase { "Urun\tFiyat\r\nElma\t12.99" ) } - - func testMonospacePresetUsesOcrResultAlignment() { - let ocrResult = OCRResult( - blocks: [ - OCRTextBlock(text: "Name", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)), - OCRTextBlock(text: "Value", confidence: 0.95, boundingBox: CGRect(x: 0.50, y: 0.80, width: 0.10, height: 0.04)) - ], - captureDate: Date(), - sourceRect: .zero - ) - - let output = CaptureOutputFormatter.format( - rawText: "Name Value", - captureMode: .standard, - contentKind: .text, - preset: .monospace, - ocrResult: ocrResult - ) - - XCTAssertTrue(output.contains("Name")) - XCTAssertTrue(output.contains("Value")) - XCTAssertGreaterThan(output.count, "Name Value".count) - } - - func testMonospacePresetFallsBackToRawTextWithoutOcrResult() { - let output = CaptureOutputFormatter.format( - rawText: "Hello World", - captureMode: .standard, - contentKind: .text, - preset: .monospace - ) - - XCTAssertEqual(output, "Hello World") - } } diff --git a/ScreenTextGrabTests/OCRResultTests.swift b/ScreenTextGrabTests/OCRResultTests.swift index d5b9326..c09e51e 100644 --- a/ScreenTextGrabTests/OCRResultTests.swift +++ b/ScreenTextGrabTests/OCRResultTests.swift @@ -190,7 +190,7 @@ final class OCRResultTests: XCTestCase { // MARK: - Monospace Alignment - func testMonospaceAlignedTextPreservesHorizontalGap() { + func testMonospaceAlignedTextPreservesHorizontalPosition() { let result = OCRResult( blocks: [ OCRTextBlock(text: "Name", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)), @@ -201,9 +201,11 @@ final class OCRResultTests: XCTestCase { ) let output = result.monospaceAlignedText() - XCTAssertTrue(output.hasPrefix("Name")) - XCTAssertTrue(output.hasSuffix("Value")) - XCTAssertGreaterThan(output.count, "Name Value".count) + // col = Int(0.10 * 120) = 12, so "Name" starts after 12 spaces + XCTAssertTrue(output.hasPrefix(String(repeating: " ", count: 12) + "Name")) + // col = Int(0.50 * 120) = 60, so "Value" starts at position 60 + let valueIndex = output.index(output.startIndex, offsetBy: 60) + XCTAssertEqual(output[valueIndex...], "Value") XCTAssertEqual(output.components(separatedBy: "\n").count, 1) } @@ -220,8 +222,8 @@ final class OCRResultTests: XCTestCase { let output = result.monospaceAlignedText() let lines = output.components(separatedBy: "\n") XCTAssertEqual(lines.count, 2) - XCTAssertEqual(lines[0], "Hello") - XCTAssertEqual(lines[1], "World") + XCTAssertTrue(lines[0].hasSuffix("Hello")) + XCTAssertTrue(lines[1].hasSuffix("World")) } func testMonospaceAlignedTextInsertsBlankLinesForLargeVerticalGap() { @@ -236,8 +238,8 @@ final class OCRResultTests: XCTestCase { let output = result.monospaceAlignedText() let lines = output.components(separatedBy: "\n") - XCTAssertEqual(lines.first, "Top") - XCTAssertEqual(lines.last, "Bottom") + XCTAssertTrue(lines.first?.hasSuffix("Top") == true) + XCTAssertTrue(lines.last?.hasSuffix("Bottom") == true) XCTAssertGreaterThan(lines.count, 2) XCTAssertTrue(lines.dropFirst().dropLast().allSatisfy { $0.isEmpty }) } @@ -261,7 +263,8 @@ final class OCRResultTests: XCTestCase { sourceRect: .zero ) - XCTAssertEqual(result.monospaceAlignedText(), "Hello") + let output = result.monospaceAlignedText() + XCTAssertTrue(output.hasSuffix("Hello")) } func testMonospaceAlignedTextSortsBlocksByXWithinLine() { @@ -275,8 +278,9 @@ final class OCRResultTests: XCTestCase { ) let output = result.monospaceAlignedText() - XCTAssertTrue(output.hasPrefix("Left")) - XCTAssertTrue(output.hasSuffix("Right")) + let leftRange = output.range(of: "Left")! + let rightRange = output.range(of: "Right")! + XCTAssertLessThan(leftRange.lowerBound, rightRange.lowerBound) } func testMonospaceAlignedTextWithExplicitColumns() { @@ -289,8 +293,31 @@ final class OCRResultTests: XCTestCase { sourceRect: .zero ) - let output = result.monospaceAlignedText(columns: 40) - XCTAssertTrue(output.hasPrefix("A")) - XCTAssertTrue(output.hasSuffix("B")) + // With 40 columns: A at col 4, B at col 20 + let output40 = result.monospaceAlignedText(columns: 40) + let indexB40 = output40.index(output40.startIndex, offsetBy: 20) + XCTAssertEqual(output40[indexB40], "B") + + // With 200 columns: A at col 20, B at col 100 + let output200 = result.monospaceAlignedText(columns: 200) + let indexB200 = output200.index(output200.startIndex, offsetBy: 100) + XCTAssertEqual(output200[indexB200], "B") + } + + func testMonospaceAlignedTextClampsColumnCount() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Test", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.08, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + // Columns below 20 should be clamped to 20, above 300 to 300 + let outputLow = result.monospaceAlignedText(columns: 5) + let outputHigh = result.monospaceAlignedText(columns: 500) + // Both should produce valid output with "Test" at the clamped column position + XCTAssertTrue(outputLow.hasSuffix("Test")) + XCTAssertTrue(outputHigh.hasSuffix("Test")) } } From 380e491f3880083158339653e5a3877a24c5dc76 Mon Sep 17 00:00:00 2001 From: luw2007 Date: Thu, 10 Sep 2026 21:27:53 +0800 Subject: [PATCH 3/3] Add gap-tree multi-column reading order sorting Adds a Umi-OCR-style gap tree algorithm that detects column boundaries from continuous vertical gaps and sorts blocks in human reading order (left column fully, then right column). Algorithm: - detectVerticalCuts: divide y-axis into 20 bands, find x gaps present in >=40% of bands, merge nearby cuts - detectHorizontalCuts: cluster into rows, find gaps >2x median as paragraph boundaries - buildLayoutTree: recursive split by vertical cuts first (columns), then horizontal cuts (row groups), leaf nodes contain blocks - traverseLayoutTree: pre-order traversal gives reading order Integration: - monospaceAlignedText gains multicolumnSorting parameter - MonospaceLayoutSettings gains multicolumnSortingEnabled flag - Settings UI: sub-toggle under Monospace Layout section - Works for both normal capture and watch mode Tests: - Two-column layout reads left column first - Single-column layout unchanged - monospace with sorting: left rows before right rows - monospace without sorting: same-y blocks interleave on one line --- ScreenTextGrab/AppState.swift | 15 +- ScreenTextGrab/Models/AppModels.swift | 14 +- ScreenTextGrab/Models/OCRResult.swift | 220 +++++++++++++++++- .../Services/CaptureCoordinator.swift | 6 +- .../Views/Settings/SettingsTabViews.swift | 17 ++ ScreenTextGrab/Views/SettingsView.swift | 4 + ScreenTextGrabTests/OCRResultTests.swift | 82 +++++++ 7 files changed, 349 insertions(+), 9 deletions(-) diff --git a/ScreenTextGrab/AppState.swift b/ScreenTextGrab/AppState.swift index c2df05e..3290db5 100644 --- a/ScreenTextGrab/AppState.swift +++ b/ScreenTextGrab/AppState.swift @@ -256,7 +256,8 @@ final class AppState: ObservableObject { func setMonospaceLayoutEnabled(_ enabled: Bool) { monospaceLayoutSettings = MonospaceLayoutSettings( isEnabled: enabled, - columns: monospaceLayoutSettings.columns + columns: monospaceLayoutSettings.columns, + multicolumnSortingEnabled: monospaceLayoutSettings.multicolumnSortingEnabled ) persistMonospaceLayoutSettingsIfNeeded() } @@ -264,7 +265,17 @@ final class AppState: ObservableObject { func setMonospaceLayoutColumns(_ columns: Int) { monospaceLayoutSettings = MonospaceLayoutSettings( isEnabled: monospaceLayoutSettings.isEnabled, - columns: columns + columns: columns, + multicolumnSortingEnabled: monospaceLayoutSettings.multicolumnSortingEnabled + ) + persistMonospaceLayoutSettingsIfNeeded() + } + + func setMonospaceMulticolumnSorting(_ enabled: Bool) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: monospaceLayoutSettings.isEnabled, + columns: monospaceLayoutSettings.columns, + multicolumnSortingEnabled: enabled ) persistMonospaceLayoutSettingsIfNeeded() } diff --git a/ScreenTextGrab/Models/AppModels.swift b/ScreenTextGrab/Models/AppModels.swift index 2707036..2a43d45 100644 --- a/ScreenTextGrab/Models/AppModels.swift +++ b/ScreenTextGrab/Models/AppModels.swift @@ -657,25 +657,33 @@ enum CaptureOutputPresetStore { struct MonospaceLayoutSettings: Codable, Equatable, Sendable { var isEnabled: Bool var columns: Int + var multicolumnSortingEnabled: Bool static let defaultColumns = 120 - static let defaultValue = MonospaceLayoutSettings(isEnabled: false, columns: defaultColumns) + static let defaultValue = MonospaceLayoutSettings( + isEnabled: false, + columns: defaultColumns, + multicolumnSortingEnabled: false + ) - init(isEnabled: Bool = false, columns: Int = defaultColumns) { + init(isEnabled: Bool = false, columns: Int = defaultColumns, multicolumnSortingEnabled: Bool = false) { self.isEnabled = isEnabled self.columns = max(20, min(300, columns)) + self.multicolumnSortingEnabled = multicolumnSortingEnabled } private enum CodingKeys: String, CodingKey { case isEnabled case columns + case multicolumnSortingEnabled } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false let columns = try container.decodeIfPresent(Int.self, forKey: .columns) ?? Self.defaultColumns - self.init(isEnabled: isEnabled, columns: columns) + let multicolumnSortingEnabled = try container.decodeIfPresent(Bool.self, forKey: .multicolumnSortingEnabled) ?? false + self.init(isEnabled: isEnabled, columns: columns, multicolumnSortingEnabled: multicolumnSortingEnabled) } } diff --git a/ScreenTextGrab/Models/OCRResult.swift b/ScreenTextGrab/Models/OCRResult.swift index a155fd2..c5f9f02 100644 --- a/ScreenTextGrab/Models/OCRResult.swift +++ b/ScreenTextGrab/Models/OCRResult.swift @@ -49,15 +49,18 @@ struct OCRResult: Sendable { /// dikey boşlukları boş satırlarla ifade eder. /// - Parameter columns: Çıktı toplam sütun sayısı (varsayılan 120). /// Yatay hizalama doğruluğunu kontrol eder. - func monospaceAlignedText(columns: Int = 120) -> String { + func monospaceAlignedText(columns: Int = 120, multicolumnSorting: Bool = false) -> String { guard !blocks.isEmpty else { return "" } let resolvedColumns = max(20, min(300, columns)) + // Çok sütunlu sıralama açıksa, boşluk ağacı ile insan okuma sırasını uygula + let orderedBlocks = multicolumnSorting ? readingOrderSortedBlocks() : blocks + // y'ye göre sırala (büyük y = ekranın üstü, y yakınsa x'e göre) - let sorted = blocks.sorted { a, b in + let sorted = orderedBlocks.sorted { a, b in if abs(a.boundingBox.midY - b.boundingBox.midY) < 0.02 { return a.boundingBox.minX < b.boundingBox.minX } @@ -128,6 +131,219 @@ struct OCRResult: Sendable { return output } + // MARK: - Gap Tree (Çok Sütunlu Okuma Sırası) + + /// Boşluk ağacı algoritması ile blokları insan okuma sırasına göre sıralar. + /// Dikey boşlukları sütun sınırı olarak algılar, önce sol sütunu tamamen + /// yukarıdan aşağıya okur, sonra sağ sütuna geçer. + func readingOrderSortedBlocks() -> [OCRTextBlock] { + guard blocks.count > 1 else { + return blocks + } + let tree = buildLayoutTree(blocks) + return traverseLayoutTree(tree) + } + + private indirect enum LayoutNode { + case leaf([OCRTextBlock]) + case verticalSplit(left: LayoutNode, right: LayoutNode) + case horizontalSplit(top: LayoutNode, bottom: LayoutNode) + } + + /// Dikey kesimleri algılar: birden fazla satır bandında sürekli boşluk olan x konumları + private func detectVerticalCuts(_ blocks: [OCRTextBlock]) -> [CGFloat] { + guard blocks.count >= 4 else { + return [] + } + + let minY = blocks.map { $0.boundingBox.minY }.min() ?? 0 + let maxY = blocks.map { $0.boundingBox.maxY }.max() ?? 1 + let totalHeight = maxY - minY + guard totalHeight > 0.05 else { + return [] + } + + // Y eksenini bandlara böl, her bandda boşluk olan x aralıklarını topla + let bandCount = 20 + let bandHeight = totalHeight / CGFloat(bandCount) + var gapSupport: [ClosedRange: Int] = [:] + + for bandIndex in 0.. bandMinY && block.boundingBox.minY < bandMaxY + } + .map { block in + block.boundingBox.minX...block.boundingBox.maxX + } + .sorted { $0.lowerBound < $1.lowerBound } + + guard !occupiedRanges.isEmpty else { + continue + } + + // Boşluk aralıklarını bul + var currentEnd = occupiedRanges[0].upperBound + for range in occupiedRanges.dropFirst() { + if range.lowerBound > currentEnd { + let gap = currentEnd...range.lowerBound + gapSupport[gap] = (gapSupport[gap] ?? 0) + 1 + } + currentEnd = max(currentEnd, range.upperBound) + } + } + + // En az %40 bandda desteklenen boşlukları kesim olarak kabul et + let minSupport = max(2, Int(Double(bandCount) * 0.4)) + let cuts = gapSupport + .filter { $0.value >= minSupport } + .map { ($0.key.lowerBound + $0.key.upperBound) / 2 } + .sorted() + + // Birbirine çok yakın kesimleri birleştir + var merged: [CGFloat] = [] + for cut in cuts { + if let last = merged.last, abs(cut - last) < 0.03 { + merged[merged.count - 1] = (last + cut) / 2 + } else { + merged.append(cut) + } + } + + return merged + } + + /// Yatay kesimleri algılar: büyük dikey boşluklar (paragraf araları) + private func detectHorizontalCuts(_ blocks: [OCRTextBlock]) -> [CGFloat] { + guard blocks.count >= 3 else { + return [] + } + + // Satırlara kümüle + let sorted = blocks.sorted { $0.boundingBox.midY > $1.boundingBox.midY } + var rows: [[OCRTextBlock]] = [] + var currentRow: [OCRTextBlock] = [] + for block in sorted { + if let first = currentRow.first, + abs(block.boundingBox.midY - first.boundingBox.midY) < 0.02 { + currentRow.append(block) + } else { + if !currentRow.isEmpty { + rows.append(currentRow) + } + currentRow = [block] + } + } + if !currentRow.isEmpty { + rows.append(currentRow) + } + + guard rows.count >= 2 else { + return [] + } + + // Satır arası boşlukları hesapla, medyanın 2xinden büyükleri kesim + let gaps = zip(rows, rows.dropFirst()).map { topRow, bottomRow in + let topMinY = topRow.map { $0.boundingBox.minY }.min() ?? 0 + let bottomMaxY = bottomRow.map { $0.boundingBox.maxY }.max() ?? 0 + return topMinY - bottomMaxY + } + + let sortedGaps = gaps.filter { $0 > 0 }.sorted() + guard !sortedGaps.isEmpty else { + return [] + } + let medianGap = sortedGaps[sortedGaps.count / 2] + let threshold = max(medianGap * 2.0, 0.04) + + var cuts: [CGFloat] = [] + for (index, gap) in gaps.enumerated() where gap >= threshold { + let topRow = rows[index] + let bottomRow = rows[index + 1] + let topMinY = topRow.map { $0.boundingBox.minY }.min() ?? 0 + let bottomMaxY = bottomRow.map { $0.boundingBox.maxY }.max() ?? 0 + cuts.append((topMinY + bottomMaxY) / 2) + } + + return cuts + } + + /// Layout ağacını özyinelemeli olarak oluşturur + private func buildLayoutTree(_ blocks: [OCRTextBlock]) -> LayoutNode { + guard blocks.count > 1 else { + return .leaf(blocks) + } + + // Önce dikey kesim (sütun) dene + let verticalCuts = detectVerticalCuts(blocks) + if !verticalCuts.isEmpty { + let split = splitBlocksByVerticalCuts(blocks, cuts: verticalCuts) + if split.count >= 2, split.allSatisfy({ !$0.isEmpty }) { + let leftTree = buildLayoutTree(split[0]) + let rightTree = buildLayoutTree(Array(split.dropFirst().joined())) + return .verticalSplit(left: leftTree, right: rightTree) + } + } + + // Sonra yatay kesim (satır grubu) dene + let horizontalCuts = detectHorizontalCuts(blocks) + if !horizontalCuts.isEmpty { + let split = splitBlocksByHorizontalCuts(blocks, cuts: horizontalCuts) + if split.count >= 2, split.allSatisfy({ !$0.isEmpty }) { + let topTree = buildLayoutTree(split[0]) + let bottomTree = buildLayoutTree(Array(split.dropFirst().joined())) + return .horizontalSplit(top: topTree, bottom: bottomTree) + } + } + + return .leaf(blocks) + } + + private func splitBlocksByVerticalCuts(_ blocks: [OCRTextBlock], cuts: [CGFloat]) -> [[OCRTextBlock]] { + guard !cuts.isEmpty else { + return [blocks] + } + + var groups: [[OCRTextBlock]] = Array(repeating: [], count: cuts.count + 1) + for block in blocks { + let midX = block.boundingBox.midX + let groupIndex = cuts.firstIndex { midX < $0 } ?? cuts.count + groups[groupIndex].append(block) + } + return groups + } + + private func splitBlocksByHorizontalCuts(_ blocks: [OCRTextBlock], cuts: [CGFloat]) -> [[OCRTextBlock]] { + guard !cuts.isEmpty else { + return [blocks] + } + + var groups: [[OCRTextBlock]] = Array(repeating: [], count: cuts.count + 1) + for block in blocks { + let midY = block.boundingBox.midY + // Büyük y = üstte, ilk kesimden büyükse üst grupta + let groupIndex = cuts.firstIndex { midY < $0 } ?? cuts.count + groups[groupIndex].append(block) + } + return groups + } + + /// Ağacı pre-order dolaşarak okuma sırasını elde et + private func traverseLayoutTree(_ node: LayoutNode) -> [OCRTextBlock] { + switch node { + case .leaf(let blocks): + return blocks + case .verticalSplit(let left, let right): + return traverseLayoutTree(left) + traverseLayoutTree(right) + case .horizontalSplit(let top, let bottom): + return traverseLayoutTree(top) + traverseLayoutTree(bottom) + } + } + private var standardFormattedText: String { groupedLines(lineThreshold: 0.03) .map { line in diff --git a/ScreenTextGrab/Services/CaptureCoordinator.swift b/ScreenTextGrab/Services/CaptureCoordinator.swift index 01704c2..052d153 100644 --- a/ScreenTextGrab/Services/CaptureCoordinator.swift +++ b/ScreenTextGrab/Services/CaptureCoordinator.swift @@ -1201,7 +1201,8 @@ final class CaptureCoordinator: CaptureCoordinating { } let monospaceText = selection.ocrResult.monospaceAlignedText( - columns: appState.monospaceLayoutSettings.columns + columns: appState.monospaceLayoutSettings.columns, + multicolumnSorting: appState.monospaceLayoutSettings.multicolumnSortingEnabled ) return monospaceText.isEmpty ? selection.text : monospaceText } @@ -1520,7 +1521,8 @@ final class CaptureCoordinator: CaptureCoordinating { let effectiveRawText: String if appState.monospaceLayoutSettings.isEnabled { let monospaceText = selection.ocrResult.monospaceAlignedText( - columns: appState.monospaceLayoutSettings.columns + columns: appState.monospaceLayoutSettings.columns, + multicolumnSorting: appState.monospaceLayoutSettings.multicolumnSortingEnabled ) effectiveRawText = monospaceText.isEmpty ? rawText : monospaceText } else { diff --git a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift index 46c2aaf..6d910e0 100644 --- a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift +++ b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift @@ -11,6 +11,7 @@ struct SettingsGeneralTabView: View { let monospaceLayoutEnabledBinding: Binding let monospaceLayoutColumns: Int let onMonospaceLayoutColumnsChange: (Int) -> Void + let monospaceMulticolumnSortingBinding: Binding let isRecordingHotkey: Bool let hotkeyDisplayLabel: String let hotkeyFeedback: HotkeyFeedback? @@ -159,6 +160,22 @@ struct SettingsGeneralTabView: View { Text(L10n.pair("Daha fazla sütun = daha hassas yatay hizalama.", "More columns = more precise horizontal alignment.")) .font(.system(size: 10.5, weight: .medium, design: .rounded)) .foregroundStyle(.secondary) + + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 1) + .padding(.vertical, 4) + + Toggle(isOn: monospaceMulticolumnSortingBinding) { + Text(L10n.pair("Çok sütunlu okuma sırası", "Multi-column reading order")) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + } + .toggleStyle(.switch) + .tint(.accentMint) + + Text(L10n.pair("Boşluk ağacı ile sütunları algılar, sol sütunu tamamen okur sonra sağ sütuna geçer.", "Detects columns with a gap tree; reads the left column fully before the right column.")) + .font(.system(size: 10.5, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) } .padding(.top, 8) } diff --git a/ScreenTextGrab/Views/SettingsView.swift b/ScreenTextGrab/Views/SettingsView.swift index d92ca8a..ee769a1 100644 --- a/ScreenTextGrab/Views/SettingsView.swift +++ b/ScreenTextGrab/Views/SettingsView.swift @@ -156,6 +156,10 @@ struct SettingsView: View { ), monospaceLayoutColumns: appState.monospaceLayoutSettings.columns, onMonospaceLayoutColumnsChange: { appState.setMonospaceLayoutColumns($0) }, + monospaceMulticolumnSortingBinding: Binding( + get: { appState.monospaceLayoutSettings.multicolumnSortingEnabled }, + set: { appState.setMonospaceMulticolumnSorting($0) } + ), isRecordingHotkey: isRecordingHotkey, hotkeyDisplayLabel: appState.hotkeyDisplayLabel, hotkeyFeedback: hotkeyFeedback, diff --git a/ScreenTextGrabTests/OCRResultTests.swift b/ScreenTextGrabTests/OCRResultTests.swift index c09e51e..649a7d9 100644 --- a/ScreenTextGrabTests/OCRResultTests.swift +++ b/ScreenTextGrabTests/OCRResultTests.swift @@ -320,4 +320,86 @@ final class OCRResultTests: XCTestCase { XCTAssertTrue(outputLow.hasSuffix("Test")) XCTAssertTrue(outputHigh.hasSuffix("Test")) } + + // MARK: - Gap Tree Multi-column Sorting + + func testReadingOrderSortedTwoColumns() { + // Sol sütun: 3 satır, sağ sütun: 3 satır + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "L1", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.85, width: 0.06, height: 0.04)), + OCRTextBlock(text: "L2", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.75, width: 0.06, height: 0.04)), + OCRTextBlock(text: "L3", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.65, width: 0.06, height: 0.04)), + OCRTextBlock(text: "R1", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.85, width: 0.06, height: 0.04)), + OCRTextBlock(text: "R2", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.75, width: 0.06, height: 0.04)), + OCRTextBlock(text: "R3", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.65, width: 0.06, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let sorted = result.readingOrderSortedBlocks() + let texts = sorted.map(\.text) + + // Sol sütun tamamen önce, sonra sağ sütun + XCTAssertEqual(texts, ["L1", "L2", "L3", "R1", "R2", "R3"]) + } + + func testReadingOrderSortedSingleColumnReturnsOriginal() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "A", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.80, width: 0.04, height: 0.04)), + OCRTextBlock(text: "B", confidence: 0.95, boundingBox: CGRect(x: 0.10, y: 0.70, width: 0.04, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let sorted = result.readingOrderSortedBlocks() + XCTAssertEqual(sorted.map(\.text), ["A", "B"]) + } + + func testMonospaceWithMulticolumnSorting() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Left1", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.85, width: 0.10, height: 0.04)), + OCRTextBlock(text: "Left2", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.75, width: 0.10, height: 0.04)), + OCRTextBlock(text: "Right1", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.85, width: 0.12, height: 0.04)), + OCRTextBlock(text: "Right2", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.75, width: 0.12, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText(columns: 120, multicolumnSorting: true) + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Sol sütun satırları önce, sonra sağ sütun satırları + XCTAssertTrue(lines[0].contains("Left1")) + XCTAssertTrue(lines[1].contains("Left2")) + XCTAssertTrue(lines[2].contains("Right1")) + XCTAssertTrue(lines[3].contains("Right2")) + } + + func testMonospaceWithoutMulticolumnSortingInterleavesRows() { + let result = OCRResult( + blocks: [ + OCRTextBlock(text: "Left1", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.85, width: 0.10, height: 0.04)), + OCRTextBlock(text: "Left2", confidence: 0.95, boundingBox: CGRect(x: 0.05, y: 0.75, width: 0.10, height: 0.04)), + OCRTextBlock(text: "Right1", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.85, width: 0.12, height: 0.04)), + OCRTextBlock(text: "Right2", confidence: 0.95, boundingBox: CGRect(x: 0.60, y: 0.75, width: 0.12, height: 0.04)) + ], + captureDate: Date(), + sourceRect: .zero + ) + + let output = result.monospaceAlignedText(columns: 120, multicolumnSorting: false) + let lines = output.components(separatedBy: "\n").filter { !$0.isEmpty } + + // Sıralama kapalıyken aynı y'deki bloklar aynı satırda birleşir + XCTAssertTrue(lines[0].contains("Left1")) + XCTAssertTrue(lines[0].contains("Right1")) + XCTAssertTrue(lines[1].contains("Left2")) + XCTAssertTrue(lines[1].contains("Right2")) + } }