Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
219 changes: 219 additions & 0 deletions Docs/ImportButtonIntegration.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>`, 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
63 changes: 63 additions & 0 deletions Sources/SwiftMarkItDown/Converters/ImageOCRConverter.swift
Original file line number Diff line number Diff line change
@@ -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> = 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
19 changes: 19 additions & 0 deletions Sources/SwiftMarkItDown/DocumentFormat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -52,3 +67,7 @@ public enum DocumentFormat: String, CaseIterable, Sendable {
}
}
}

public extension DocumentFormat {
static let imageFormats: Set<DocumentFormat> = [.png, .jpeg, .heic, .tiff, .gif]
}
3 changes: 2 additions & 1 deletion Sources/SwiftMarkItDown/MarkItDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public struct MarkItDown: Sendable {
PlainTextConverter(),
HTMLConverter(),
CSVConverter(),
JSONConverter()
JSONConverter(),
ImageOCRConverter()
]
}

Expand Down
Loading
Loading