Skip to content
Open
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ try await doc.parse()
print(doc.exportedContent)
```

## Exporting (Markdown / LLM output → office files)

PicoDocs can also go the other way: turn LLM Markdown output (or a structured
`ConverterResult`) into a real office file via `PicoDocsEngine.write(...)`.

```swift
// From a Markdown string (e.g. an LLM response):
let docx = try PicoDocsEngine.write(markdown: markdown, to: .docx)
let xlsx = try PicoDocsEngine.write(markdown: markdown, to: .xlsx)
let pptx = try PicoDocsEngine.write(markdown: markdown, to: .pptx)
let rtf = try PicoDocsEngine.write(markdown: markdown, to: .rtf) // Apple platforms

// From a structured result (e.g. round-tripping an imported document):
let data = try PicoDocsEngine.write(result, to: .docx)
```

Notes:
- **DOCX/XLSX/PPTX** are written as OOXML (ZIP + XML) on all platforms. **RTF** uses
`NSAttributedString` and is available on Apple platforms.
- **Pages/Keynote** are intentionally not implemented — writing valid iWork files
third-party is unsupported (`ExportableFileType.isImplemented == false`); export to
DOCX/PPTX and let Pages/Keynote import it instead.
- Custom or additional writers can be registered via `DocumentExporterRegistry`,
mirroring `DocumentConverterRegistry` on the import side.

## Setup for Apps

### Info.plist Configuration
Expand Down
13 changes: 1 addition & 12 deletions Sources/PicoDocs/Converters/WordConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -615,18 +615,7 @@ public struct WordConverter: DocumentConverter {
}

private static func mimeType(forExtension ext: String) -> String {
switch ext.lowercased() {
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "gif": return "image/gif"
case "bmp": return "image/bmp"
case "tif", "tiff": return "image/tiff"
case "svg": return "image/svg+xml"
case "webp": return "image/webp"
case "emf": return "image/emf"
case "wmf": return "image/wmf"
default: return "application/octet-stream"
}
OfficeMediaType.mimeType(forExtension: ext)
}

// MARK: - Archive helpers
Expand Down
47 changes: 47 additions & 0 deletions Sources/PicoDocs/Exporters/AttributedStringDOCXExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// AttributedStringDOCXExporter.swift
// PicoDocs
//
// An *optional, opt-in* DOCX writer that uses Apple's `NSAttributedString`
// `.officeOpenXML` serializer (macOS only). It is intentionally NOT registered in
// `DocumentExporterRegistry.default`: the hand-rolled `WordprocessingMLExporter`
// is the primary, all-platform DOCX writer and the round-trip oracle. The repo
// already moved *off* the `NSAttributedString` DOCX path on the read side because
// it was "lossy, font-size heading guessing, and a hard throw on iOS"
// (`WordConverter.swift`), so this is kept only for opt-in use and comparison
// testing — register it explicitly via `registry.registering(...)` if you want it.
//
// `.officeOpenXML` writing is macOS-only; on other Apple platforms this throws
// `.platformUnavailable` so a registry can fall through.
//

#if canImport(AppKit) || canImport(UIKit)

import Foundation

public struct AttributedStringDOCXExporter: DocumentExporter {

public init() {}

public func accepts(_ format: ExportableFileType) -> Bool { format == .docx }

public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data {
guard format == .docx else { throw ExporterError.notAccepted }
#if canImport(AppKit)
let attributed = AttributedStringDocumentBuilder.attributedString(from: result)
do {
return try attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.officeOpenXML]
)
} catch {
throw ExporterError.serializationFailed("DOCX (officeOpenXML) serialization failed: \(error.localizedDescription)")
}
#else
// officeOpenXML writing is unavailable on iOS/tvOS/visionOS.
throw ExporterError.platformUnavailable
#endif
}
}

#endif
166 changes: 166 additions & 0 deletions Sources/PicoDocs/Exporters/AttributedStringDocumentBuilder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
//
// AttributedStringDocumentBuilder.swift
// PicoDocs
//
// Builds an `NSAttributedString` from a `ConverterResult` using the shared
// Markdown block + inline IR, so Apple's document writers can serialize it to RTF
// (`.rtf`) and DOCX (`.officeOpenXML`).
//
// Why build the attributed string directly instead of via HTML import: the
// `NSAttributedString(data:options:[.documentType:.html])` importer is WebKit-
// backed and must run on the main thread, which would make the exporters unusable
// from the engine's non-isolated, `Sendable` `write(...)`. Constructing runs from
// the IR keeps serialization thread-agnostic and pure CPU.
//
// Scope: prose fidelity (headings, bold/italic, links, lists, code, blockquotes,
// tables as tab-separated rows). Images are rendered as their alt text here — the
// hand-rolled OOXML exporters embed real image bytes; embedding via platform image
// attachments (NSImage vs UIImage) is intentionally avoided to keep this portable
// across AppKit/UIKit.
//

#if canImport(AppKit) || canImport(UIKit)

import Foundation

#if canImport(AppKit)
import AppKit
private typealias PlatformFont = NSFont
#elseif canImport(UIKit)
import UIKit
private typealias PlatformFont = UIFont
#endif

enum AttributedStringDocumentBuilder {

private static let baseSize: CGFloat = 12

/// Heading point sizes by level (1...6).
private static func headingSize(_ level: Int) -> CGFloat {
switch max(1, min(level, 6)) {
case 1: return 24
case 2: return 20
case 3: return 17
case 4: return 15
case 5: return 13
default: return 12
}
}

static func attributedString(from result: ConverterResult) -> NSAttributedString {
let output = NSMutableAttributedString()
let blocks = MarkdownBlockParser.parse(result.markdown())
for (index, block) in blocks.enumerated() {
append(block, to: output)
if index < blocks.count - 1 {
output.append(NSAttributedString(string: "\n"))
}
}
return output
}

// MARK: - Blocks

private static func append(_ block: MarkdownBlock, to output: NSMutableAttributedString) {
switch block {
case .heading(let level, let text):
output.append(inline(text, size: headingSize(level), bold: true))
output.append(NSAttributedString(string: "\n"))

case .paragraph(let text):
output.append(inline(text))
output.append(NSAttributedString(string: "\n"))

case .code(let code):
let attrs: [NSAttributedString.Key: Any] = [.font: monospacedFont(size: baseSize)]
output.append(NSAttributedString(string: code + "\n", attributes: attrs))

case .blockquote(let lines):
for line in lines {
output.append(inline(line, italic: true))
output.append(NSAttributedString(string: "\n"))
}

case .list(let ordered, let items):
for (i, item) in items.enumerated() {
let marker = ordered ? "\(i + 1).\t" : "•\t"
output.append(NSAttributedString(string: marker, attributes: [.font: bodyFont()]))
output.append(inline(item.replacingOccurrences(of: "\n", with: " ")))
output.append(NSAttributedString(string: "\n"))
}

case .table(let rows):
for row in rows {
output.append(inline(row.joined(separator: "\t")))
output.append(NSAttributedString(string: "\n"))
}

case .rule:
output.append(NSAttributedString(string: "————————\n", attributes: [.font: bodyFont()]))
}
}

// MARK: - Inline

private static func inline(_ markdown: String, size: CGFloat = baseSize, bold: Bool = false, italic: Bool = false) -> NSAttributedString {
let result = NSMutableAttributedString()
render(MarkdownInlineParser.parse(markdown), into: result, size: size, bold: bold, italic: italic, link: nil)
return result
}

private static func render(_ nodes: [MarkdownInline], into output: NSMutableAttributedString, size: CGFloat, bold: Bool, italic: Bool, link: String?) {
for node in nodes {
switch node {
case .text(let s):
output.append(NSAttributedString(string: s, attributes: attributes(size: size, bold: bold, italic: italic, monospace: false, link: link)))
case .code(let s):
output.append(NSAttributedString(string: s, attributes: attributes(size: size, bold: bold, italic: italic, monospace: true, link: link)))
case .strong(let children):
render(children, into: output, size: size, bold: true, italic: italic, link: link)
case .emphasis(let children):
render(children, into: output, size: size, bold: bold, italic: true, link: link)
case .link(let label, let destination):
render(label, into: output, size: size, bold: bold, italic: italic, link: destination)
case .image(let alt, _):
output.append(NSAttributedString(string: alt, attributes: attributes(size: size, bold: bold, italic: italic, monospace: false, link: link)))
case .footnoteReference:
break // references carry no inline glyph in this projection
Comment on lines +126 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve footnote markers in RTF exports

On Apple platforms the default RTF writer goes through this builder, and any Markdown/converted DOCX containing [^id] markers loses them here. The body reference is dropped entirely, and a footnote definition like [^fn1]: note becomes just : note, so the exported RTF no longer preserves where the note was referenced or which definition it belongs to; emit a literal marker as the DOCX writer does instead of skipping it.

Useful? React with 👍 / 👎.

}
}
}

private static func attributes(size: CGFloat, bold: Bool, italic: Bool, monospace: Bool, link: String?) -> [NSAttributedString.Key: Any] {
var attrs: [NSAttributedString.Key: Any] = [
.font: monospace ? monospacedFont(size: size, bold: bold) : font(size: size, bold: bold, italic: italic)
]
if let link, let url = URL(string: link) { attrs[.link] = url }
return attrs
}

// MARK: - Fonts

private static func bodyFont() -> PlatformFont { font(size: baseSize, bold: false, italic: false) }

private static func font(size: CGFloat, bold: Bool, italic: Bool) -> PlatformFont {
let base = PlatformFont.systemFont(ofSize: size, weight: bold ? .bold : .regular)
return italic ? applyItalic(base, size: size) : base
}

private static func monospacedFont(size: CGFloat, bold: Bool = false) -> PlatformFont {
PlatformFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : .regular)
}

private static func applyItalic(_ base: PlatformFont, size: CGFloat) -> PlatformFont {
#if canImport(AppKit)
let descriptor = base.fontDescriptor.withSymbolicTraits(.italic)
return NSFont(descriptor: descriptor, size: size) ?? base
#else
guard let descriptor = base.fontDescriptor.withSymbolicTraits(
base.fontDescriptor.symbolicTraits.union(.traitItalic)
) else { return base }
return UIFont(descriptor: descriptor, size: size)
#endif
}
}

#endif
36 changes: 36 additions & 0 deletions Sources/PicoDocs/Exporters/AttributedStringRTFExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// AttributedStringRTFExporter.swift
// PicoDocs
//
// Writes RTF (`.rtf`) by building an `NSAttributedString` from the result (see
// `AttributedStringDocumentBuilder`) and asking Foundation's document writer to
// serialize it. RTF write support is available across Apple platforms (macOS,
// iOS, tvOS, visionOS), so this is the lowest-risk binary exporter and round-trips
// through the existing `RTFConverter`.
//

#if canImport(AppKit) || canImport(UIKit)

import Foundation

public struct AttributedStringRTFExporter: DocumentExporter {

public init() {}

public func accepts(_ format: ExportableFileType) -> Bool { format == .rtf }

public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data {
guard format == .rtf else { throw ExporterError.notAccepted }
let attributed = AttributedStringDocumentBuilder.attributedString(from: result)
do {
return try attributed.data(
from: NSRange(location: 0, length: attributed.length),
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf]
)
} catch {
throw ExporterError.serializationFailed("RTF serialization failed: \(error.localizedDescription)")
}
}
}

#endif
42 changes: 42 additions & 0 deletions Sources/PicoDocs/Exporters/DocumentExporter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//
// DocumentExporter.swift
// PicoDocs
//
// The write-side counterpart of `DocumentConverter`: a small, Sendable serializer
// that turns a canonical `ConverterResult` into office-file bytes for one family
// of formats. The requested `ExportableFileType` has already been chosen by the
// caller, so `accepts` only inspects the format — it never inspects the result.
//
// Exporters are synchronous (`throws`, not `async`): serialization is pure CPU
// with no I/O, unlike converters which may touch PDFKit/Vision/network.
//

import Foundation

/// Errors an exporter can throw to communicate intent to the registry.
public enum ExporterError: Error, Sendable {
/// The exporter declined this format; the registry should try the next
/// candidate rather than treat it as a hard failure.
case notAccepted
/// The exporter accepts the format in principle, but the platform API it needs
/// is unavailable here (e.g. `NSAttributedString`'s `.officeOpenXML` on tvOS).
/// The registry falls through to the next candidate, exactly like `.notAccepted`.
case platformUnavailable
/// An accepting exporter genuinely failed to serialize. The registry surfaces
/// this rather than silently falling through to a lower-fidelity writer.
case serializationFailed(String)
}

public protocol DocumentExporter: Sendable {

/// Cheap check against the requested format only. Must not inspect the result
/// or perform heavy work.
func accepts(_ format: ExportableFileType) -> Bool

/// Serialize the canonical structured form into office-file bytes.
///
/// Throw `ExporterError.notAccepted` / `.platformUnavailable` to defer to the
/// next exporter; throw `.serializationFailed` for a real failure, which the
/// registry surfaces rather than degrading silently.
func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data
}
Loading
Loading