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 2b2638e..2707036 100644 --- a/ScreenTextGrab/Models/AppModels.swift +++ b/ScreenTextGrab/Models/AppModels.swift @@ -654,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/OCRResult.swift b/ScreenTextGrab/Models/OCRResult.swift index bf6ba73..a155fd2 100644 --- a/ScreenTextGrab/Models/OCRResult.swift +++ b/ScreenTextGrab/Models/OCRResult.swift @@ -43,6 +43,91 @@ 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) -> String { + guard !blocks.isEmpty else { + return "" + } + + let resolvedColumns = max(20, min(300, columns)) + + // 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 + } + + // 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 + } + 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..01704c2 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,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, @@ -1504,8 +1517,18 @@ 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, 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 04e5dd6..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, diff --git a/ScreenTextGrabTests/OCRResultTests.swift b/ScreenTextGrabTests/OCRResultTests.swift index 1909a76..c09e51e 100644 --- a/ScreenTextGrabTests/OCRResultTests.swift +++ b/ScreenTextGrabTests/OCRResultTests.swift @@ -187,4 +187,137 @@ 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")) + } }