diff --git a/Docs/ImportButtonIntegration.md b/Docs/ImportButtonIntegration.md new file mode 100644 index 0000000..76d41cb --- /dev/null +++ b/Docs/ImportButtonIntegration.md @@ -0,0 +1,219 @@ +# Integrating SwiftMarkItDown with an Import Button + +Use this guide when an iOS, iPadOS, or macOS app wants an **Import** button that lets users pick a document or image and converts the selected input to Markdown with `SwiftMarkItDown`. + +The library exposes a small synchronous API: + +```swift +let request = ConversionRequest( + data: data, + fileName: fileName, + contentType: contentType +) +let document = try MarkItDown().convert(request) +let markdown = document.markdown +``` + +Image OCR is automatic for supported image formats when the app is running on Apple platforms with Vision, CoreGraphics, and ImageIO available. On platforms without those frameworks, image formats are still recognized but conversion returns `unsupportedFormat`. + +## Supported import inputs + +The default converter pipeline can handle these inputs from an import button: + +| Category | Extensions | Notes | +| --- | --- | --- | +| Text | `txt`, `text`, `md`, `markdown` | Decoded as text and cleaned up. | +| Web | `html`, `htm` | Converted to Markdown for common tags. | +| Data | `csv`, `json` | Converted to tables or nested bullets. | +| Images | `png`, `jpg`, `jpeg`, `heic`, `heif`, `tif`, `tiff`, `gif` | Uses Apple Vision OCR where available. | + +PDF, DOCX, PPTX, and XLSX are recognized by `DocumentFormat`, but still return `unsupportedFormat` until their converter modules are implemented. + +## SwiftUI document import button + +For most apps, start with SwiftUI's `fileImporter`. It opens the system document picker, reads the selected file into `Data`, and hands that payload to `SwiftMarkItDown`. + +```swift +import SwiftMarkItDown +import SwiftUI +import UniformTypeIdentifiers + +struct ImportMarkdownButton: View { + @State private var isImporting = false + @State private var markdown = "" + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Button("Import") { + isImporting = true + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + ScrollView { + Text(markdown) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } + .fileImporter( + isPresented: $isImporting, + allowedContentTypes: SwiftMarkItDownImportTypes.allowedDocumentTypes, + allowsMultipleSelection: false + ) { result in + Task { + await importSelection(result) + } + } + } + + @MainActor + private func importSelection(_ result: Result<[URL], Error>) async { + do { + guard let url = try result.get().first else { return } + let converted = try await SwiftMarkItDownImporter.convertFile(url) + markdown = converted.markdown + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} +``` + +## Import helper + +Use a small helper to keep file access, content type inference, and background conversion out of the view. The security-scoped-resource calls are important for files returned by the document picker. + +```swift +import Foundation +import SwiftMarkItDown +import UniformTypeIdentifiers + +enum SwiftMarkItDownImporter { + static func convertFile(_ url: URL) async throws -> MarkdownDocument { + try await Task.detached(priority: .userInitiated) { + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + let data = try Data(contentsOf: url) + let contentType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType + + let request = ConversionRequest( + data: data, + fileName: url.lastPathComponent, + contentType: contentType + ) + return try MarkItDown().convert(request) + }.value + } +} +``` + +## Allowed document types + +Expose the formats your app wants the system picker to show. This list includes current converters plus image OCR inputs. + +```swift +import UniformTypeIdentifiers + +enum SwiftMarkItDownImportTypes { + static let allowedDocumentTypes: [UTType] = [ + .plainText, + .text, + .utf8PlainText, + .html, + .commaSeparatedText, + .json, + .png, + .jpeg, + .heic, + .tiff, + .gif + ] +} +``` + +If your app wants to display future/reserved file types in the picker, add their UTTypes and handle `ConversionError.unsupportedFormat` in the error UI. + +## Photo-library import button + +If your app's import action should specifically pick a photo instead of opening the document picker, use `PhotosPicker` and pass the selected image data to `SwiftMarkItDown` with an image filename or format hint. + +```swift +import PhotosUI +import SwiftMarkItDown +import SwiftUI + +struct PhotoOCRImportButton: View { + @State private var selectedItem: PhotosPickerItem? + @State private var markdown = "" + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + PhotosPicker("Import Photo", selection: $selectedItem, matching: .images) + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + } + + Text(markdown) + .textSelection(.enabled) + } + .onChange(of: selectedItem) { _, item in + Task { + await importPhoto(item) + } + } + } + + @MainActor + private func importPhoto(_ item: PhotosPickerItem?) async { + do { + guard let data = try await item?.loadTransferable(type: Data.self) else { return } + let request = ConversionRequest( + data: data, + fileName: "photo.jpeg", + contentType: "image/jpeg" + ) + let document = try await Task.detached(priority: .userInitiated) { + try MarkItDown().convert(request) + }.value + + markdown = document.markdown + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} +``` + +## Error handling recommendations + +Handle these cases in the app's import UI: + +- `unsupportedFormat`: show a friendly message that the selected file type is recognized but not implemented on this platform or in this release. +- `malformedInput`: show a message that the file could not be decoded, parsed, or recognized. +- Empty OCR output: keep the import successful but tell the user that no readable text was detected. +- Large files or images: run conversion off the main actor, as shown above, and show progress or a spinner. + +## Where to send the Markdown + +After `MarkItDown().convert(...)` returns, use `document.markdown` wherever your app already stores imported content: + +- populate an editor buffer, +- attach it to a note, +- save it to a local Markdown file, +- send it to a share sheet, +- or pass it into your app's search/indexing pipeline. diff --git a/README.md b/README.md index bb463a5..4394adb 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,12 @@ The current MVP is intentionally small and deterministic so it can run fully on- - `html` to Markdown for common headings, inline emphasis, links, code, paragraphs, and list items, with document ``, script, and style content ignored. - `csv` to GitHub-Flavored Markdown tables, including quoted fields and escaped pipes. - `json` to nested Markdown bullets with stable key ordering. +- Apple-platform image OCR for `png`, `jpg`/`jpeg`, `heic`, `tiff`, and `gif` inputs using Vision text recognition, returning recognized lines as Markdown text. - A CLI wrapper for local/manual conversion checks. - A SwiftUI iOS demo app for editing sample input and converting it to Markdown in the simulator. +Image OCR is available when the package is built on platforms that provide Vision, CoreGraphics, and ImageIO. On other platforms, image formats are recognized but return `unsupportedFormat`. + PDF, DOCX, PPTX, and XLSX are represented in the format model but still return `unsupportedFormat` until their native converter modules are implemented. ## Repository layout @@ -44,6 +47,10 @@ let document = try MarkItDown().convert(request) print(document.markdown) ``` +## Import button integration + +See [Docs/ImportButtonIntegration.md](Docs/ImportButtonIntegration.md) for SwiftUI `fileImporter` and `PhotosPicker` examples that wire an app's Import button into `SwiftMarkItDown`, including image OCR inputs. + ## CLI usage ```bash @@ -64,7 +71,8 @@ GitHub Actions runs the same checks on pushes to `main`, pull requests, and manu ## Roadmap 1. Expand the text/HTML/CSV/JSON converters with richer Markdown normalization and metadata extraction. -2. Add a ZIP/OpenXML package reader as shared infrastructure for DOCX, PPTX, and XLSX. -3. Implement DOCX paragraph, heading, table, hyperlink, and image-reference extraction. -4. Add PDFKit/Vision-backed PDF text and OCR extraction for Apple platforms behind conditional compilation. -5. Evolve the demo into a more complete iOS MVP with document picker import, share/export flows, progress reporting, and a pluggable backend escape hatch for heavyweight conversions. +2. Improve OCR layout reconstruction for headings, lists, tables, and multi-column scans. +3. Add a ZIP/OpenXML package reader as shared infrastructure for DOCX, PPTX, and XLSX. +4. Implement DOCX paragraph, heading, table, hyperlink, and image-reference extraction. +5. Add PDFKit/Vision-backed PDF text and OCR extraction for Apple platforms behind conditional compilation. +6. Evolve the demo into a more complete iOS MVP with document picker import, share/export flows, progress reporting, and a pluggable backend escape hatch for heavyweight conversions. diff --git a/Sources/SwiftMarkItDown/Converters/ImageOCRConverter.swift b/Sources/SwiftMarkItDown/Converters/ImageOCRConverter.swift new file mode 100644 index 0000000..bc9e566 --- /dev/null +++ b/Sources/SwiftMarkItDown/Converters/ImageOCRConverter.swift @@ -0,0 +1,63 @@ +import Foundation + +#if canImport(Vision) && canImport(CoreGraphics) && canImport(ImageIO) +import CoreGraphics +import ImageIO +import Vision +#endif + +/// Uses Apple Vision text recognition to extract Markdown-ready text from images. +public struct ImageOCRConverter: DocumentConverter { + public let supportedFormats: Set = DocumentFormat.imageFormats + + public init() {} + + public func convert(_ request: ConversionRequest, format: DocumentFormat) throws -> MarkdownDocument { + #if canImport(Vision) && canImport(CoreGraphics) && canImport(ImageIO) + guard let imageSource = CGImageSourceCreateWithData(request.data as CFData, nil), + let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + throw ConversionError.malformedInput("The input could not be decoded as an image.") + } + + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = true + request.minimumTextHeight = 0.01 + + let handler = VNImageRequestHandler(cgImage: image, options: [:]) + try handler.perform([request]) + + let lines = (request.results ?? []) + .compactMap { observation -> RecognizedTextLine? in + guard let candidate = observation.topCandidates(1).first else { return nil } + let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + return RecognizedTextLine(text: text, bounds: observation.boundingBox, confidence: candidate.confidence) + } + .sorted { lhs, rhs in + let verticalDelta = abs(lhs.bounds.midY - rhs.bounds.midY) + if verticalDelta > 0.02 { + return lhs.bounds.midY > rhs.bounds.midY + } + return lhs.bounds.minX < rhs.bounds.minX + } + + let markdown = lines.map(\.text).joined(separator: "\n").smid_trimmedBlankLines + return MarkdownDocument( + markdown: markdown, + sourceFormat: format, + metadata: ["recognizedTextLineCount": String(lines.count)] + ) + #else + throw ConversionError.unsupportedFormat(format) + #endif + } +} + +#if canImport(Vision) && canImport(CoreGraphics) && canImport(ImageIO) +private struct RecognizedTextLine { + let text: String + let bounds: CGRect + let confidence: VNConfidence +} +#endif diff --git a/Sources/SwiftMarkItDown/DocumentFormat.swift b/Sources/SwiftMarkItDown/DocumentFormat.swift index 7c9eb98..d84ce06 100644 --- a/Sources/SwiftMarkItDown/DocumentFormat.swift +++ b/Sources/SwiftMarkItDown/DocumentFormat.swift @@ -7,6 +7,11 @@ public enum DocumentFormat: String, CaseIterable, Sendable { case html = "html" case csv = "csv" case json = "json" + case png = "png" + case jpeg = "jpg" + case heic = "heic" + case tiff = "tiff" + case gif = "gif" case pdf = "pdf" case docx = "docx" case pptx = "pptx" @@ -29,6 +34,11 @@ public enum DocumentFormat: String, CaseIterable, Sendable { case "htm", "html": return .html case "csv": return .csv case "json": return .json + case "png": return .png + case "jpg", "jpeg": return .jpeg + case "heic", "heif": return .heic + case "tif", "tiff": return .tiff + case "gif": return .gif case "pdf": return .pdf case "docx": return .docx case "pptx": return .pptx @@ -44,6 +54,11 @@ public enum DocumentFormat: String, CaseIterable, Sendable { case "text/html", "application/xhtml+xml": .html case "text/csv", "application/csv": .csv case "application/json", "text/json": .json + case "image/png": .png + case "image/jpeg", "image/jpg": .jpeg + case "image/heic", "image/heif": .heic + case "image/tiff": .tiff + case "image/gif": .gif case "application/pdf": .pdf case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": .docx case "application/vnd.openxmlformats-officedocument.presentationml.presentation": .pptx @@ -52,3 +67,7 @@ public enum DocumentFormat: String, CaseIterable, Sendable { } } } + +public extension DocumentFormat { + static let imageFormats: Set = [.png, .jpeg, .heic, .tiff, .gif] +} diff --git a/Sources/SwiftMarkItDown/MarkItDown.swift b/Sources/SwiftMarkItDown/MarkItDown.swift index 8047697..d38f208 100644 --- a/Sources/SwiftMarkItDown/MarkItDown.swift +++ b/Sources/SwiftMarkItDown/MarkItDown.swift @@ -13,7 +13,8 @@ public struct MarkItDown: Sendable { PlainTextConverter(), HTMLConverter(), CSVConverter(), - JSONConverter() + JSONConverter(), + ImageOCRConverter() ] } diff --git a/Tests/SwiftMarkItDownTests/SwiftMarkItDownTests.swift b/Tests/SwiftMarkItDownTests/SwiftMarkItDownTests.swift index fec71d2..bd4cecf 100644 --- a/Tests/SwiftMarkItDownTests/SwiftMarkItDownTests.swift +++ b/Tests/SwiftMarkItDownTests/SwiftMarkItDownTests.swift @@ -2,12 +2,30 @@ import Foundation import Testing @testable import SwiftMarkItDown +#if canImport(Vision) && canImport(CoreGraphics) && canImport(CoreText) && canImport(ImageIO) +import CoreGraphics +import CoreText +import ImageIO +import Vision +#endif + @Suite("SwiftMarkItDown core conversions") struct SwiftMarkItDownTests { @Test("infers format from extension") func infersFormatFromExtension() { #expect(DocumentFormat.infer(fileName: "report.csv", contentType: nil) == .csv) #expect(DocumentFormat.infer(fileName: "deck.pptx", contentType: nil) == .pptx) + #expect(DocumentFormat.infer(fileName: "scan.png", contentType: nil) == .png) + #expect(DocumentFormat.infer(fileName: "receipt.jpeg", contentType: nil) == .jpeg) + #expect(DocumentFormat.infer(fileName: "photo.HEIC", contentType: nil) == .heic) + } + + @Test("infers image formats from content type") + func infersImageFormatsFromContentType() { + #expect(DocumentFormat.infer(fileName: nil, contentType: "image/png") == .png) + #expect(DocumentFormat.infer(fileName: nil, contentType: "image/jpeg; charset=binary") == .jpeg) + #expect(DocumentFormat.infer(fileName: nil, contentType: "image/heic") == .heic) + #expect(DocumentFormat.infer(fileName: nil, contentType: "image/tiff") == .tiff) } @Test("converts plain text without surrounding blank lines") @@ -42,6 +60,31 @@ struct SwiftMarkItDownTests { #expect(document.markdown == "- **formats**:\n - txt\n - html\n- **title**: Roadmap") } + #if canImport(Vision) && canImport(CoreGraphics) && canImport(CoreText) && canImport(ImageIO) + @Test("uses Vision OCR to convert rendered images to Markdown") + func convertsRenderedImagesWithVisionOCR() throws { + for sample in try renderedOCRSamples() { + let request = ConversionRequest(data: sample.data, fileName: sample.fileName) + let document = try MarkItDown().convert(request) + let normalized = document.markdown.uppercased() + + #expect(document.sourceFormat == sample.format) + #expect(normalized.contains("SWIFT")) + #expect(normalized.contains("OCR")) + #expect(normalized.contains("MARKDOWN")) + #expect(document.metadata["recognizedTextLineCount"] != "0") + } + } + #else + @Test("throws unsupported for images when Vision OCR is unavailable") + func throwsForImagesWhenVisionOCRIsUnavailable() throws { + let request = ConversionRequest(data: Data(), fileName: "scan.png") + #expect(throws: ConversionError.unsupportedFormat(.png)) { + try MarkItDown().convert(request) + } + } + #endif + @Test("throws for reserved but unimplemented formats") func throwsForUnimplementedFormats() throws { let request = ConversionRequest(data: Data(), fileName: "paper.pdf") @@ -50,3 +93,70 @@ struct SwiftMarkItDownTests { } } } + +#if canImport(Vision) && canImport(CoreGraphics) && canImport(CoreText) && canImport(ImageIO) +private struct OCRSample { + let fileName: String + let format: DocumentFormat + let data: Data +} + +private func renderedOCRSamples() throws -> [OCRSample] { + [ + OCRSample(fileName: "ocr-sample.png", format: .png, data: try renderOCRImage(typeIdentifier: "public.png" as CFString)), + OCRSample(fileName: "ocr-sample.jpg", format: .jpeg, data: try renderOCRImage(typeIdentifier: "public.jpeg" as CFString)) + ] +} + +private func renderOCRImage(typeIdentifier: CFString) throws -> Data { + let width = 1_200 + let height = 520 + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + throw ConversionError.malformedInput("Could not create OCR test image context.") + } + + context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.setAllowsAntialiasing(true) + context.setShouldAntialias(true) + + let font = CTFontCreateWithName("Helvetica-Bold" as CFString, 112, nil) + let attributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key(kCTFontAttributeName as String): font, + NSAttributedString.Key(kCTForegroundColorAttributeName as String): CGColor(red: 0, green: 0, blue: 0, alpha: 1) + ] + + for (index, line) in ["SWIFT OCR", "MARKDOWN"].enumerated() { + let attributed = NSAttributedString(string: line, attributes: attributes) + let textLine = CTLineCreateWithAttributedString(attributed) + context.textPosition = CGPoint(x: 80, y: height - 170 - (index * 150)) + CTLineDraw(textLine, context) + } + + guard let image = context.makeImage() else { + throw ConversionError.malformedInput("Could not render OCR test image.") + } + + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(output, typeIdentifier, 1, nil) else { + throw ConversionError.malformedInput("Could not create OCR test image destination.") + } + + let options = [kCGImageDestinationLossyCompressionQuality as String: 0.95] as CFDictionary + CGImageDestinationAddImage(destination, image, options) + guard CGImageDestinationFinalize(destination) else { + throw ConversionError.malformedInput("Could not encode OCR test image.") + } + + return output as Data +} +#endif