diff --git a/README.md b/README.md index 9545052..3086c05 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/PicoDocs/Converters/WordConverter.swift b/Sources/PicoDocs/Converters/WordConverter.swift index fe26a9b..0a406ec 100644 --- a/Sources/PicoDocs/Converters/WordConverter.swift +++ b/Sources/PicoDocs/Converters/WordConverter.swift @@ -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 diff --git a/Sources/PicoDocs/Exporters/AttributedStringDOCXExporter.swift b/Sources/PicoDocs/Exporters/AttributedStringDOCXExporter.swift new file mode 100644 index 0000000..0d73c24 --- /dev/null +++ b/Sources/PicoDocs/Exporters/AttributedStringDOCXExporter.swift @@ -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 diff --git a/Sources/PicoDocs/Exporters/AttributedStringDocumentBuilder.swift b/Sources/PicoDocs/Exporters/AttributedStringDocumentBuilder.swift new file mode 100644 index 0000000..daf0f70 --- /dev/null +++ b/Sources/PicoDocs/Exporters/AttributedStringDocumentBuilder.swift @@ -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 + } + } + } + + 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 diff --git a/Sources/PicoDocs/Exporters/AttributedStringRTFExporter.swift b/Sources/PicoDocs/Exporters/AttributedStringRTFExporter.swift new file mode 100644 index 0000000..0ab8fec --- /dev/null +++ b/Sources/PicoDocs/Exporters/AttributedStringRTFExporter.swift @@ -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 diff --git a/Sources/PicoDocs/Exporters/DocumentExporter.swift b/Sources/PicoDocs/Exporters/DocumentExporter.swift new file mode 100644 index 0000000..3ecbc8a --- /dev/null +++ b/Sources/PicoDocs/Exporters/DocumentExporter.swift @@ -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 +} diff --git a/Sources/PicoDocs/Exporters/DocumentExporterRegistry.swift b/Sources/PicoDocs/Exporters/DocumentExporterRegistry.swift new file mode 100644 index 0000000..d6cdf48 --- /dev/null +++ b/Sources/PicoDocs/Exporters/DocumentExporterRegistry.swift @@ -0,0 +1,90 @@ +// +// DocumentExporterRegistry.swift +// PicoDocs +// +// An immutable, Sendable registry of exporters — the write-side mirror of +// `DocumentConverterRegistry`. Serialization is pure (result in, bytes out) with +// no shared mutable state, so this is a value type rather than an actor. +// + +import Foundation + +public struct DocumentExporterRegistry: Sendable { + + private struct Entry: Sendable { + let priority: Double + let exporter: any DocumentExporter + } + + /// Kept sorted by ascending priority (see `registering`). + private let entries: [Entry] + + /// Standard priorities. Lower is tried first (more specific wins). + public enum Priority { + public static let specific: Double = 0 + public static let generic: Double = 10 + } + + public init() { + self.entries = [] + } + + private init(entries: [Entry]) { + self.entries = entries + } + + /// Returns a new registry with `exporter` added. Value-returning so it can be + /// chained on a `let` (third parties extend the engine this way). + public func registering(_ exporter: any DocumentExporter, priority: Double = Priority.specific) -> DocumentExporterRegistry { + // Keep `entries` sorted by ascending priority at registration time so + // `write` doesn't re-sort on every call. Insert after equal-priority + // entries to preserve registration order among ties (stable ordering). + var updated = entries + let index = updated.firstIndex { $0.priority > priority } ?? updated.endIndex + updated.insert(Entry(priority: priority, exporter: exporter), at: index) + return DocumentExporterRegistry(entries: updated) + } + + /// The default registry with all built-in exporters registered. + public static let `default`: DocumentExporterRegistry = makeDefault() + + static func makeDefault() -> DocumentExporterRegistry { + var registry = DocumentExporterRegistry() + .registering(WordprocessingMLExporter(), priority: Priority.specific) + .registering(XLSXExporter(), priority: Priority.specific) + .registering(PPTXExporter(), priority: Priority.specific) + #if canImport(AppKit) || canImport(UIKit) + // RTF is written via NSAttributedString (Apple-only). On other platforms + // `.rtf` simply has no exporter and `write` throws. + registry = registry.registering(AttributedStringRTFExporter(), priority: Priority.specific) + #endif + // NOTE: `AttributedStringDOCXExporter` is intentionally NOT registered by + // default. The hand-rolled `WordprocessingMLExporter` is the primary, all- + // platform DOCX writer (and the round-trip oracle); the Apple path is kept + // available for opt-in `.registering(...)` and round-trip testing so we + // don't ship two DOCX semantics. See its file header. + return registry + } + + /// Serialize `result` to `format` using the first accepting exporter + /// (ascending priority). + /// + /// Fallthrough semantics mirror `DocumentConverterRegistry`: an exporter that + /// throws `.notAccepted` or `.platformUnavailable` defers to the next + /// candidate; `.serializationFailed` (an accepting exporter that genuinely + /// failed) propagates rather than degrading to a lower-fidelity writer. + public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data { + for entry in entries { + guard entry.exporter.accepts(format) else { continue } + do { + return try entry.exporter.write(result, format: format) + } catch let error as ExporterError { + switch error { + case .notAccepted, .platformUnavailable: continue + case .serializationFailed: throw error + } + } + } + throw PicoDocsError.unableToExportToRequestedFormat + } +} diff --git a/Sources/PicoDocs/Exporters/IWork/IWorkExportSpike.swift b/Sources/PicoDocs/Exporters/IWork/IWorkExportSpike.swift new file mode 100644 index 0000000..27524c8 --- /dev/null +++ b/Sources/PicoDocs/Exporters/IWork/IWorkExportSpike.swift @@ -0,0 +1,34 @@ +// +// IWorkExportSpike.swift +// PicoDocs +// +// Research spike notes — there is intentionally NO Pages/Keynote writer. +// `ExportableFileType.pages` / `.keynote` report `isImplemented == false`, are not +// registered in `DocumentExporterRegistry.default`, and `PicoDocsEngine.write(...)` +// throws `PicoDocsError.unableToExportToRequestedFormat` for them. +// +// Why writing iWork third-party is impractical (vs. the feasible OOXML writers): +// +// - An iWork file is a ZIP whose document model lives in `Index/*.iwa` archives. +// Each `.iwa` is Snappy-framed protobuf (see the read side: +// `Converters/Pages/Snappy.swift`, `IWAArchive.swift`, `ProtobufReader.swift`). +// The read path only *decodes* these and only extracts plain text — even reading +// headings/tables/styles is deferred there. +// +// - Writing requires emitting a valid TSP object graph (component archives, object +// identifiers, the message-info table, and document/slide objects) that Pages/ +// Keynote's strict importer accepts. Apple publishes no schema; the proto +// definitions are reverse-engineered and version-fragile, and the project has no +// `SwiftProtobuf` dependency. +// +// Recommended path for "export to Pages/Keynote": export OOXML (`.docx` / `.pptx`) +// and let Pages/Keynote import it — they open OOXML cleanly. Revisit a native iWork +// writer only if a spike demonstrates a reliable, version-stable encoder. +// + +import Foundation + +enum IWorkExportSpike { + /// The formats this spike covers; deliberately unimplemented (see file header). + static let deferredFormats: [ExportableFileType] = [.pages, .keynote] +} diff --git a/Sources/PicoDocs/Exporters/OOXMLPackageWriter.swift b/Sources/PicoDocs/Exporters/OOXMLPackageWriter.swift new file mode 100644 index 0000000..c7a4e00 --- /dev/null +++ b/Sources/PicoDocs/Exporters/OOXMLPackageWriter.swift @@ -0,0 +1,100 @@ +// +// OOXMLPackageWriter.swift +// PicoDocs +// +// A tiny in-memory wrapper over ZIPFoundation's write mode, shared by the OOXML +// exporters (DOCX/XLSX/PPTX). OOXML files are a ZIP ("package") of XML "parts" +// plus media; this assembles one entirely in memory (no temp files), which keeps +// the exporters' `write(...) -> Data` pure and usable from any context. +// +// The read side (`WordConverter`/`EPUBConverter`) opens archives with +// `Archive(data:accessMode:.read)`; this is the symmetric `.create` path. +// + +import Foundation +import ZIPFoundation + +struct OOXMLPackageWriter { + + private var archive: Archive + + init() throws { + guard let archive = Archive(data: Data(), accessMode: .create) else { + throw ExporterError.serializationFailed("Could not create in-memory OOXML archive") + } + self.archive = archive + } + + /// Adds a UTF-8 XML part at `path` (e.g. "word/document.xml"). + mutating func addXML(_ path: String, _ xml: String) throws { + try addData(path, Data(xml.utf8)) + } + + /// Adds raw bytes at `path` (e.g. an image under "word/media/"). + mutating func addData(_ path: String, _ data: Data) throws { + do { + try archive.addEntry( + with: path, + type: .file, + uncompressedSize: Int64(data.count), + compressionMethod: .deflate, + provider: { position, size in + // `Int(position)` tolerates either Int/Int64 provider positions. + let start = Int(position) + let length = Swift.min(size, data.count - start) + guard length > 0 else { return Data() } + let lower = data.index(data.startIndex, offsetBy: start) + let upper = data.index(lower, offsetBy: length) + return data.subdata(in: lower.. Data { + guard let data = archive.data else { + throw ExporterError.serializationFailed("Could not finalize OOXML archive") + } + return data + } + + // MARK: - XML helpers + + /// XML standalone declaration used at the top of every part. + static let xmlDeclaration = "\n" + + /// Escapes text content for an XML element body. + /// + /// Also drops scalars that XML 1.0 forbids (most C0 control characters, plus the + /// surrogate/`FFFE`/`FFFF` ranges). LLM output and text extracted from PDFs can + /// carry stray `NUL`/vertical-tab/etc.; left in, they'd make `document.xml`, + /// worksheet, or slide parts non-well-formed and Office would reject the file. + /// Raw `write(markdown:)` input doesn't pass through `sanitizeUnicode`, so this + /// is the single choke point that guarantees valid XML bodies. + static func escape(_ text: String) -> String { + let sanitized = String(String.UnicodeScalarView(text.unicodeScalars.filter(isValidXMLScalar))) + return sanitized.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + /// Whether a scalar is allowed in an XML 1.0 document (tab/LF/CR, then the + /// permitted BMP and supplementary ranges). + private static func isValidXMLScalar(_ scalar: Unicode.Scalar) -> Bool { + let v = scalar.value + return v == 0x9 || v == 0xA || v == 0xD || + (v >= 0x20 && v <= 0xD7FF) || + (v >= 0xE000 && v <= 0xFFFD) || + (v >= 0x10000 && v <= 0x10FFFF) + } + + /// Escapes a value for an XML attribute (adds quote escaping). + static func escapeAttribute(_ text: String) -> String { + escape(text) + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } +} diff --git a/Sources/PicoDocs/Exporters/OfficeMediaType.swift b/Sources/PicoDocs/Exporters/OfficeMediaType.swift new file mode 100644 index 0000000..eb94ea6 --- /dev/null +++ b/Sources/PicoDocs/Exporters/OfficeMediaType.swift @@ -0,0 +1,48 @@ +// +// OfficeMediaType.swift +// PicoDocs +// +// Shared mapping between image file extensions and MIME types, used by both the +// read side (`WordConverter`, when stamping `metadata["mimeType"]` on extracted +// images) and the write side (the OOXML exporters, when declaring media content +// types), so the two agree. +// + +import Foundation + +enum OfficeMediaType { + + /// The MIME type for a media file extension (without the leading dot). + 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" + } + } + + /// The file extension (without the dot) for an image MIME type. Inverse of + /// `mimeType(forExtension:)`, used when writing media whose carrier only knows + /// its MIME type (e.g. a `DocumentSection` image with `metadata["mimeType"]`). + static func fileExtension(forMIME mime: String) -> String { + switch mime.lowercased() { + case "image/png": return "png" + case "image/jpeg", "image/jpg": return "jpeg" + case "image/gif": return "gif" + case "image/bmp": return "bmp" + case "image/tiff": return "tiff" + case "image/svg+xml": return "svg" + case "image/webp": return "webp" + case "image/emf": return "emf" + case "image/wmf": return "wmf" + default: return "bin" + } + } +} diff --git a/Sources/PicoDocs/Exporters/PPTXExporter.swift b/Sources/PicoDocs/Exporters/PPTXExporter.swift new file mode 100644 index 0000000..f5a983a --- /dev/null +++ b/Sources/PicoDocs/Exporters/PPTXExporter.swift @@ -0,0 +1,199 @@ +// +// PPTXExporter.swift +// PicoDocs +// +// Hand-rolled PresentationML (PPTX) writer on `OOXMLPackageWriter`. One slide per +// top-level heading (level 1–2): the heading is the title placeholder, the +// following blocks become body text. Explicit `.slide` sections (from a future +// presentation reader) map one section per slide. +// +// PPTX is the strictest minimal OOXML package — it requires a slide master, a +// layout, and a theme even for plain text — so those parts are fixed templates +// (`PPTXTemplates`). There is no in-repo PPTX *reader*, so this is validated by +// structural/golden tests (slide count, title text) rather than round-trip. +// Images are rendered as their alt text on slides for now (no `` embedding). +// + +import Foundation + +public struct PPTXExporter: DocumentExporter { + + public init() {} + + public func accepts(_ format: ExportableFileType) -> Bool { format == .pptx } + + public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data { + guard format == .pptx else { throw ExporterError.notAccepted } + + let slides = Self.slides(from: result) + let count = max(slides.count, 1) + let effectiveSlides = slides.isEmpty ? [Slide(title: "", body: [])] : slides + + var pkg = try OOXMLPackageWriter() + try pkg.addXML("[Content_Types].xml", Self.contentTypes(slideCount: count)) + try pkg.addXML("_rels/.rels", Self.rootRels) + try pkg.addXML("ppt/presentation.xml", Self.presentationXML(slideCount: count)) + try pkg.addXML("ppt/_rels/presentation.xml.rels", Self.presentationRels(slideCount: count)) + try pkg.addXML("ppt/slideMasters/slideMaster1.xml", PPTXTemplates.slideMaster) + try pkg.addXML("ppt/slideMasters/_rels/slideMaster1.xml.rels", PPTXTemplates.slideMasterRels) + try pkg.addXML("ppt/slideLayouts/slideLayout1.xml", PPTXTemplates.slideLayout) + try pkg.addXML("ppt/slideLayouts/_rels/slideLayout1.xml.rels", PPTXTemplates.slideLayoutRels) + try pkg.addXML("ppt/theme/theme1.xml", PPTXTemplates.theme) + for (i, slide) in effectiveSlides.enumerated() { + try pkg.addXML("ppt/slides/slide\(i + 1).xml", Self.slideXML(slide)) + try pkg.addXML("ppt/slides/_rels/slide\(i + 1).xml.rels", PPTXTemplates.slideRels) + } + return try pkg.data() + } + + // MARK: - Slide model + + struct Slide { let title: String; let body: [String] } + + private static func slides(from result: ConverterResult) -> [Slide] { + // Explicit slide sections map 1:1. + let slideSections = result.sections.filter { $0.kind == .slide } + if !slideSections.isEmpty { + return slideSections.map { section in + Slide(title: section.title ?? "", body: bodyLines(MarkdownBlockParser.parse(section.markdown))) + } + } + + // Otherwise segment the merged Markdown at top-level headings. + var slides: [Slide] = [] + var title = "" + var body: [String] = [] + var started = false + func flush() { if started { slides.append(Slide(title: title, body: body)) } } + + for block in MarkdownBlockParser.parse(result.markdown()) { + if case .heading(let level, let text) = block, level <= 2 { + flush() + title = text + body = [] + started = true + } else { + started = true + body += bodyLines([block]) + } + } + flush() + return slides + } + + private static func bodyLines(_ blocks: [MarkdownBlock]) -> [String] { + var lines: [String] = [] + for block in blocks { + switch block { + case .heading(_, let text): + lines.append(plain(text)) + case .paragraph(let text): + for line in text.components(separatedBy: "\n") where !line.isEmpty { + lines.append(plain(line)) + } + case .list(_, let items): + for item in items { lines.append(plain(item)) } + case .code(let code): + for line in code.components(separatedBy: "\n") { lines.append(line) } + case .blockquote(let quoteLines): + for line in quoteLines { lines.append(plain(line)) } + case .table(let rows): + for row in rows { lines.append(row.map { plain($0) }.joined(separator: "\t")) } + case .rule: + continue + } + } + return lines + } + + private static func plain(_ markdown: String) -> String { + MarkdownInlineParser.parse(markdown).plainText + } + + // MARK: - Slide part + + private static func slideXML(_ slide: Slide) -> String { + let titleRuns = "\(OOXMLPackageWriter.escape(slide.title))" + let bodyParagraphs: String + if slide.body.isEmpty { + bodyParagraphs = "" + } else { + bodyParagraphs = slide.body.map { + "\(OOXMLPackageWriter.escape($0))" + }.joined() + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \ + \ + \ + \ + \ + \ + \(titleRuns)\ + \ + \ + \ + \ + \(bodyParagraphs)\ + + """ + } + + // MARK: - Package parts (dynamic) + + private static let rootRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + + """ + + private static func contentTypes(slideCount: Int) -> String { + var overrides = """ + \ + \ + \ + + """ + for i in 1...slideCount { + overrides += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \(overrides) + """ + } + + private static func presentationXML(slideCount: Int) -> String { + // sldMasterIdLst uses rId1; slides use rId2... (see presentationRels). + var sldIds = "" + for i in 1...slideCount { + sldIds += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \(sldIds)\ + \ + + """ + } + + private static func presentationRels(slideCount: Int) -> String { + var rels = "" + for i in 1...slideCount { + rels += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \(rels) + """ + } +} diff --git a/Sources/PicoDocs/Exporters/PPTXTemplates.swift b/Sources/PicoDocs/Exporters/PPTXTemplates.swift new file mode 100644 index 0000000..dff1d36 --- /dev/null +++ b/Sources/PicoDocs/Exporters/PPTXTemplates.swift @@ -0,0 +1,100 @@ +// +// PPTXTemplates.swift +// PicoDocs +// +// Fixed PresentationML parts that every PPTX needs but that don't vary with +// content: the slide master, a blank slide layout, a minimal Office theme, and +// their relationship files. Kept out of `PPTXExporter` to keep that file focused +// on slide generation. +// + +import Foundation + +enum PPTXTemplates { + + static let slideMaster = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \ + \ + \ + \ + \ + \ + \ + + """ + + static let slideMasterRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \ + + """ + + static let slideLayout = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \ + \ + + """ + + static let slideLayoutRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + + """ + + static let slideRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + + """ + + /// A minimal but complete Office theme (clrScheme + fontScheme + fmtScheme), + /// which PowerPoint requires even for plain slides. + static let theme = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + + """ +} diff --git a/Sources/PicoDocs/Exporters/WordprocessingMLExporter.swift b/Sources/PicoDocs/Exporters/WordprocessingMLExporter.swift new file mode 100644 index 0000000..24f31d0 --- /dev/null +++ b/Sources/PicoDocs/Exporters/WordprocessingMLExporter.swift @@ -0,0 +1,441 @@ +// +// WordprocessingMLExporter.swift +// PicoDocs +// +// The primary, all-platform DOCX writer: walks the shared Markdown block + inline +// IR and emits a minimal-but-valid WordprocessingML package — the inverse of +// `WordConverter`'s read. It is the round-trip oracle (export -> `WordConverter` -> +// compare), and deliberately mirrors the exact markers `WordConverter` recognizes: +// `w:pStyle w:val="Heading{N}"` for headings, `w:numPr` for list items, +// `w:b`/`w:i` run properties for emphasis, `w:hyperlink r:id` for links, and +// `a:blip r:embed` drawings for images. +// + +import Foundation + +public struct WordprocessingMLExporter: 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 } + + let builder = Builder(images: Self.imageIndex(result.sections)) + for block in MarkdownBlockParser.parse(result.markdown()) { + builder.append(block) + } + builder.finishRelationships() + + var pkg = try OOXMLPackageWriter() + try pkg.addXML("[Content_Types].xml", Self.contentTypes(mediaExtensions: builder.mediaExtensions, hasNumbering: builder.usedNumbering)) + try pkg.addXML("_rels/.rels", Self.rootRels) + try pkg.addXML("word/document.xml", Self.documentXML(body: builder.body)) + try pkg.addXML("word/_rels/document.xml.rels", Self.documentRels(builder.relationships)) + if builder.usedNumbering { + try pkg.addXML("word/numbering.xml", Self.numberingXML(usedBullet: builder.usedBullet, orderedNumIds: builder.orderedNumIds)) + } + for media in builder.media { + try pkg.addData("word/media/\(media.filename)", media.data) + } + return try pkg.data() + } + + // MARK: - Image index, from the .image sections + + /// A distinct embedded image: its bytes and the unique media part filename it + /// will be written under (`word/media/`). + private struct IndexedImage { + let data: Data + let mediaFilename: String + } + + /// Resolves an inline image reference to an embedded carrier. Keyed by full + /// source path *and* by basename — the basename map only when unambiguous — so + /// two carriers that share a basename (`charts/logo.png` vs `headers/logo.png`) + /// stay distinct instead of one overwriting the other. + private struct ImageIndex { + let byPath: [String: IndexedImage] + let byBasename: [String: IndexedImage] + + func lookup(_ source: String) -> IndexedImage? { + if let image = byPath[source] { return image } + return byBasename[(source as NSString).lastPathComponent] + } + } + + private static func imageIndex(_ sections: [DocumentSection]) -> ImageIndex { + var byPath: [String: IndexedImage] = [:] + var byBasename: [String: IndexedImage] = [:] + var basenameCounts: [String: Int] = [:] + var usedFilenames: Set = [] + + for section in sections where section.kind == .image { + guard let base64 = section.metadata["base64"], !base64.isEmpty, + let data = Data(base64Encoded: base64) else { continue } + + // The carrier's display name (basename of the source path, else title). + let name = (section.sourcePath as NSString?)?.lastPathComponent ?? section.title + // Pick a stem + extension; fall back to the declared MIME for the + // extension so a name like "logo" (or no name) still gets a type Office + // recognizes rather than `.bin`/octet-stream. + let mime = section.metadata["mimeType"] + var ext = (name as NSString?)?.pathExtension.lowercased() ?? "" + if ext.isEmpty { ext = OfficeMediaType.fileExtension(forMIME: mime ?? "") } + let stem = (name as NSString?)?.deletingPathExtension ?? "" + + // Allocate a unique media filename (suffix on basename collisions) so + // distinct images never share one `word/media/` part. + let base = stem.isEmpty ? "image\(usedFilenames.count + 1)" : stem + var mediaFilename = "\(base).\(ext)" + var n = 2 + while usedFilenames.contains(mediaFilename) { + mediaFilename = "\(base)-\(n).\(ext)" + n += 1 + } + usedFilenames.insert(mediaFilename) + + let image = IndexedImage(data: data, mediaFilename: mediaFilename) + if let path = section.sourcePath, !path.isEmpty { + byPath[path] = image + } + if let name, !name.isEmpty { + basenameCounts[name, default: 0] += 1 + byBasename[name] = image + } + } + // Drop ambiguous basenames; those references must use the full path. + for (name, count) in basenameCounts where count > 1 { + byBasename.removeValue(forKey: name) + } + return ImageIndex(byPath: byPath, byBasename: byBasename) + } + + // MARK: - Builder + + /// Accumulates body XML, relationships, and media as blocks are appended. + /// A reference type because it threads shared id counters through the inline + /// recursion. + private final class Builder { + private(set) var body = "" + private(set) var relationships: [Relationship] = [] + private(set) var media: [(filename: String, data: Data)] = [] + private(set) var mediaExtensions: Set = [] + private(set) var usedBullet = false + private(set) var orderedNumIds: [Int] = [] + + /// Numbering is needed when any list (bullet or ordered) was emitted. + var usedNumbering: Bool { usedBullet || !orderedNumIds.isEmpty } + + private let images: ImageIndex + private var relCounter = 0 + private var numberingRelAdded = false + private var drawingCounter = 0 + private var emittedMediaRel: [String: String] = [:] // media filename -> relID + private var nextOrderedNumId = 2 // 1 is reserved for bullets + + struct Relationship { let id: String; let type: String; let target: String; let external: Bool } + + init(images: ImageIndex) { self.images = images } + + private func nextRelID() -> String { relCounter += 1; return "rId\(relCounter)" } + + func append(_ block: MarkdownBlock) { + switch block { + case .heading(let level, let text): + let pPr = "" + body += paragraph(pPr: pPr, content: inlineRuns(text)) + + case .paragraph(let text): + body += paragraph(pPr: "", content: inlineRuns(text)) + + case .code(let code): + // One paragraph, hard line breaks between lines, monospace runs. + let lines = code.components(separatedBy: "\n") + var content = "" + for (i, line) in lines.enumerated() { + if i > 0 { content += "" } + content += textRun(line, bold: false, italic: false, monospace: true) + } + body += paragraph(pPr: "", content: content) + + case .blockquote(let lines): + let pPr = "" + for line in lines { + body += paragraph(pPr: pPr, content: inlineRuns(line)) + } + + case .list(let ordered, let items): + // Each ordered list gets its own numbering instance so Word restarts + // it at 1 instead of continuing the previous list; bullets can all + // share one instance (their marker doesn't accumulate). + let numId: Int + if ordered { + numId = nextOrderedNumId + nextOrderedNumId += 1 + orderedNumIds.append(numId) + } else { + usedBullet = true + numId = 1 + } + let pPr = "" + for item in items { + body += paragraph(pPr: pPr, content: inlineRuns(item.replacingOccurrences(of: "\n", with: " "))) + } + + case .table(let rows): + body += table(rows) + + case .rule: + // A bottom-bordered empty paragraph. WordConverter drops empty + // paragraphs, so a rule simply doesn't survive round-trip (acceptable). + body += "" + } + } + + /// Allocates the numbering relationship once, after the body is built. + func finishRelationships() { + guard usedNumbering, !numberingRelAdded else { return } + numberingRelAdded = true + relationships.append(Relationship( + id: nextRelID(), + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering", + target: "numbering.xml", + external: false + )) + } + + // MARK: Inline + + private func inlineRuns(_ markdown: String) -> String { + renderRuns(MarkdownInlineParser.parse(markdown), bold: false, italic: false) + } + + private func renderRuns(_ nodes: [MarkdownInline], bold: Bool, italic: Bool) -> String { + var out = "" + for node in nodes { + switch node { + case .text(let s): + out += textRun(s, bold: bold, italic: italic, monospace: false) + case .code(let s): + out += textRun(s, bold: bold, italic: italic, monospace: true) + case .strong(let children): + out += renderRuns(children, bold: true, italic: italic) + case .emphasis(let children): + out += renderRuns(children, bold: bold, italic: true) + case .link(let label, let destination): + let id = nextRelID() + relationships.append(Relationship( + id: id, + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", + target: destination, + external: true + )) + out += "\(renderRuns(label, bold: bold, italic: italic))" + case .image(let alt, let source): + out += imageRun(alt: alt, source: source) ?? textRun(alt, bold: bold, italic: italic, monospace: false) + case .footnoteReference(let fid): + // No footnote part is generated; preserve the marker as literal text. + out += textRun("[^\(fid)]", bold: bold, italic: italic, monospace: false) + } + } + return out + } + + /// Emits a drawing run for an image whose bytes we hold. Returns nil when the + /// reference isn't a known image (e.g. an external URL), so the caller falls + /// back to alt text. + /// + /// - Resolves the carrier via the index (full path, else unambiguous basename), + /// which already assigned it a unique, correctly-typed media filename. + /// - Packages and relates each distinct media file exactly once, reusing the + /// relationship for repeated references so the OOXML package can't end up + /// with duplicate `word/media/` parts. + /// - Writes the Markdown alt text into `wp:docPr/@descr`, which + /// `WordConverter.imageAltText` reads first, so meaningful alt text survives + /// the round-trip instead of collapsing to the filename. + private func imageRun(alt: String, source: String) -> String? { + guard let image = images.lookup(source) else { return nil } + let filename = image.mediaFilename + let ext = (filename as NSString).pathExtension.lowercased() + + // One media part + one relationship per distinct file; reuse for repeats. + let relID: String + if let existing = emittedMediaRel[filename] { + relID = existing + } else { + mediaExtensions.insert(ext) + media.append((filename, image.data)) + relID = nextRelID() + relationships.append(Relationship( + id: relID, + type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + target: "media/\(filename)", + external: false + )) + emittedMediaRel[filename] = relID + } + + // Each drawing needs a unique non-visual id, even when reusing media. + drawingCounter += 1 + let docPrID = drawingCounter + let name = OOXMLPackageWriter.escapeAttribute(filename) + let descr = alt.isEmpty ? "" : " descr=\"\(OOXMLPackageWriter.escapeAttribute(alt))\"" + // Fixed display size (EMU); WordConverter ignores extents on read. + let cx = 4572000, cy = 3429000 + return """ + \ + \ + \ + \ + \ + \ + \ + \ + + """ + } + + // MARK: Table + + private func table(_ rows: [[String]]) -> String { + guard !rows.isEmpty else { return "" } + let columns = rows.map(\.count).max() ?? 0 + guard columns > 0 else { return "" } + let borders = """ + \ + \ + \ + \ + \ + \ + \ + + """ + let grid = String(repeating: "", count: columns) + var out = "\(borders)\(grid)" + for row in rows { + out += "" + for col in 0..\(cellParagraph(cell))" + } + out += "" + } + out += "" + return out + } + + /// A table cell paragraph. `
` separators become hard line breaks; each + /// segment is parsed for inline emphasis/links so `**x**` etc. round-trip. + private func cellParagraph(_ cell: String) -> String { + let segments = cell.components(separatedBy: "
") + var content = "" + for (i, segment) in segments.enumerated() { + if i > 0 { content += "" } + content += inlineRuns(segment) + } + return "\(content)" + } + + // MARK: Run/paragraph primitives + + private func paragraph(pPr: String, content: String) -> String { + "\(pPr)\(content)" + } + + private func textRun(_ text: String, bold: Bool, italic: Bool, monospace: Bool) -> String { + "\(runProperties(bold: bold, italic: italic, monospace: monospace))\(OOXMLPackageWriter.escape(text))" + } + + private func runProperties(bold: Bool, italic: Bool, monospace: Bool) -> String { + var inner = "" + if bold { inner += "" } + if italic { inner += "" } + if monospace { inner += "" } + return inner.isEmpty ? "" : "\(inner)" + } + } + + // MARK: - Package parts + + private static let rootRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + + """ + + private static func contentTypes(mediaExtensions: Set, hasNumbering: Bool) -> String { + var defaults = """ + \ + + """ + for ext in mediaExtensions.sorted() { + defaults += "" + } + var overrides = "" + if hasNumbering { + overrides += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \(defaults)\(overrides) + """ + } + + private static func documentXML(body: String) -> String { + OOXMLPackageWriter.xmlDeclaration + """ + \ + \(body) + """ + } + + private static func documentRels(_ relationships: [Builder.Relationship]) -> String { + var rels = "" + for rel in relationships { + let mode = rel.external ? " TargetMode=\"External\"" : "" + rels += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \(rels) + """ + } + + /// Builds `numbering.xml` for the lists that were actually emitted. Bullets map + /// to a single shared instance (`numId` 1); every ordered list gets its own + /// `numId` over a shared decimal abstract definition, each with a `startOverride` + /// of 1 so Word restarts separate lists instead of continuing the count. + private static func numberingXML(usedBullet: Bool, orderedNumIds: [Int]) -> String { + var abstracts = "" + if usedBullet { + abstracts += """ + \ + + """ + } + if !orderedNumIds.isEmpty { + abstracts += """ + \ + + """ + } + var nums = "" + if usedBullet { + nums += "" + } + for numId in orderedNumIds { + nums += """ + \ + + """ + } + return OOXMLPackageWriter.xmlDeclaration + """ + \(abstracts)\(nums) + """ + } +} diff --git a/Sources/PicoDocs/Exporters/XLSXExporter.swift b/Sources/PicoDocs/Exporters/XLSXExporter.swift new file mode 100644 index 0000000..c94ea45 --- /dev/null +++ b/Sources/PicoDocs/Exporters/XLSXExporter.swift @@ -0,0 +1,232 @@ +// +// XLSXExporter.swift +// PicoDocs +// +// Hand-rolled SpreadsheetML (XLSX) writer — CoreXLSX (the read dependency) cannot +// write, so this builds the package directly on `OOXMLPackageWriter`. One sheet per +// document section: a section's lossless `metadata["csv"]` is used when present, +// otherwise its Markdown is flattened to rows (tables become rows; prose/list/code +// lines become single-cell rows, mirroring `DocumentRenderer.renderCSV` so nothing +// is dropped). Cells are emitted as inline strings so values round-trip exactly +// through `SpreadsheetConverter`; numeric typing is a later refinement. +// + +import Foundation + +public struct XLSXExporter: DocumentExporter { + + public init() {} + + public func accepts(_ format: ExportableFileType) -> Bool { format == .xlsx } + + public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data { + guard format == .xlsx else { throw ExporterError.notAccepted } + + var sheets: [(name: String, rows: [[String]])] = [] + var usedNames = Set() + for section in result.sections where section.kind != .image { + let rows = Self.rows(for: section) + guard !rows.isEmpty else { continue } + let name = Self.uniqueSheetName(section, index: sheets.count + 1, used: &usedNames) + sheets.append((name, rows)) + } + if sheets.isEmpty { + // The engine guards fully-empty input; this only triggers for content + // that flattens to nothing. Emit a single empty sheet rather than an + // invalid (sheet-less) workbook. + sheets = [("Sheet1", [[""]])] + } + + var pkg = try OOXMLPackageWriter() + try pkg.addXML("[Content_Types].xml", Self.contentTypes(sheetCount: sheets.count)) + try pkg.addXML("_rels/.rels", Self.rootRels) + try pkg.addXML("xl/workbook.xml", Self.workbookXML(sheets: sheets)) + try pkg.addXML("xl/_rels/workbook.xml.rels", Self.workbookRels(sheetCount: sheets.count)) + for (i, sheet) in sheets.enumerated() { + try pkg.addXML("xl/worksheets/sheet\(i + 1).xml", Self.worksheetXML(rows: sheet.rows)) + } + return try pkg.data() + } + + // MARK: - Rows + + private static func rows(for section: DocumentSection) -> [[String]] { + if let csv = section.metadata["csv"], !csv.isEmpty { + return parseCSV(csv) + } + var blocks = MarkdownBlockParser.parse(section.markdown) + // `SpreadsheetConverter` prefixes each sheet's Markdown with `## ` + // while also carrying the name in `section.sheetName`. Without this guard that + // redundant title would become cell A1 and push the real data down a row, + // corrupting an XLSX round-trip. Drop only a *leading* heading that echoes the + // sheet name/title; genuine in-body headings stay as single-cell rows. + if case .heading(_, let text)? = blocks.first, + let title = section.sheetName ?? section.title, + plain(text) == title { + blocks.removeFirst() + } + var rows: [[String]] = [] + for block in blocks { + switch block { + case .table(let tableRows): + rows += tableRows + case .heading(_, let text): + rows.append([plain(text)]) + case .paragraph(let text): + for line in text.components(separatedBy: "\n") where !line.isEmpty { + rows.append([plain(line)]) + } + case .list(_, let items): + for item in items { rows.append([plain(item)]) } + case .code(let code): + for line in code.components(separatedBy: "\n") { rows.append([line]) } + case .blockquote(let lines): + for line in lines { rows.append([plain(line)]) } + case .rule: + continue + } + } + return rows + } + + private static func plain(_ markdown: String) -> String { + MarkdownInlineParser.parse(markdown).plainText + } + + /// Minimal RFC-4180 CSV parser: handles quoted fields with embedded commas, + /// quotes (`""`), and newlines. + private static func parseCSV(_ csv: String) -> [[String]] { + var rows: [[String]] = [] + var row: [String] = [] + var field = "" + var inQuotes = false + let chars = Array(csv) + var i = 0 + func endField() { row.append(field); field = "" } + func endRow() { endField(); rows.append(row); row = [] } + while i < chars.count { + let c = chars[i] + if inQuotes { + if c == "\"" { + if i + 1 < chars.count, chars[i + 1] == "\"" { field.append("\""); i += 2; continue } + inQuotes = false; i += 1; continue + } + field.append(c); i += 1 + } else { + switch c { + case "\"": inQuotes = true; i += 1 + case ",": endField(); i += 1 + case "\r": + if i + 1 < chars.count, chars[i + 1] == "\n" { i += 1 } + endRow(); i += 1 + case "\n": endRow(); i += 1 + default: field.append(c); i += 1 + } + } + } + // Flush the trailing field/row unless the input ended exactly on a newline. + if !field.isEmpty || !row.isEmpty { endRow() } + return rows + } + + // MARK: - Sheet naming + + private static func uniqueSheetName(_ section: DocumentSection, index: Int, used: inout Set) -> String { + let raw = section.sheetName ?? section.title ?? "Sheet\(index)" + var name = sanitizeSheetName(raw) + if name.isEmpty { name = "Sheet\(index)" } + var candidate = name + var suffix = 2 + while used.contains(candidate.lowercased()) { + let tail = " (\(suffix))" + candidate = String(name.prefix(31 - tail.count)) + tail + suffix += 1 + } + used.insert(candidate.lowercased()) + return candidate + } + + /// Excel sheet names: ≤31 chars and none of `: \ / ? * [ ]`. + private static func sanitizeSheetName(_ name: String) -> String { + let invalid = CharacterSet(charactersIn: ":\\/?*[]") + // Map scalars (not characters) so multi-scalar grapheme clusters — flag + // emoji, skin-tone modifiers, family sequences — survive intact; rebuilding + // a String from the scalar view re-segments them into single characters. + let space: Unicode.Scalar = " " + let cleanedScalars = name.unicodeScalars.map { invalid.contains($0) ? space : $0 } + let cleaned = String(String.UnicodeScalarView(cleanedScalars)) + .trimmingCharacters(in: .whitespacesAndNewlines) + return String(cleaned.prefix(31)) + } + + // MARK: - Package parts + + private static let rootRels = OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + + """ + + private static func contentTypes(sheetCount: Int) -> String { + var overrides = "" + for i in 1...sheetCount { + overrides += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \ + \(overrides) + """ + } + + private static func workbookXML(sheets: [(name: String, rows: [[String]])]) -> String { + var sheetTags = "" + for (i, sheet) in sheets.enumerated() { + sheetTags += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \(sheetTags) + """ + } + + private static func workbookRels(sheetCount: Int) -> String { + var rels = "" + for i in 1...sheetCount { + rels += "" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \(rels) + """ + } + + private static func worksheetXML(rows: [[String]]) -> String { + var data = "" + for (r, row) in rows.enumerated() { + let rowNumber = r + 1 + var cells = "" + for (c, value) in row.enumerated() { + let ref = "\(columnName(c + 1))\(rowNumber)" + cells += "\(OOXMLPackageWriter.escape(value))" + } + data += "\(cells)" + } + return OOXMLPackageWriter.xmlDeclaration + """ + \ + \(data) + """ + } + + /// 1-based column index to its spreadsheet letter (1 -> A, 27 -> AA). + private static func columnName(_ index: Int) -> String { + var n = index + var name = "" + while n > 0 { + let remainder = (n - 1) % 26 + name = String(UnicodeScalar(65 + remainder)!) + name + n = (n - 1) / 26 + } + return name + } +} diff --git a/Sources/PicoDocs/Models/ExportableFileType.swift b/Sources/PicoDocs/Models/ExportableFileType.swift new file mode 100644 index 0000000..876c6b5 --- /dev/null +++ b/Sources/PicoDocs/Models/ExportableFileType.swift @@ -0,0 +1,59 @@ +// +// ExportableFileType.swift +// PicoDocs +// +// The binary office formats PicoDocs can *write* (the reverse of the read-only +// `DocumentConverter` flow). Kept separate from `ExportFileType` — which is the +// LLM-friendly *text* output set (markdown/html/xml/csv/plaintext) returned as a +// `String` — so "returns String" and "returns Data" never conflate at the API. +// +// Use in `PicoDocsEngine.write(...)`. +// + +import Foundation + +public enum ExportableFileType: String, Equatable, Codable, CaseIterable, Identifiable, Sendable { + case docx + case rtf + case xlsx + case pptx + case pages + case keynote + + public var id: String { rawValue } + + /// The conventional file extension (matches the raw value). + public var fileExtension: String { rawValue } + + /// The format's MIME type. + public var mimeType: String { + switch self { + case .docx: return "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + case .xlsx: return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + case .pptx: return "application/vnd.openxmlformats-officedocument.presentationml.presentation" + case .rtf: return "application/rtf" + case .pages: return "application/x-iwork-pages-sffpages" + case .keynote: return "application/x-iwork-keynote-sffkey" + } + } + + /// Whether a built-in exporter exists for this format *on the current platform*. + /// `.rtf` is written through `NSAttributedString`, so it's only available where + /// AppKit/UIKit is — matching the registry, which registers the RTF exporter + /// under the same condition (`DocumentExporterRegistry.makeDefault`). iWork + /// (Pages/Keynote) is a research spike — writing valid third-party iWork is + /// unsupported — so it always reports `false` and `write(...)` throws + /// `unableToExportToRequestedFormat`. + public var isImplemented: Bool { + switch self { + case .docx, .xlsx, .pptx: return true + case .rtf: + #if canImport(AppKit) || canImport(UIKit) + return true + #else + return false + #endif + case .pages, .keynote: return false + } + } +} diff --git a/Sources/PicoDocs/PicoDocs.swift b/Sources/PicoDocs/PicoDocs.swift index b85eb60..6154e55 100644 --- a/Sources/PicoDocs/PicoDocs.swift +++ b/Sources/PicoDocs/PicoDocs.swift @@ -90,6 +90,118 @@ public enum PicoDocsEngine { return try DocumentRenderer.render(result, to: format) } + // MARK: - Writing (Markdown / ConverterResult -> office files) + + /// Serialize a structured `ConverterResult` into an office file's bytes. + /// + /// The inverse of `convert`: detection/conversion produced the canonical + /// `ConverterResult`; this hands it to the first registered exporter that + /// accepts `format`. Throws `PicoDocsError.unableToExportToRequestedFormat` + /// when no exporter accepts (e.g. `.pages`/`.keynote`, which are unimplemented), + /// and `PicoDocsError.emptyDocument` for empty input. + public static func write( + _ result: ConverterResult, + to format: ExportableFileType, + registry: DocumentExporterRegistry = .default + ) throws -> Data { + // Mirror `convert`'s post-sanitize check: an empty document is an error, + // *unless* it carries image sections (an image-only doc is valid output). + let isEmpty = result.markdown().trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let hasImages = result.sections.contains { $0.kind == .image } + if isEmpty, !hasImages { + throw PicoDocsError.emptyDocument + } + // An image-only result carries `.image` byte sections but no body referencing + // them; `result.markdown()` (which omits `.image` carriers) is empty, so the + // markdown-driven exporters would emit a blank file. Synthesize one inline + // reference per carrier so the bytes are actually embedded. + let exportable = isEmpty && hasImages ? Self.withSynthesizedImageReferences(result) : result + return try registry.write(exportable, format: format) + } + + /// Appends a `.body` section with an inline `![alt](name)` reference for each + /// `.image` carrier, so an image-only result renders its images instead of a + /// blank document. `name` mirrors the exporters' image-index key (the source + /// path's basename, falling back to the title); a carrier with neither is given + /// a generated `image-.` name (extension from its MIME), assigned as its + /// `sourcePath` so the exporter's index derives the identical lookup key — so even + /// an unnamed, MIME-only carrier is embedded rather than silently dropped. + private static func withSynthesizedImageReferences(_ result: ConverterResult) -> ConverterResult { + var sections = result.sections + var refs: [DocumentSection] = [] + var generatedCount = 0 + for index in sections.indices where sections[index].kind == .image { + let section = sections[index] + var name = (section.sourcePath as NSString?)?.lastPathComponent ?? section.title + if name?.isEmpty ?? true { + generatedCount += 1 + let ext = OfficeMediaType.fileExtension(forMIME: section.metadata["mimeType"] ?? "") + let generated = "image-\(generatedCount).\(ext)" + sections[index].sourcePath = generated + name = generated + } + guard let name, !name.isEmpty else { continue } + let alt = section.title ?? name + refs.append(DocumentSection(kind: .body, markdown: "![\(alt)](\(name))")) + } + guard !refs.isEmpty else { return result } + sections.append(contentsOf: refs) + return ConverterResult(title: result.title, author: result.author, cover: result.cover, sections: sections) + } + + /// Convenience: serialize a raw Markdown string into an office file's bytes. + /// + /// The string is wrapped into a single-body `ConverterResult` (exactly as the + /// plain-text/RTF converters model raw input), so LLM Markdown output and a + /// structured result share one write path. Empty input throws + /// `PicoDocsError.emptyDocument`. + public static func write( + markdown: String, + title: String? = nil, + author: String? = nil, + to format: ExportableFileType, + registry: DocumentExporterRegistry = .default + ) throws -> Data { + guard !markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw PicoDocsError.emptyDocument + } + let result = ConverterResult( + title: title, + author: author, + sections: [DocumentSection(kind: .body, markdown: markdown)] + ) + return try write(result, to: format, registry: registry) + } + + /// Read bytes in one format and write bytes in another (office -> office), + /// bridging `convert` and `write`. + public static func transcode( + data: Data, + filename: String? = nil, + mimeType: String? = nil, + url: URL? = nil, + charset: String.Encoding? = nil, + to format: ExportableFileType, + enhanceReadability: Bool = true, + enableOCR: Bool = true, + sanitizeUnicode: Bool = false, + convertRegistry: DocumentConverterRegistry = .default, + exportRegistry: DocumentExporterRegistry = .default + ) async throws -> Data { + let result = try await convert( + data: data, + filename: filename, + mimeType: mimeType, + url: url, + charset: charset, + enhanceReadability: enhanceReadability, + enableOCR: enableOCR, + sanitizeUnicode: sanitizeUnicode, + registry: convertRegistry + ) + return try write(result, to: format, registry: exportRegistry) + } + // MARK: - StreamInfo construction static func makeStreamInfo(filename: String?, mimeType: String?, url: URL?, charset: String.Encoding?, enhanceReadability: Bool = true, enableOCR: Bool = true, sanitizeUnicode: Bool = false) -> StreamInfo { diff --git a/Sources/PicoDocs/Renderers/DocumentRenderer.swift b/Sources/PicoDocs/Renderers/DocumentRenderer.swift index 936f992..1032150 100644 --- a/Sources/PicoDocs/Renderers/DocumentRenderer.swift +++ b/Sources/PicoDocs/Renderers/DocumentRenderer.swift @@ -43,7 +43,7 @@ public enum DocumentRenderer { private static func renderPlaintext(_ result: ConverterResult) -> String { let (bodyMarkdown, notes) = extractFootnotes(result.markdown()) - let parsed = parseBlocks(bodyMarkdown) + let parsed = MarkdownBlockParser.parse(bodyMarkdown) let numbers = footnoteNumbers(blocks: parsed, notes: notes) var out: [String] = [] // Inline `[^id]` references become `[N]` inside `stripInline` (code spans @@ -87,7 +87,7 @@ public enum DocumentRenderer { private static func renderHTML(_ result: ConverterResult) -> String { let (bodyMarkdown, notes) = extractFootnotes(result.markdown()) - let parsed = parseBlocks(bodyMarkdown) + let parsed = MarkdownBlockParser.parse(bodyMarkdown) let numbers = footnoteNumbers(blocks: parsed, notes: notes) var blocks: [String] = [] // Inline `[^id]` references are turned into superscript links inside @@ -278,7 +278,7 @@ public enum DocumentRenderer { /// numbered while visible body numbers stay in document order. Unreferenced /// definitions get no number (and so aren't rendered), matching how Markdown /// footnote processors treat them. - private static func footnoteNumbers(blocks: [Block], notes: [(id: String, text: String)]) -> [String: Int] { + private static func footnoteNumbers(blocks: [MarkdownBlock], notes: [(id: String, text: String)]) -> [String: Int] { let noteText = Dictionary(notes.map { ($0.id, $0.text) }, uniquingKeysWith: { first, _ in first }) var numbers: [String: Int] = [:] var next = 1 @@ -414,8 +414,8 @@ public enum DocumentRenderer { // (second row), so all-dash data rows elsewhere are preserved. var rowIndex = 0 while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("|") { - let cells = parseTableRow(lines[i]).map { MarkdownTableCell.unescape($0) } - if !(rowIndex == 1 && isTableSeparatorRow(cells)) { + let cells = MarkdownBlockParser.parseTableRow(lines[i]).map { MarkdownTableCell.unescape($0) } + if !(rowIndex == 1 && MarkdownBlockParser.isTableSeparatorRow(cells)) { rows.append(cells.map { csvField($0) }.joined(separator: ",")) } rowIndex += 1 @@ -429,188 +429,6 @@ public enum DocumentRenderer { return rows } - // MARK: - Markdown block parsing - - private enum Block { - case heading(Int, String) - case paragraph(String) - case code(String) - case blockquote([String]) - case list(ordered: Bool, items: [String]) - case table([[String]]) - case rule - } - - private static func parseBlocks(_ markdown: String) -> [Block] { - let lines = markdown.components(separatedBy: "\n") - var blocks: [Block] = [] - var i = 0 - - func isBlank(_ s: String) -> Bool { s.trimmingCharacters(in: .whitespaces).isEmpty } - - while i < lines.count { - let line = lines[i] - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if isBlank(line) { i += 1; continue } - - if trimmed.hasPrefix("```") { - i += 1 - var code: [String] = [] - while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { - code.append(lines[i]); i += 1 - } - if i < lines.count { i += 1 } // closing fence - blocks.append(.code(code.joined(separator: "\n"))) - continue - } - - if trimmed == "---" || trimmed == "***" || trimmed == "___" { - blocks.append(.rule); i += 1; continue - } - - if let heading = headingMatch(trimmed) { - blocks.append(.heading(heading.level, heading.text)); i += 1; continue - } - - if trimmed.hasPrefix("|") { - var rows: [[String]] = [] - var rowIndex = 0 - while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("|") { - let cells = parseTableRow(lines[i]).map { MarkdownTableCell.unescape($0) } - // The header/body separator is conventionally the second row; - // only drop an all-dash row there, so real data rows that - // happen to be all dashes elsewhere are kept. - if !(rowIndex == 1 && isTableSeparatorRow(cells)) { rows.append(cells) } - rowIndex += 1 - i += 1 - } - if !rows.isEmpty { blocks.append(.table(rows)) } - continue - } - - if trimmed.hasPrefix(">") { - var inner: [String] = [] - while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix(">") { - var quoted = lines[i].trimmingCharacters(in: .whitespaces) - quoted.removeFirst() // ">" - if quoted.hasPrefix(" ") { quoted.removeFirst() } - inner.append(quoted) - i += 1 - } - blocks.append(.blockquote(inner)); continue - } - - if listMarker(trimmed) != nil { - let ordered = listMarker(trimmed) == .ordered - var items: [String] = [] - while i < lines.count { - let itemLine = lines[i].trimmingCharacters(in: .whitespaces) - if let marker = listMarker(itemLine), (marker == .ordered) == ordered { - items.append(stripListMarker(itemLine)); i += 1 - } else if !isBlank(lines[i]), lines[i].hasPrefix(" "), !items.isEmpty { - items[items.count - 1] += "\n" + lines[i].trimmingCharacters(in: .whitespaces) - i += 1 - } else { - break - } - } - blocks.append(.list(ordered: ordered, items: items)); continue - } - - // Paragraph: gather until a blank line or a structural line. - var paragraph: [String] = [] - while i < lines.count { - let candidate = lines[i].trimmingCharacters(in: .whitespaces) - if isBlank(lines[i]) || candidate.hasPrefix("```") || candidate.hasPrefix("|") - || candidate.hasPrefix(">") || candidate == "---" || candidate == "***" - || headingMatch(candidate) != nil || listMarker(candidate) != nil { - break - } - paragraph.append(lines[i]); i += 1 - } - if !paragraph.isEmpty { - blocks.append(.paragraph(paragraph.joined(separator: "\n"))) - } - } - return blocks - } - - private static func headingMatch(_ line: String) -> (level: Int, text: String)? { - var level = 0 - var index = line.startIndex - while index < line.endIndex, line[index] == "#", level < 6 { - level += 1; index = line.index(after: index) - } - guard level > 0, index < line.endIndex, line[index] == " " else { return nil } - let text = String(line[line.index(after: index)...]).trimmingCharacters(in: .whitespaces) - return (level, text) - } - - private enum ListKind: Equatable { case ordered, unordered } - - private static func listMarker(_ line: String) -> ListKind? { - if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { return .unordered } - // ordered: one-or-more digits, then ". " - var index = line.startIndex - var digits = 0 - while index < line.endIndex, line[index].isNumber { digits += 1; index = line.index(after: index) } - if digits > 0, index < line.endIndex, line[index] == "." { - let after = line.index(after: index) - if after < line.endIndex, line[after] == " " { return .ordered } - } - return nil - } - - private static func stripListMarker(_ line: String) -> String { - if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { - return String(line.dropFirst(2)) - } - if let dot = line.firstIndex(of: "."), line[line.startIndex.. [String] { - var cells = line.trimmingCharacters(in: .whitespaces) - if cells.hasPrefix("|") { cells.removeFirst() } - if cells.hasSuffix("|") { - // Strip the trailing delimiter only if the pipe is unescaped (an even - // number of backslashes precede it); otherwise it's a literal `\|` in - // a row that omits the closing delimiter. - let backslashes = cells.dropLast().reversed().prefix { $0 == "\\" }.count - if backslashes.isMultiple(of: 2) { cells.removeLast() } - } - // Split on unescaped pipes only; a backslash escapes the next character, - // so `\|` stays in the cell while `\\|` is a literal backslash + delimiter. - var result: [String] = [] - var current = "" - var escaped = false - for character in cells { - if escaped { - current.append(character); escaped = false - } else if character == "\\" { - current.append(character); escaped = true - } else if character == "|" { - result.append(current.trimmingCharacters(in: .whitespaces)); current = "" - } else { - current.append(character) - } - } - result.append(current.trimmingCharacters(in: .whitespaces)) - return result - } - - private static func isTableSeparatorRow(_ cells: [String]) -> Bool { - guard !cells.isEmpty else { return false } - return cells.allSatisfy { cell in - let trimmed = cell.trimmingCharacters(in: .whitespaces) - return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == ":" } - } - } - - // MARK: - Inline rendering // Sentinels that bracket extracted spans; private-use scalars that won't diff --git a/Sources/PicoDocs/Renderers/MarkdownBlock.swift b/Sources/PicoDocs/Renderers/MarkdownBlock.swift new file mode 100644 index 0000000..5926b2a --- /dev/null +++ b/Sources/PicoDocs/Renderers/MarkdownBlock.swift @@ -0,0 +1,204 @@ +// +// MarkdownBlock.swift +// PicoDocs +// +// The shared block-level intermediate representation for the Markdown subset +// PicoDocs converters emit. Extracted from `DocumentRenderer` so that *both* +// halves of the engine use one parser: the renderer (Markdown -> HTML/plaintext/ +// XML/CSV) and the exporters (Markdown -> DOCX/XLSX/PPTX). A fix to table/list/ +// heading parsing then benefits both directions. +// +// This is deliberately a narrow, hand-rolled parser for the canonical Markdown +// the converters produce — not a full CommonMark parser. `DocumentRenderer`'s +// header comment floats replacing it with swift-markdown; if that ever happens, +// it should slot in behind this same `[MarkdownBlock]` contract, with the +// renderer + exporter round-trip tests as the safety net. +// + +import Foundation + +/// A block-level element of the canonical Markdown subset. +enum MarkdownBlock: Equatable { + case heading(Int, String) + case paragraph(String) + case code(String) + case blockquote([String]) + case list(ordered: Bool, items: [String]) + case table([[String]]) + case rule +} + +/// Parses canonical Markdown into `[MarkdownBlock]`. The structural helpers +/// (`parseTableRow`, `isTableSeparatorRow`, …) are `internal` because the +/// renderer's CSV path and the exporters reuse them. +enum MarkdownBlockParser { + + static func parse(_ markdown: String) -> [MarkdownBlock] { + let lines = markdown.components(separatedBy: "\n") + var blocks: [MarkdownBlock] = [] + var i = 0 + + func isBlank(_ s: String) -> Bool { s.trimmingCharacters(in: .whitespaces).isEmpty } + + while i < lines.count { + let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if isBlank(line) { i += 1; continue } + + if trimmed.hasPrefix("```") { + i += 1 + var code: [String] = [] + while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[i]); i += 1 + } + if i < lines.count { i += 1 } // closing fence + blocks.append(.code(code.joined(separator: "\n"))) + continue + } + + if trimmed == "---" || trimmed == "***" || trimmed == "___" { + blocks.append(.rule); i += 1; continue + } + + if let heading = headingMatch(trimmed) { + blocks.append(.heading(heading.level, heading.text)); i += 1; continue + } + + if trimmed.hasPrefix("|") { + var rows: [[String]] = [] + var rowIndex = 0 + while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("|") { + let cells = parseTableRow(lines[i]).map { MarkdownTableCell.unescape($0) } + // The header/body separator is conventionally the second row; + // only drop an all-dash row there, so real data rows that + // happen to be all dashes elsewhere are kept. + if !(rowIndex == 1 && isTableSeparatorRow(cells)) { rows.append(cells) } + rowIndex += 1 + i += 1 + } + if !rows.isEmpty { blocks.append(.table(rows)) } + continue + } + + if trimmed.hasPrefix(">") { + var inner: [String] = [] + while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).hasPrefix(">") { + var quoted = lines[i].trimmingCharacters(in: .whitespaces) + quoted.removeFirst() // ">" + if quoted.hasPrefix(" ") { quoted.removeFirst() } + inner.append(quoted) + i += 1 + } + blocks.append(.blockquote(inner)); continue + } + + if listMarker(trimmed) != nil { + let ordered = listMarker(trimmed) == .ordered + var items: [String] = [] + while i < lines.count { + let itemLine = lines[i].trimmingCharacters(in: .whitespaces) + if let marker = listMarker(itemLine), (marker == .ordered) == ordered { + items.append(stripListMarker(itemLine)); i += 1 + } else if !isBlank(lines[i]), lines[i].hasPrefix(" "), !items.isEmpty { + items[items.count - 1] += "\n" + lines[i].trimmingCharacters(in: .whitespaces) + i += 1 + } else { + break + } + } + blocks.append(.list(ordered: ordered, items: items)); continue + } + + // Paragraph: gather until a blank line or a structural line. + var paragraph: [String] = [] + while i < lines.count { + let candidate = lines[i].trimmingCharacters(in: .whitespaces) + if isBlank(lines[i]) || candidate.hasPrefix("```") || candidate.hasPrefix("|") + || candidate.hasPrefix(">") || candidate == "---" || candidate == "***" + || headingMatch(candidate) != nil || listMarker(candidate) != nil { + break + } + paragraph.append(lines[i]); i += 1 + } + if !paragraph.isEmpty { + blocks.append(.paragraph(paragraph.joined(separator: "\n"))) + } + } + return blocks + } + + static func headingMatch(_ line: String) -> (level: Int, text: String)? { + var level = 0 + var index = line.startIndex + while index < line.endIndex, line[index] == "#", level < 6 { + level += 1; index = line.index(after: index) + } + guard level > 0, index < line.endIndex, line[index] == " " else { return nil } + let text = String(line[line.index(after: index)...]).trimmingCharacters(in: .whitespaces) + return (level, text) + } + + enum ListKind: Equatable { case ordered, unordered } + + static func listMarker(_ line: String) -> ListKind? { + if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { return .unordered } + // ordered: one-or-more digits, then ". " + var index = line.startIndex + var digits = 0 + while index < line.endIndex, line[index].isNumber { digits += 1; index = line.index(after: index) } + if digits > 0, index < line.endIndex, line[index] == "." { + let after = line.index(after: index) + if after < line.endIndex, line[after] == " " { return .ordered } + } + return nil + } + + static func stripListMarker(_ line: String) -> String { + if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { + return String(line.dropFirst(2)) + } + if let dot = line.firstIndex(of: "."), line[line.startIndex.. [String] { + var cells = line.trimmingCharacters(in: .whitespaces) + if cells.hasPrefix("|") { cells.removeFirst() } + if cells.hasSuffix("|") { + // Strip the trailing delimiter only if the pipe is unescaped (an even + // number of backslashes precede it); otherwise it's a literal `\|` in + // a row that omits the closing delimiter. + let backslashes = cells.dropLast().reversed().prefix { $0 == "\\" }.count + if backslashes.isMultiple(of: 2) { cells.removeLast() } + } + // Split on unescaped pipes only; a backslash escapes the next character, + // so `\|` stays in the cell while `\\|` is a literal backslash + delimiter. + var result: [String] = [] + var current = "" + var escaped = false + for character in cells { + if escaped { + current.append(character); escaped = false + } else if character == "\\" { + current.append(character); escaped = true + } else if character == "|" { + result.append(current.trimmingCharacters(in: .whitespaces)); current = "" + } else { + current.append(character) + } + } + result.append(current.trimmingCharacters(in: .whitespaces)) + return result + } + + static func isTableSeparatorRow(_ cells: [String]) -> Bool { + guard !cells.isEmpty else { return false } + return cells.allSatisfy { cell in + let trimmed = cell.trimmingCharacters(in: .whitespaces) + return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == ":" } + } + } +} diff --git a/Sources/PicoDocs/Renderers/MarkdownInline.swift b/Sources/PicoDocs/Renderers/MarkdownInline.swift new file mode 100644 index 0000000..9dabb8a --- /dev/null +++ b/Sources/PicoDocs/Renderers/MarkdownInline.swift @@ -0,0 +1,255 @@ +// +// MarkdownInline.swift +// PicoDocs +// +// A structured inline intermediate representation for the Markdown subset the +// converters emit. The renderer's existing inline helpers (`extractCodeSpans`, +// `extractLinks`, `applyEmphasisHTML`/`Strip`) are geared toward emitting HTML or +// stripping to text; the office exporters instead need *run structure* — a DOCX +// `w:r` with `w:b`/`w:i`, a `w:hyperlink`, an inline image — so they consume this +// tree. +// +// Scope (Phase 0B): this IR is introduced for the exporters. The HTML/plaintext +// renderers keep their own battle-tested inline path for now; converging them onto +// this model is a separate, test-guarded step. +// + +import Foundation + +/// One inline node of the canonical Markdown subset. Emphasis/strong/link labels +/// nest, so they carry child nodes. +indirect enum MarkdownInline: Equatable { + case text(String) + case strong([MarkdownInline]) + case emphasis([MarkdownInline]) + case code(String) + case link(label: [MarkdownInline], destination: String) + case image(alt: String, source: String) + case footnoteReference(String) +} + +enum MarkdownInlineParser { + + /// Parses an inline Markdown string into structured nodes. Code spans, links, + /// images, and footnote references are pulled out by a single scan (so their + /// contents aren't reinterpreted), and the remaining plain-text runs are parsed + /// for `*`/`**`/`***` emphasis. + static func parse(_ text: String) -> [MarkdownInline] { + let chars = Array(text) + var nodes: [MarkdownInline] = [] + var run = "" + var i = 0 + + func flush() { + if !run.isEmpty { + nodes.append(contentsOf: parseEmphasis(run)) + run = "" + } + } + + while i < chars.count { + let c = chars[i] + + // Inline code span: `...` (literal, no nested formatting). + if c == "`", let close = firstIndex(of: "`", in: chars, from: i + 1) { + flush() + nodes.append(.code(String(chars[(i + 1)..)` that `WordConverter` emits. Returns the node and the + /// index just past the closing `)`, or nil if the syntax doesn't match. + private static func parseLinkOrImage(_ chars: [Character], from: Int, isImage: Bool) -> (node: MarkdownInline, next: Int)? { + let bracket = isImage ? from + 1 : from + guard bracket < chars.count, chars[bracket] == "[" else { return nil } + // Find the label's closing `]`, skipping backslash-escaped delimiters: + // `WordConverter` escapes `[`/`]` inside labels and alt text, so a visible + // `]` arrives as `\]` and must not terminate the label early. + guard let labelEnd = indexOfUnescaped("]", in: chars, from: bracket + 1) else { return nil } + let parenOpen = labelEnd + 1 + guard parenOpen < chars.count, chars[parenOpen] == "(" else { return nil } + + let destStart = parenOpen + 1 + var dest = "" + var cursor = destStart + if destStart < chars.count, chars[destStart] == "<" { + guard let gt = firstIndex(of: ">", in: chars, from: destStart + 1) else { return nil } + dest = String(chars[(destStart + 1).. Int? { + var i = start + while i < chars.count { + if chars[i] == "\\" { i += 2; continue } // skip the escape and its target + if chars[i] == character { return i } + i += 1 + } + return nil + } + + /// Index of the `)` that closes a bare destination opened just past `(`, + /// honoring nested balanced parens and backslash escapes; nil if unbalanced. + private static func balancedParenClose(_ chars: [Character], from start: Int) -> Int? { + var depth = 0 + var i = start + while i < chars.count { + let c = chars[i] + if c == "\\" { i += 2; continue } + if c == "(" { depth += 1 } + else if c == ")" { + if depth == 0 { return i } + depth -= 1 + } + i += 1 + } + return nil + } + + /// Removes backslash escapes (`\x` -> `x`), recovering the literal label/destination + /// text that `WordConverter` (and CommonMark authors) escape. + private static func unescape(_ text: String) -> String { + guard text.contains("\\") else { return text } + var out = "" + var escaped = false + for ch in text { + if escaped { out.append(ch); escaped = false } + else if ch == "\\" { escaped = true } + else { out.append(ch) } + } + if escaped { out.append("\\") } + return out + } + + // MARK: - Emphasis + + /// `***`/`**`/`*` matchers, compiled once (the expensive part). `parseEmphasis` + /// recurses over every inline run, so rebuilding these per call was needless CPU; + /// the patterns are constant and known-valid, hence `try!`. `NSRegularExpression` + /// is immutable/thread-safe once built, so `nonisolated(unsafe)` is sound here — + /// and it keeps non-`Sendable` closures out of global state (the wraps are cheap + /// and stay local to the call). + nonisolated(unsafe) private static let emphasisRegexes: [NSRegularExpression] = [ + try! NSRegularExpression(pattern: "\\*\\*\\*(.+?)\\*\\*\\*"), + try! NSRegularExpression(pattern: "\\*\\*(.+?)\\*\\*"), + try! NSRegularExpression(pattern: "\\*(.+?)\\*"), + ] + + /// Parses `***`/`**`/`*` emphasis into nested nodes, preferring (at the same + /// position) the longest delimiter — mirroring the renderer's pass order so + /// `***x***` becomes strong(emphasis(x)). + static func parseEmphasis(_ text: String) -> [MarkdownInline] { + guard !text.isEmpty else { return [] } + let wraps: [([MarkdownInline]) -> MarkdownInline] = [ + { .strong([.emphasis($0)]) }, + { .strong($0) }, + { .emphasis($0) }, + ] + let ns = text as NSString + var best: (full: Range, inner: String, wrap: ([MarkdownInline]) -> MarkdownInline)? + for (regex, wrap) in zip(emphasisRegexes, wraps) { + guard let match = regex.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)), + let full = Range(match.range, in: text), + let inner = Range(match.range(at: 1), in: text) else { continue } + // Strictly-less keeps the first (longest) pattern at a tie position. + if best == nil || full.lowerBound < best!.full.lowerBound { + best = (full, String(text[inner]), wrap) + } + } + guard let match = best else { return [.text(text)] } + var nodes: [MarkdownInline] = [] + let prefix = String(text[text.startIndex.. Int? { + var i = start + while i < chars.count { + if chars[i] == character { return i } + i += 1 + } + return nil + } +} + +// MARK: - Plain text projection + +extension MarkdownInline { + /// The node's visible text with all inline formatting removed (links/images + /// collapse to their label/alt; footnote references contribute nothing). Useful + /// for exporters that need a bare string, e.g. spreadsheet cells. + var plainText: String { + switch self { + case .text(let s): return s + case .code(let s): return s + case .strong(let children), .emphasis(let children): return children.plainText + case .link(let label, _): return label.plainText + case .image(let alt, _): return alt + case .footnoteReference: return "" + } + } +} + +extension Array where Element == MarkdownInline { + var plainText: String { map(\.plainText).joined() } +} diff --git a/Tests/PicoDocsTests/ExporterTests.swift b/Tests/PicoDocsTests/ExporterTests.swift new file mode 100644 index 0000000..79c48a6 --- /dev/null +++ b/Tests/PicoDocsTests/ExporterTests.swift @@ -0,0 +1,304 @@ +// +// ExporterTests.swift +// PicoDocsTests +// +// The reverse flow: Markdown / ConverterResult -> office files. The primary signal +// is round-trip — write a file, re-import it with the existing converter, and +// compare the recovered text — using the readers as oracles. PPTX has no in-repo +// reader, so it's checked structurally (unzip + required parts + title text). +// + +import Foundation +import Testing +import ZIPFoundation +@testable import PicoDocs + +@Suite("Exporters (Markdown/ConverterResult -> office files)") +struct ExporterTests { + + // MARK: - Helpers + + /// Reads a single entry from an in-memory archive (test-side mirror of the + /// converters' `readEntry`). + private func entry(_ data: Data, _ path: String) -> Data? { + guard let archive = Archive(data: data, accessMode: .read), + let entry = archive[path] else { return nil } + var out = Data() + _ = try? archive.extract(entry) { out.append($0) } + return out + } + + private func text(_ data: Data, _ path: String) -> String? { + entry(data, path).flatMap { String(data: $0, encoding: .utf8) } + } + + // MARK: - DOCX round-trip (via WordConverter) + + @Test("DOCX round-trips heading, bold, list, and table through WordConverter") + func docxRoundTrip() async throws { + let markdown = """ + # Title + + Some **bold** and *italic* text. + + - First + - Second + + | Name | Age | + | --- | --- | + | Alice | 30 | + """ + let data = try PicoDocsEngine.write(markdown: markdown, to: .docx) + + // Detection should route ZIP -> docx via the well-known entry name. + #expect(text(data, "word/document.xml") != nil) + + let result = try await PicoDocsEngine.convert(data: data, filename: "out.docx") + let recovered = result.markdown() + #expect(recovered.contains("# Title")) + #expect(recovered.contains("**bold**")) + #expect(recovered.contains("*italic*")) + #expect(recovered.contains("- First")) + #expect(recovered.contains("- Second")) + #expect(recovered.contains("Alice")) + #expect(recovered.contains("| Name | Age |")) + } + + @Test("DOCX is a valid OOXML package with the required parts") + func docxPackageParts() throws { + let data = try PicoDocsEngine.write(markdown: "# Hi\n\nBody", to: .docx) + #expect(text(data, "[Content_Types].xml") != nil) + #expect(text(data, "_rels/.rels") != nil) + #expect(text(data, "word/_rels/document.xml.rels") != nil) + let document = try #require(text(data, "word/document.xml")) + #expect(document.contains("")) + #expect(document.contains("Heading1")) + } + + @Test("DOCX embeds image bytes from .image sections and round-trips the ref") + func docxImageRoundTrip() async throws { + // A 1x1 transparent PNG. + let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + let body = DocumentSection(kind: .body, markdown: "![pic.png](pic.png)") + let image = DocumentSection( + title: "pic.png", + kind: .image, + markdown: "![pic.png](pic.png)", + sourcePath: "word/media/pic.png", + metadata: ["mimeType": "image/png", "base64": pngBase64] + ) + let result = ConverterResult(sections: [body, image]) + let data = try PicoDocsEngine.write(result, to: .docx) + + #expect(entry(data, "word/media/pic.png") != nil) + let recovered = try await PicoDocsEngine.convert(data: data, filename: "out.docx") + #expect(recovered.sections.contains { $0.kind == .image }) + } + + @Test("Image-only result embeds its images instead of a blank file") + func docxImageOnlyEmbeds() async throws { + // A 1x1 transparent PNG, carried as the only section (no inline body ref). + let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + let image = DocumentSection( + title: "pic.png", + kind: .image, + markdown: "![pic.png](pic.png)", + sourcePath: "word/media/pic.png", + metadata: ["mimeType": "image/png", "base64": pngBase64] + ) + let result = ConverterResult(sections: [image]) + let data = try PicoDocsEngine.write(result, to: .docx) + + #expect(entry(data, "word/media/pic.png") != nil) + let recovered = try await PicoDocsEngine.convert(data: data, filename: "out.docx") + #expect(recovered.sections.contains { $0.kind == .image }) + } + + // MARK: - XLSX round-trip (via SpreadsheetConverter / CoreXLSX) + + @Test("XLSX round-trips table cells through SpreadsheetConverter") + func xlsxRoundTrip() async throws { + let section = DocumentSection( + title: "People", + kind: .table, + markdown: """ + | Name | Age | + | --- | --- | + | Alice | 30 | + | Bob | 25 | + """, + sheetName: "People" + ) + let result = ConverterResult(sections: [section]) + let data = try PicoDocsEngine.write(result, to: .xlsx) + + #expect(text(data, "xl/workbook.xml")?.contains("People") == true) + + let recovered = try await PicoDocsEngine.convert(data: data, filename: "out.xlsx") + let md = recovered.markdown() + #expect(md.contains("Alice")) + #expect(md.contains("Bob")) + #expect(md.contains("30")) + #expect(md.contains("25")) + } + + // MARK: - PPTX (structural — no in-repo reader) + + @Test("PPTX produces one slide per heading with title text") + func pptxStructure() throws { + let markdown = """ + # Slide One + + Point A + + # Slide Two + + Point B + """ + let data = try PicoDocsEngine.write(markdown: markdown, to: .pptx) + + #expect(text(data, "ppt/presentation.xml") != nil) + #expect(text(data, "ppt/slideMasters/slideMaster1.xml") != nil) + #expect(text(data, "ppt/theme/theme1.xml") != nil) + + let slide1 = try #require(text(data, "ppt/slides/slide1.xml")) + let slide2 = try #require(text(data, "ppt/slides/slide2.xml")) + #expect(slide1.contains("Slide One")) + #expect(slide1.contains("Point A")) + #expect(slide2.contains("Slide Two")) + // Exactly two slides. + #expect(text(data, "ppt/slides/slide3.xml") == nil) + } + + // MARK: - RTF (Apple-only, via RTFConverter) + + #if canImport(AppKit) || canImport(UIKit) + @Test("RTF round-trips prose and bold through RTFConverter") + func rtfRoundTrip() async throws { + let markdown = "A paragraph with **bold** text." + let data = try PicoDocsEngine.write(markdown: markdown, to: .rtf) + let recovered = try await PicoDocsEngine.convert(data: data, filename: "out.rtf") + let md = recovered.markdown() + #expect(md.contains("paragraph")) + #expect(md.contains("**bold**")) + } + #endif + + // MARK: - Contract + + @Test("Empty Markdown throws .emptyDocument") + func emptyMarkdownThrows() { + #expect(throws: PicoDocsError.self) { + try PicoDocsEngine.write(markdown: " \n ", to: .docx) + } + } + + @Test("Unimplemented iWork formats throw unableToExportToRequestedFormat") + func iworkUnsupported() { + #expect(!ExportableFileType.pages.isImplemented) + #expect(!ExportableFileType.keynote.isImplemented) + #expect(throws: PicoDocsError.self) { + try PicoDocsEngine.write(markdown: "# Hi", to: .pages) + } + } + + @Test("RTF availability reflects the current platform") + func rtfImplementedMatchesPlatform() { + #if canImport(AppKit) || canImport(UIKit) + #expect(ExportableFileType.rtf.isImplemented) + #else + // No AppKit/UIKit: the RTF exporter isn't registered, so don't claim support. + #expect(!ExportableFileType.rtf.isImplemented) + #endif + #expect(ExportableFileType.docx.isImplemented) + #expect(ExportableFileType.xlsx.isImplemented) + } + + // MARK: - Regression: spreadsheet title row, image identity, MIME extensions + + @Test("XLSX doesn't turn a sheet's title heading into a data row") + func xlsxSkipsSheetTitleHeading() throws { + // Mirror SpreadsheetConverter output: a `## ` heading + table, with the + // name also carried in `sheetName` (and no lossless csv metadata). + let section = DocumentSection( + title: "People", + kind: .sheet, + markdown: """ + ## People + + | Name | Age | + | --- | --- | + | Alice | 30 | + """, + sheetName: "People" + ) + let data = try PicoDocsEngine.write(ConverterResult(sections: [section]), to: .xlsx) + let sheet = try #require(text(data, "xl/worksheets/sheet1.xml")) + // A1 must be the real header, not the sheet name shifted into the grid. + #expect(sheet.contains("Name")) + #expect(!sheet.contains("People")) + } + + @Test("DOCX keeps same-basename images from different paths distinct") + func docxDistinctSameBasename() throws { + let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + let body = DocumentSection(kind: .body, markdown: "![one](charts/logo.png)\n\n![two](headers/logo.png)") + let img1 = DocumentSection( + title: "logo.png", kind: .image, markdown: "![logo](charts/logo.png)", + sourcePath: "charts/logo.png", metadata: ["mimeType": "image/png", "base64": pngBase64] + ) + let img2 = DocumentSection( + title: "logo.png", kind: .image, markdown: "![logo](headers/logo.png)", + sourcePath: "headers/logo.png", metadata: ["mimeType": "image/png", "base64": pngBase64] + ) + let data = try PicoDocsEngine.write(ConverterResult(sections: [body, img1, img2]), to: .docx) + // Two distinct media parts, not one collapsed by shared basename. + #expect(entry(data, "word/media/logo.png") != nil) + #expect(entry(data, "word/media/logo-2.png") != nil) + } + + @Test("DOCX embeds an unnamed MIME-only image with the right extension") + func docxUnnamedMimeOnlyImage() throws { + let pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + // No sourcePath, no title — only bytes + MIME. + let image = DocumentSection(kind: .image, markdown: "", metadata: ["mimeType": "image/png", "base64": pngBase64]) + let data = try PicoDocsEngine.write(ConverterResult(sections: [image]), to: .docx) + // Embedded with a PNG extension, not dropped or stored as octet-stream `.bin`. + #expect(entry(data, "word/media/image-1.png") != nil) + #expect(entry(data, "word/media/image-1.bin") == nil) + } +} + +@Suite("Markdown inline IR") +struct MarkdownInlineParserTests { + + @Test("Parses nested strong/emphasis, code, and links") + func parsesInline() { + let nodes = MarkdownInlineParser.parse("a **b _c_** `d` [e](http://x)") + // Plain-text projection collapses formatting. + #expect(nodes.plainText == "a b _c_ d e") + + let strong = MarkdownInlineParser.parse("***x***") + #expect(strong == [.strong([.emphasis([.text("x")])])]) + } + + @Test("Parses image and footnote reference") + func parsesImageAndFootnote() { + #expect(MarkdownInlineParser.parse("![alt](pic.png)") == [.image(alt: "alt", source: "pic.png")]) + #expect(MarkdownInlineParser.parse("[^1]") == [.footnoteReference("1")]) + } + + @Test("Handles balanced parens and escaped delimiters in links") + func parsesTrickyLinks() { + // Balanced parens in a bare destination aren't truncated at the first `)`. + #expect( + MarkdownInlineParser.parse("[spec](https://e.com/Foo_(bar))") + == [.link(label: [.text("spec")], destination: "https://e.com/Foo_(bar)")] + ) + // An escaped `]` in a label doesn't end it early and is unescaped. + #expect( + MarkdownInlineParser.parse("![a\\]b](pic.png)") + == [.image(alt: "a]b", source: "pic.png")] + ) + } +}