-
Notifications
You must be signed in to change notification settings - Fork 4
Add office file exporters (DOCX/XLSX/PPTX/RTF) for Markdown output #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ronaldmannak
wants to merge
5
commits into
main
Choose a base branch
from
claude/markdown-to-office-export-bexpyx
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
bc33c18
Add Markdown/ConverterResult -> office file export (reverse flow)
claude be8f6a1
Address Codex/Gemini review on office exporters
ronaldmannak 66d1511
Fix Swift 6 concurrency error; embed image-only results
ronaldmannak 4676e9b
Address second Codex review round on office exporters
ronaldmannak d8f9504
Re-run CI (flaky iWork reader fixture-count tests; no code change)
ronaldmannak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
47 changes: 47 additions & 0 deletions
47
Sources/PicoDocs/Exporters/AttributedStringDOCXExporter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| // | ||
| // AttributedStringDOCXExporter.swift | ||
| // PicoDocs | ||
| // | ||
| // An *optional, opt-in* DOCX writer that uses Apple's `NSAttributedString` | ||
| // `.officeOpenXML` serializer (macOS only). It is intentionally NOT registered in | ||
| // `DocumentExporterRegistry.default`: the hand-rolled `WordprocessingMLExporter` | ||
| // is the primary, all-platform DOCX writer and the round-trip oracle. The repo | ||
| // already moved *off* the `NSAttributedString` DOCX path on the read side because | ||
| // it was "lossy, font-size heading guessing, and a hard throw on iOS" | ||
| // (`WordConverter.swift`), so this is kept only for opt-in use and comparison | ||
| // testing — register it explicitly via `registry.registering(...)` if you want it. | ||
| // | ||
| // `.officeOpenXML` writing is macOS-only; on other Apple platforms this throws | ||
| // `.platformUnavailable` so a registry can fall through. | ||
| // | ||
|
|
||
| #if canImport(AppKit) || canImport(UIKit) | ||
|
|
||
| import Foundation | ||
|
|
||
| public struct AttributedStringDOCXExporter: DocumentExporter { | ||
|
|
||
| public init() {} | ||
|
|
||
| public func accepts(_ format: ExportableFileType) -> Bool { format == .docx } | ||
|
|
||
| public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data { | ||
| guard format == .docx else { throw ExporterError.notAccepted } | ||
| #if canImport(AppKit) | ||
| let attributed = AttributedStringDocumentBuilder.attributedString(from: result) | ||
| do { | ||
| return try attributed.data( | ||
| from: NSRange(location: 0, length: attributed.length), | ||
| documentAttributes: [.documentType: NSAttributedString.DocumentType.officeOpenXML] | ||
| ) | ||
| } catch { | ||
| throw ExporterError.serializationFailed("DOCX (officeOpenXML) serialization failed: \(error.localizedDescription)") | ||
| } | ||
| #else | ||
| // officeOpenXML writing is unavailable on iOS/tvOS/visionOS. | ||
| throw ExporterError.platformUnavailable | ||
| #endif | ||
| } | ||
| } | ||
|
|
||
| #endif |
166 changes: 166 additions & 0 deletions
166
Sources/PicoDocs/Exporters/AttributedStringDocumentBuilder.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| // | ||
| // AttributedStringDocumentBuilder.swift | ||
| // PicoDocs | ||
| // | ||
| // Builds an `NSAttributedString` from a `ConverterResult` using the shared | ||
| // Markdown block + inline IR, so Apple's document writers can serialize it to RTF | ||
| // (`.rtf`) and DOCX (`.officeOpenXML`). | ||
| // | ||
| // Why build the attributed string directly instead of via HTML import: the | ||
| // `NSAttributedString(data:options:[.documentType:.html])` importer is WebKit- | ||
| // backed and must run on the main thread, which would make the exporters unusable | ||
| // from the engine's non-isolated, `Sendable` `write(...)`. Constructing runs from | ||
| // the IR keeps serialization thread-agnostic and pure CPU. | ||
| // | ||
| // Scope: prose fidelity (headings, bold/italic, links, lists, code, blockquotes, | ||
| // tables as tab-separated rows). Images are rendered as their alt text here — the | ||
| // hand-rolled OOXML exporters embed real image bytes; embedding via platform image | ||
| // attachments (NSImage vs UIImage) is intentionally avoided to keep this portable | ||
| // across AppKit/UIKit. | ||
| // | ||
|
|
||
| #if canImport(AppKit) || canImport(UIKit) | ||
|
|
||
| import Foundation | ||
|
|
||
| #if canImport(AppKit) | ||
| import AppKit | ||
| private typealias PlatformFont = NSFont | ||
| #elseif canImport(UIKit) | ||
| import UIKit | ||
| private typealias PlatformFont = UIFont | ||
| #endif | ||
|
|
||
| enum AttributedStringDocumentBuilder { | ||
|
|
||
| private static let baseSize: CGFloat = 12 | ||
|
|
||
| /// Heading point sizes by level (1...6). | ||
| private static func headingSize(_ level: Int) -> CGFloat { | ||
| switch max(1, min(level, 6)) { | ||
| case 1: return 24 | ||
| case 2: return 20 | ||
| case 3: return 17 | ||
| case 4: return 15 | ||
| case 5: return 13 | ||
| default: return 12 | ||
| } | ||
| } | ||
|
|
||
| static func attributedString(from result: ConverterResult) -> NSAttributedString { | ||
| let output = NSMutableAttributedString() | ||
| let blocks = MarkdownBlockParser.parse(result.markdown()) | ||
| for (index, block) in blocks.enumerated() { | ||
| append(block, to: output) | ||
| if index < blocks.count - 1 { | ||
| output.append(NSAttributedString(string: "\n")) | ||
| } | ||
| } | ||
| return output | ||
| } | ||
|
|
||
| // MARK: - Blocks | ||
|
|
||
| private static func append(_ block: MarkdownBlock, to output: NSMutableAttributedString) { | ||
| switch block { | ||
| case .heading(let level, let text): | ||
| output.append(inline(text, size: headingSize(level), bold: true)) | ||
| output.append(NSAttributedString(string: "\n")) | ||
|
|
||
| case .paragraph(let text): | ||
| output.append(inline(text)) | ||
| output.append(NSAttributedString(string: "\n")) | ||
|
|
||
| case .code(let code): | ||
| let attrs: [NSAttributedString.Key: Any] = [.font: monospacedFont(size: baseSize)] | ||
| output.append(NSAttributedString(string: code + "\n", attributes: attrs)) | ||
|
|
||
| case .blockquote(let lines): | ||
| for line in lines { | ||
| output.append(inline(line, italic: true)) | ||
| output.append(NSAttributedString(string: "\n")) | ||
| } | ||
|
|
||
| case .list(let ordered, let items): | ||
| for (i, item) in items.enumerated() { | ||
| let marker = ordered ? "\(i + 1).\t" : "•\t" | ||
| output.append(NSAttributedString(string: marker, attributes: [.font: bodyFont()])) | ||
| output.append(inline(item.replacingOccurrences(of: "\n", with: " "))) | ||
| output.append(NSAttributedString(string: "\n")) | ||
| } | ||
|
|
||
| case .table(let rows): | ||
| for row in rows { | ||
| output.append(inline(row.joined(separator: "\t"))) | ||
| output.append(NSAttributedString(string: "\n")) | ||
| } | ||
|
|
||
| case .rule: | ||
| output.append(NSAttributedString(string: "————————\n", attributes: [.font: bodyFont()])) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Inline | ||
|
|
||
| private static func inline(_ markdown: String, size: CGFloat = baseSize, bold: Bool = false, italic: Bool = false) -> NSAttributedString { | ||
| let result = NSMutableAttributedString() | ||
| render(MarkdownInlineParser.parse(markdown), into: result, size: size, bold: bold, italic: italic, link: nil) | ||
| return result | ||
| } | ||
|
|
||
| private static func render(_ nodes: [MarkdownInline], into output: NSMutableAttributedString, size: CGFloat, bold: Bool, italic: Bool, link: String?) { | ||
| for node in nodes { | ||
| switch node { | ||
| case .text(let s): | ||
| output.append(NSAttributedString(string: s, attributes: attributes(size: size, bold: bold, italic: italic, monospace: false, link: link))) | ||
| case .code(let s): | ||
| output.append(NSAttributedString(string: s, attributes: attributes(size: size, bold: bold, italic: italic, monospace: true, link: link))) | ||
| case .strong(let children): | ||
| render(children, into: output, size: size, bold: true, italic: italic, link: link) | ||
| case .emphasis(let children): | ||
| render(children, into: output, size: size, bold: bold, italic: true, link: link) | ||
| case .link(let label, let destination): | ||
| render(label, into: output, size: size, bold: bold, italic: italic, link: destination) | ||
| case .image(let alt, _): | ||
| output.append(NSAttributedString(string: alt, attributes: attributes(size: size, bold: bold, italic: italic, monospace: false, link: link))) | ||
| case .footnoteReference: | ||
| break // references carry no inline glyph in this projection | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static func attributes(size: CGFloat, bold: Bool, italic: Bool, monospace: Bool, link: String?) -> [NSAttributedString.Key: Any] { | ||
| var attrs: [NSAttributedString.Key: Any] = [ | ||
| .font: monospace ? monospacedFont(size: size, bold: bold) : font(size: size, bold: bold, italic: italic) | ||
| ] | ||
| if let link, let url = URL(string: link) { attrs[.link] = url } | ||
| return attrs | ||
| } | ||
|
|
||
| // MARK: - Fonts | ||
|
|
||
| private static func bodyFont() -> PlatformFont { font(size: baseSize, bold: false, italic: false) } | ||
|
|
||
| private static func font(size: CGFloat, bold: Bool, italic: Bool) -> PlatformFont { | ||
| let base = PlatformFont.systemFont(ofSize: size, weight: bold ? .bold : .regular) | ||
| return italic ? applyItalic(base, size: size) : base | ||
| } | ||
|
|
||
| private static func monospacedFont(size: CGFloat, bold: Bool = false) -> PlatformFont { | ||
| PlatformFont.monospacedSystemFont(ofSize: size, weight: bold ? .bold : .regular) | ||
| } | ||
|
|
||
| private static func applyItalic(_ base: PlatformFont, size: CGFloat) -> PlatformFont { | ||
| #if canImport(AppKit) | ||
| let descriptor = base.fontDescriptor.withSymbolicTraits(.italic) | ||
| return NSFont(descriptor: descriptor, size: size) ?? base | ||
| #else | ||
| guard let descriptor = base.fontDescriptor.withSymbolicTraits( | ||
| base.fontDescriptor.symbolicTraits.union(.traitItalic) | ||
| ) else { return base } | ||
| return UIFont(descriptor: descriptor, size: size) | ||
| #endif | ||
| } | ||
| } | ||
|
|
||
| #endif | ||
36 changes: 36 additions & 0 deletions
36
Sources/PicoDocs/Exporters/AttributedStringRTFExporter.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // | ||
| // AttributedStringRTFExporter.swift | ||
| // PicoDocs | ||
| // | ||
| // Writes RTF (`.rtf`) by building an `NSAttributedString` from the result (see | ||
| // `AttributedStringDocumentBuilder`) and asking Foundation's document writer to | ||
| // serialize it. RTF write support is available across Apple platforms (macOS, | ||
| // iOS, tvOS, visionOS), so this is the lowest-risk binary exporter and round-trips | ||
| // through the existing `RTFConverter`. | ||
| // | ||
|
|
||
| #if canImport(AppKit) || canImport(UIKit) | ||
|
|
||
| import Foundation | ||
|
|
||
| public struct AttributedStringRTFExporter: DocumentExporter { | ||
|
|
||
| public init() {} | ||
|
|
||
| public func accepts(_ format: ExportableFileType) -> Bool { format == .rtf } | ||
|
|
||
| public func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data { | ||
| guard format == .rtf else { throw ExporterError.notAccepted } | ||
| let attributed = AttributedStringDocumentBuilder.attributedString(from: result) | ||
| do { | ||
| return try attributed.data( | ||
| from: NSRange(location: 0, length: attributed.length), | ||
| documentAttributes: [.documentType: NSAttributedString.DocumentType.rtf] | ||
| ) | ||
| } catch { | ||
| throw ExporterError.serializationFailed("RTF serialization failed: \(error.localizedDescription)") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #endif |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| // | ||
| // DocumentExporter.swift | ||
| // PicoDocs | ||
| // | ||
| // The write-side counterpart of `DocumentConverter`: a small, Sendable serializer | ||
| // that turns a canonical `ConverterResult` into office-file bytes for one family | ||
| // of formats. The requested `ExportableFileType` has already been chosen by the | ||
| // caller, so `accepts` only inspects the format — it never inspects the result. | ||
| // | ||
| // Exporters are synchronous (`throws`, not `async`): serialization is pure CPU | ||
| // with no I/O, unlike converters which may touch PDFKit/Vision/network. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| /// Errors an exporter can throw to communicate intent to the registry. | ||
| public enum ExporterError: Error, Sendable { | ||
| /// The exporter declined this format; the registry should try the next | ||
| /// candidate rather than treat it as a hard failure. | ||
| case notAccepted | ||
| /// The exporter accepts the format in principle, but the platform API it needs | ||
| /// is unavailable here (e.g. `NSAttributedString`'s `.officeOpenXML` on tvOS). | ||
| /// The registry falls through to the next candidate, exactly like `.notAccepted`. | ||
| case platformUnavailable | ||
| /// An accepting exporter genuinely failed to serialize. The registry surfaces | ||
| /// this rather than silently falling through to a lower-fidelity writer. | ||
| case serializationFailed(String) | ||
| } | ||
|
|
||
| public protocol DocumentExporter: Sendable { | ||
|
|
||
| /// Cheap check against the requested format only. Must not inspect the result | ||
| /// or perform heavy work. | ||
| func accepts(_ format: ExportableFileType) -> Bool | ||
|
|
||
| /// Serialize the canonical structured form into office-file bytes. | ||
| /// | ||
| /// Throw `ExporterError.notAccepted` / `.platformUnavailable` to defer to the | ||
| /// next exporter; throw `.serializationFailed` for a real failure, which the | ||
| /// registry surfaces rather than degrading silently. | ||
| func write(_ result: ConverterResult, format: ExportableFileType) throws -> Data | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Apple platforms the default RTF writer goes through this builder, and any Markdown/converted DOCX containing
[^id]markers loses them here. The body reference is dropped entirely, and a footnote definition like[^fn1]: notebecomes just: note, so the exported RTF no longer preserves where the note was referenced or which definition it belongs to; emit a literal marker as the DOCX writer does instead of skipping it.Useful? React with 👍 / 👎.