diff --git a/ScreenTextGrab/AppState.swift b/ScreenTextGrab/AppState.swift index fbd1491..3290db5 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,33 @@ final class AppState: ObservableObject { persistCaptureOutputPresetIfNeeded() } + func setMonospaceLayoutEnabled(_ enabled: Bool) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: enabled, + columns: monospaceLayoutSettings.columns, + multicolumnSortingEnabled: monospaceLayoutSettings.multicolumnSortingEnabled + ) + persistMonospaceLayoutSettingsIfNeeded() + } + + func setMonospaceLayoutColumns(_ columns: Int) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: monospaceLayoutSettings.isEnabled, + columns: columns, + multicolumnSortingEnabled: monospaceLayoutSettings.multicolumnSortingEnabled + ) + persistMonospaceLayoutSettingsIfNeeded() + } + + func setMonospaceMulticolumnSorting(_ enabled: Bool) { + monospaceLayoutSettings = MonospaceLayoutSettings( + isEnabled: monospaceLayoutSettings.isEnabled, + columns: monospaceLayoutSettings.columns, + multicolumnSortingEnabled: enabled + ) + persistMonospaceLayoutSettingsIfNeeded() + } + func setInterfaceLanguage(_ language: InterfaceLanguage) { interfaceLanguage = language persistInterfaceLanguageIfNeeded() @@ -647,6 +678,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 2b2638e..2a43d45 100644 --- a/ScreenTextGrab/Models/AppModels.swift +++ b/ScreenTextGrab/Models/AppModels.swift @@ -654,6 +654,58 @@ 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, + multicolumnSortingEnabled: false + ) + + 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 + let multicolumnSortingEnabled = try container.decodeIfPresent(Bool.self, forKey: .multicolumnSortingEnabled) ?? false + self.init(isEnabled: isEnabled, columns: columns, multicolumnSortingEnabled: multicolumnSortingEnabled) + } +} + +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/OCRResult.swift b/ScreenTextGrab/Models/OCRResult.swift index bf6ba73..c5f9f02 100644 --- a/ScreenTextGrab/Models/OCRResult.swift +++ b/ScreenTextGrab/Models/OCRResult.swift @@ -43,6 +43,307 @@ struct OCRResult: Sendable { } } + /// Eş aralıklı (monospace) hizalı metin üretir. + /// Metin bloklarının boundingBox konumlarını kullanarak satırlara ayırır, + /// 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, 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 = orderedBlocks.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 + } + + // 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] + } + } + if !currentRow.isEmpty { + rows.append(currentRow) + } + + // 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] + + var output = "" + var lastRowTopY: CGFloat? + + 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)) + } + } + + // 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 + } + output += line + "\n" + lastRowTopY = rowTopY + } + + if output.hasSuffix("\n") { + output.removeLast() + } + + 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 bb51a72..052d153 100644 --- a/ScreenTextGrab/Services/CaptureCoordinator.swift +++ b/ScreenTextGrab/Services/CaptureCoordinator.swift @@ -1173,8 +1173,10 @@ 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, @@ -1193,6 +1195,18 @@ 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, + multicolumnSorting: appState.monospaceLayoutSettings.multicolumnSortingEnabled + ) + return monospaceText.isEmpty ? selection.text : monospaceText + } + nonisolated private static func evaluateCandidatesAsync( ocrService: any OCRProviding, @@ -1504,8 +1518,19 @@ 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, + multicolumnSorting: appState.monospaceLayoutSettings.multicolumnSortingEnabled + ) + 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, diff --git a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift index 3e3d601..6d910e0 100644 --- a/ScreenTextGrab/Views/Settings/SettingsTabViews.swift +++ b/ScreenTextGrab/Views/Settings/SettingsTabViews.swift @@ -8,6 +8,10 @@ struct SettingsGeneralTabView: View { let captureModeFeedback: InlineFeedback? let outputPresetDetail: String let outputPresetFeedback: InlineFeedback? + let monospaceLayoutEnabledBinding: Binding + let monospaceLayoutColumns: Int + let onMonospaceLayoutColumnsChange: (Int) -> Void + let monospaceMulticolumnSortingBinding: Binding let isRecordingHotkey: Bool let hotkeyDisplayLabel: String let hotkeyFeedback: HotkeyFeedback? @@ -126,6 +130,57 @@ 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) + + 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) + } + } + SettingsSectionCard( title: L10n.pair("Global Kısayol", "Global Shortcut"), subtitle: isRecordingHotkey diff --git a/ScreenTextGrab/Views/SettingsView.swift b/ScreenTextGrab/Views/SettingsView.swift index 04e5dd6..ee769a1 100644 --- a/ScreenTextGrab/Views/SettingsView.swift +++ b/ScreenTextGrab/Views/SettingsView.swift @@ -150,6 +150,16 @@ 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) }, + 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 1909a76..649a7d9 100644 --- a/ScreenTextGrabTests/OCRResultTests.swift +++ b/ScreenTextGrabTests/OCRResultTests.swift @@ -187,4 +187,219 @@ final class OCRResultTests: XCTestCase { "Kategori\tOcak\tSubat\tMart\nIcekler\t12\t14\t16\nToplam\t\t\t42" ) } + + // MARK: - Monospace Alignment + + 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)), + 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() + // 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) + } + + 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) + XCTAssertTrue(lines[0].hasSuffix("Hello")) + XCTAssertTrue(lines[1].hasSuffix("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") + XCTAssertTrue(lines.first?.hasSuffix("Top") == true) + XCTAssertTrue(lines.last?.hasSuffix("Bottom") == true) + 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 + ) + + let output = result.monospaceAlignedText() + XCTAssertTrue(output.hasSuffix("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() + let leftRange = output.range(of: "Left")! + let rightRange = output.range(of: "Right")! + XCTAssertLessThan(leftRange.lowerBound, rightRange.lowerBound) + } + + 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 + ) + + // 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")) + } + + // 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")) + } }