diff --git a/Sources/MarkdownUtilities/FormatConversion/MarkdownDocument+FormatConversion.swift b/Sources/MarkdownUtilities/FormatConversion/MarkdownDocument+FormatConversion.swift index d80360b..fcc1196 100644 --- a/Sources/MarkdownUtilities/FormatConversion/MarkdownDocument+FormatConversion.swift +++ b/Sources/MarkdownUtilities/FormatConversion/MarkdownDocument+FormatConversion.swift @@ -51,6 +51,31 @@ extension MarkdownDocument { return bodyText } + // MARK: - RTF Conversion + + /// Converts the Markdown document to RTF data. + /// + /// - Parameter options: Configuration options for the conversion (default: .default) + /// - Returns: The RTF data representation of the document + /// - Throws: Conversion errors if the operation fails + public func toRTF(options: RTFOptions = .default) async throws -> Data { + let root = try await parseAST() + let converter = RTFConverter() + return try await converter.convert(from: root, options: options) + } + + /// Generates Markdown content from RTF data. + /// + /// - Parameters: + /// - data: The RTF data to convert + /// - options: Configuration options for the generation (default: .default) + /// - Returns: The generated Markdown content + /// - Throws: Generation errors if the operation fails + public static func fromRTF(data: Data, options: RTFGeneratorOptions = .default) async throws -> String { + let generator = RTFGenerator() + return try await generator.generate(from: data, options: options) + } + // MARK: - Private Helpers /// Serializes the frontmatter mapping back to YAML format. @@ -73,13 +98,4 @@ extension MarkdownDocument { // public func toHTML(options: HTMLOptions = .default) async throws -> String { // fatalError("HTML conversion not yet implemented") // } - - // /// Converts the Markdown document to RTF. - // /// - // /// - Parameter options: Configuration options for RTF conversion - // /// - Returns: The RTF data representation of the document - // /// - Throws: Conversion errors if the operation fails - // public func toRTF(options: RTFOptions = .default) async throws -> Data { - // fatalError("RTF conversion not yet implemented") - // } } diff --git a/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConversionError.swift b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConversionError.swift new file mode 100644 index 0000000..a1d2705 --- /dev/null +++ b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConversionError.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Errors that can occur during RTF conversion. +public enum RTFConversionError: Error, Sendable { + /// Failed to generate RTF data from the attributed string. + case failedToGenerateRTF + + /// Failed to parse RTF data into an attributed string. + case failedToParseRTF + + /// The provided data is not valid RTF. + case invalidRTFData +} diff --git a/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConverter.swift b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConverter.swift new file mode 100644 index 0000000..853f9cc --- /dev/null +++ b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFConverter.swift @@ -0,0 +1,389 @@ +import Foundation +import MarkdownSyntax + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +/// Converts Markdown AST to RTF data. +/// +/// This converter walks the Markdown AST and builds an `NSMutableAttributedString` +/// with appropriate font, paragraph style, and other attributes, then exports +/// RTF `Data`. +public struct RTFConverter: MarkdownConverter { + public typealias Output = Data + public typealias Options = RTFOptions + + public init() {} + + public func convert(from root: Root, options: RTFOptions) async throws -> Data { + let result = NSMutableAttributedString() + + processBlockContent(root.children, into: result, options: options, listDepth: 0) + + // Remove trailing newline if present + let length = result.length + if length > 0 { + let lastChar = result.attributedSubstring(from: NSRange(location: length - 1, length: 1)).string + if lastChar == "\n" { + result.deleteCharacters(in: NSRange(location: length - 1, length: 1)) + } + } + + let range = NSRange(location: 0, length: result.length) + guard let rtfData = result.rtf(from: range, documentAttributes: [ + .documentType: NSAttributedString.DocumentType.rtf, + ]) else { + throw RTFConversionError.failedToGenerateRTF + } + + return rtfData + } + + // MARK: - Block Content Processing + + private func processBlockContent( + _ content: [Content], + into result: NSMutableAttributedString, + options: RTFOptions, + listDepth: Int + ) { + for (index, node) in content.enumerated() { + processBlockNode(node, into: result, options: options, listDepth: listDepth) + // Add paragraph separator between blocks (not after the last one) + if index < content.count - 1 { + let separator = NSAttributedString(string: "\n", attributes: [ + .font: baseFont(options: options), + ]) + result.append(separator) + } + } + } + + private func processBlockNode( + _ node: Content, + into result: NSMutableAttributedString, + options: RTFOptions, + listDepth: Int + ) { + switch node { + case let heading as Heading: + processHeading(heading, into: result, options: options) + + case let paragraph as Paragraph: + processParagraph(paragraph, into: result, options: options, indent: CGFloat(listDepth) * options.listIndent) + + case let codeBlock as Code: + processCodeBlock(codeBlock, into: result, options: options) + + case let blockquote as Blockquote: + processBlockquote(blockquote, into: result, options: options) + + case let list as List: + processList(list, into: result, options: options, depth: listDepth) + + case is ThematicBreak: + processThematicBreak(into: result, options: options) + + default: + break + } + } + + // MARK: - Heading + + private func processHeading( + _ heading: Heading, + into result: NSMutableAttributedString, + options: RTFOptions + ) { + let depth = heading.depth.rawValue + let scaleIndex = min(depth - 1, options.headingScales.count - 1) + let scale = options.headingScales[max(0, scaleIndex)] + let fontSize = options.baseFontSize * scale + + let font = boldFont(name: options.baseFontName, size: fontSize, options: options) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacingBefore = options.paragraphSpacing + paragraphStyle.paragraphSpacing = options.paragraphSpacing + + var attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle, + ] + #if canImport(AppKit) || canImport(UIKit) + attributes[.foregroundColor] = PlatformColor.black + #endif + + let headingText = NSMutableAttributedString() + processPhrasingContent(heading.children, into: headingText, baseAttributes: attributes, options: options) + headingText.append(NSAttributedString(string: "\n", attributes: attributes)) + result.append(headingText) + } + + // MARK: - Paragraph + + private func processParagraph( + _ paragraph: Paragraph, + into result: NSMutableAttributedString, + options: RTFOptions, + indent: CGFloat = 0 + ) { + let font = baseFont(options: options) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = options.paragraphSpacing + if indent > 0 { + paragraphStyle.firstLineHeadIndent = indent + paragraphStyle.headIndent = indent + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle, + ] + + let paraText = NSMutableAttributedString() + processPhrasingContent(paragraph.children, into: paraText, baseAttributes: attributes, options: options) + paraText.append(NSAttributedString(string: "\n", attributes: attributes)) + result.append(paraText) + } + + // MARK: - Code Block + + private func processCodeBlock( + _ codeBlock: Code, + into result: NSMutableAttributedString, + options: RTFOptions + ) { + let font = monoFont(options: options) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = options.paragraphSpacing + paragraphStyle.firstLineHeadIndent = options.listIndent + paragraphStyle.headIndent = options.listIndent + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle, + ] + + let codeText = NSAttributedString(string: codeBlock.value + "\n", attributes: attributes) + result.append(codeText) + } + + // MARK: - Blockquote + + private func processBlockquote( + _ blockquote: Blockquote, + into result: NSMutableAttributedString, + options: RTFOptions + ) { + // Process children with increased indent by wrapping paragraphs + for child in blockquote.children { + if let paragraph = child as? Paragraph { + processParagraph(paragraph, into: result, options: options, indent: options.listIndent) + } else { + processBlockNode(child, into: result, options: options, listDepth: 1) + } + } + } + + // MARK: - List + + private func processList( + _ list: List, + into result: NSMutableAttributedString, + options: RTFOptions, + depth: Int + ) { + let ordered = list.ordered + for (itemIndex, listContent) in list.children.enumerated() { + guard let item = listContent as? ListItem else { continue } + + let indent = CGFloat(depth + 1) * options.listIndent + let prefix: String + if ordered { + prefix = "\(itemIndex + 1).\t" + } else { + prefix = "\u{2022}\t" + } + + let font = baseFont(options: options) + + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.paragraphSpacing = options.paragraphSpacing / 2 + paragraphStyle.firstLineHeadIndent = CGFloat(depth) * options.listIndent + paragraphStyle.headIndent = indent + paragraphStyle.tabStops = [NSTextTab(textAlignment: .left, location: indent)] + + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .paragraphStyle: paragraphStyle, + ] + + let itemText = NSMutableAttributedString(string: prefix, attributes: attributes) + + // Process item children + for child in item.children { + if let paragraph = child as? Paragraph { + processPhrasingContent(paragraph.children, into: itemText, baseAttributes: attributes, options: options) + } else if let nestedList = child as? List { + itemText.append(NSAttributedString(string: "\n", attributes: attributes)) + processList(nestedList, into: itemText, options: options, depth: depth + 1) + } + } + + itemText.append(NSAttributedString(string: "\n", attributes: attributes)) + result.append(itemText) + } + } + + // MARK: - Thematic Break + + private func processThematicBreak( + into result: NSMutableAttributedString, + options: RTFOptions + ) { + let font = baseFont(options: options) + let rule = NSAttributedString( + string: String(repeating: "\u{2500}", count: 40) + "\n", + attributes: [.font: font] + ) + result.append(rule) + } + + // MARK: - Phrasing (Inline) Content + + private func processPhrasingContent( + _ content: [PhrasingContent], + into result: NSMutableAttributedString, + baseAttributes: [NSAttributedString.Key: Any], + options: RTFOptions + ) { + for node in content { + processPhrasingNode(node, into: result, baseAttributes: baseAttributes, options: options) + } + } + + private func processPhrasingNode( + _ node: PhrasingContent, + into result: NSMutableAttributedString, + baseAttributes: [NSAttributedString.Key: Any], + options: RTFOptions + ) { + switch node { + case let text as Text: + result.append(NSAttributedString(string: text.value, attributes: baseAttributes)) + + case let inlineCode as InlineCode: + var codeAttributes = baseAttributes + codeAttributes[.font] = monoFont(options: options) + result.append(NSAttributedString(string: inlineCode.value, attributes: codeAttributes)) + + case let strong as Strong: + var strongAttributes = baseAttributes + if let currentFont = baseAttributes[.font] as? PlatformFont { + strongAttributes[.font] = addBoldTrait(to: currentFont, options: options) + } + processPhrasingContent(strong.children, into: result, baseAttributes: strongAttributes, options: options) + + case let emphasis as Emphasis: + var emAttributes = baseAttributes + if let currentFont = baseAttributes[.font] as? PlatformFont { + emAttributes[.font] = addItalicTrait(to: currentFont, options: options) + } + processPhrasingContent(emphasis.children, into: result, baseAttributes: emAttributes, options: options) + + case let delete as Delete: + var deleteAttributes = baseAttributes + deleteAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + processPhrasingContent(delete.children, into: result, baseAttributes: deleteAttributes, options: options) + + case let link as Link: + var linkAttributes = baseAttributes + if options.preserveLinks { + linkAttributes[.link] = link.url + } + let children = link.children.map { $0 as PhrasingContent } + processPhrasingContent(children, into: result, baseAttributes: linkAttributes, options: options) + + case let image as Image: + // Images are lossy in RTF — render alt text in italics + var imageAttributes = baseAttributes + if let currentFont = baseAttributes[.font] as? PlatformFont { + imageAttributes[.font] = addItalicTrait(to: currentFont, options: options) + } + let children = image.children.map { $0 as PhrasingContent } + processPhrasingContent(children, into: result, baseAttributes: imageAttributes, options: options) + + case is Break: + result.append(NSAttributedString(string: "\n", attributes: baseAttributes)) + + case is SoftBreak: + result.append(NSAttributedString(string: " ", attributes: baseAttributes)) + + case let html as HTML: + result.append(NSAttributedString(string: html.value, attributes: baseAttributes)) + + default: + break + } + } + + // MARK: - Font Helpers + + private func baseFont(options: RTFOptions) -> PlatformFont { + PlatformFont(name: options.baseFontName, size: options.baseFontSize) + ?? PlatformFont.systemFont(ofSize: options.baseFontSize) + } + + private func monoFont(options: RTFOptions) -> PlatformFont { + PlatformFont(name: options.monospaceFontName, size: options.baseFontSize) + ?? PlatformFont.systemFont(ofSize: options.baseFontSize) + } + + private func boldFont(name: String, size: CGFloat, options: RTFOptions) -> PlatformFont { + #if canImport(AppKit) + let font = PlatformFont(name: name, size: size) ?? PlatformFont.systemFont(ofSize: size) + return NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + #elseif canImport(UIKit) + if let font = PlatformFont(name: name, size: size) { + if let boldDescriptor = font.fontDescriptor.withSymbolicTraits(.traitBold) { + return PlatformFont(descriptor: boldDescriptor, size: size) + } + return font + } + return PlatformFont.boldSystemFont(ofSize: size) + #endif + } + + private func addBoldTrait(to font: PlatformFont, options: RTFOptions) -> PlatformFont { + #if canImport(AppKit) + return NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + #elseif canImport(UIKit) + var traits = font.fontDescriptor.symbolicTraits + traits.insert(.traitBold) + if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) { + return PlatformFont(descriptor: descriptor, size: font.pointSize) + } + return font + #endif + } + + private func addItalicTrait(to font: PlatformFont, options: RTFOptions) -> PlatformFont { + #if canImport(AppKit) + return NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + #elseif canImport(UIKit) + var traits = font.fontDescriptor.symbolicTraits + traits.insert(.traitItalic) + if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) { + return PlatformFont(descriptor: descriptor, size: font.pointSize) + } + return font + #endif + } +} diff --git a/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGenerator.swift b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGenerator.swift new file mode 100644 index 0000000..52faa08 --- /dev/null +++ b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGenerator.swift @@ -0,0 +1,460 @@ +import Foundation + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +/// Generates Markdown from RTF data. +/// +/// This generator loads RTF `Data` into an `NSAttributedString`, then walks +/// the attributes to produce Markdown text. Heading detection is based on +/// font size heuristics, and list/code detection uses font and indentation cues. +public struct RTFGenerator: MarkdownGenerator { + public typealias Input = Data + public typealias Options = RTFGeneratorOptions + + public init() {} + + public func generate(from input: Data, options: RTFGeneratorOptions) async throws -> String { + let attributedString = try loadRTF(from: input) + let fullString = attributedString.string + + guard !fullString.isEmpty else { + return "" + } + + let baseFontSize = detectBaseFontSize(in: attributedString) + var markdownLines: [String] = [] + let paragraphs = splitIntoParagraphs(attributedString) + + var inCodeBlock = false + + for paragraph in paragraphs { + let text = paragraph.string.trimmingCharacters(in: .newlines) + if text.isEmpty { + if inCodeBlock { + markdownLines.append("```") + markdownLines.append("") + inCodeBlock = false + } + markdownLines.append("") + continue + } + + // Detect code block (all monospace font) + if options.detectCodeBlocks && isMonospace(paragraph) { + if !inCodeBlock { + markdownLines.append("```") + inCodeBlock = true + } + markdownLines.append(text) + continue + } + + if inCodeBlock { + markdownLines.append("```") + markdownLines.append("") + inCodeBlock = false + } + + // Detect heading + if options.detectHeadings { + if let headingLevel = detectHeadingLevel(paragraph, baseFontSize: baseFontSize, threshold: options.headingSizeThreshold) { + let prefix = String(repeating: "#", count: headingLevel) + let inlineMarkdown = convertInlineAttributes(paragraph, skipBold: true) + markdownLines.append("\(prefix) \(inlineMarkdown)") + markdownLines.append("") + continue + } + } + + // Detect list items + if options.detectLists { + if let listItem = detectListItem(text) { + let content = listItem.content + let contentAttrString = extractContentAfterPrefix(paragraph, prefixLength: listItem.prefixLength) + let inlineMarkdown = convertInlineAttributes(contentAttrString) + markdownLines.append("\(listItem.prefix)\(inlineMarkdown)") + _ = content // suppress unused warning + continue + } + } + + // Detect blockquote (large left indent relative to default) + if detectBlockquote(paragraph) { + let inlineMarkdown = convertInlineAttributes(paragraph) + markdownLines.append("> \(inlineMarkdown)") + continue + } + + // Regular paragraph + let inlineMarkdown = convertInlineAttributes(paragraph) + markdownLines.append(inlineMarkdown) + markdownLines.append("") + } + + if inCodeBlock { + markdownLines.append("```") + } + + // Clean up multiple consecutive blank lines + var cleaned: [String] = [] + var lastWasBlank = false + for line in markdownLines { + let isBlank = line.trimmingCharacters(in: .whitespaces).isEmpty + if isBlank && lastWasBlank { + continue + } + cleaned.append(line) + lastWasBlank = isBlank + } + + // Remove trailing blank lines + while let last = cleaned.last, last.trimmingCharacters(in: .whitespaces).isEmpty { + cleaned.removeLast() + } + + return cleaned.joined(separator: "\n") + } + + // MARK: - RTF Loading + + private func loadRTF(from data: Data) throws -> NSAttributedString { + #if canImport(AppKit) + guard let attrString = NSAttributedString( + rtf: data, + documentAttributes: nil + ) else { + throw RTFConversionError.failedToParseRTF + } + return attrString + #elseif canImport(UIKit) + do { + let attrString = try NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil + ) + return attrString + } catch { + throw RTFConversionError.failedToParseRTF + } + #endif + } + + // MARK: - Paragraph Splitting + + private func splitIntoParagraphs(_ attributedString: NSAttributedString) -> [NSAttributedString] { + let fullString = attributedString.string + var paragraphs: [NSAttributedString] = [] + var searchStart = fullString.startIndex + + while searchStart < fullString.endIndex { + let remaining = fullString[searchStart...] + if let newlineRange = remaining.range(of: "\n") { + let paragraphEnd = newlineRange.upperBound + let nsRange = NSRange(searchStart.. CGFloat { + var sizeCounts: [CGFloat: Int] = [:] + let range = NSRange(location: 0, length: attributedString.length) + + attributedString.enumerateAttribute(.font, in: range) { value, attrRange, _ in + if let font = value as? PlatformFont { + let size = font.pointSize + sizeCounts[size, default: 0] += (attrRange.length) + } + } + + // Return the most common font size + return sizeCounts.max(by: { $0.value < $1.value })?.key ?? 14 + } + + // MARK: - Heading Detection + + private func detectHeadingLevel( + _ paragraph: NSAttributedString, + baseFontSize: CGFloat, + threshold: CGFloat + ) -> Int? { + let text = paragraph.string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + // Check if the visible content of the paragraph is bold and larger than base + // Only examine the trimmed range (skip trailing newlines/whitespace) + let fullText = paragraph.string + let trimmedText = fullText.trimmingCharacters(in: .whitespacesAndNewlines) + let trimStart = fullText.distance( + from: fullText.startIndex, + to: fullText.range(of: trimmedText)?.lowerBound ?? fullText.startIndex + ) + let trimmedRange = NSRange(location: trimStart, length: trimmedText.count) + + var isBold = true + var fontSize: CGFloat = 0 + var hasFont = false + + paragraph.enumerateAttribute(.font, in: trimmedRange) { value, _, _ in + guard let font = value as? PlatformFont else { return } + hasFont = true + fontSize = max(fontSize, font.pointSize) + + #if canImport(AppKit) + let traits = NSFontManager.shared.traits(of: font) + if !traits.contains(.boldFontMask) { + isBold = false + } + #elseif canImport(UIKit) + let traits = font.fontDescriptor.symbolicTraits + if !traits.contains(.traitBold) { + isBold = false + } + #endif + } + + guard hasFont && isBold && fontSize >= baseFontSize * threshold else { + return nil + } + + // Determine heading level based on size ratio + let ratio = fontSize / baseFontSize + if ratio >= 1.8 { return 1 } + if ratio >= 1.4 { return 2 } + if ratio >= 1.2 { return 3 } + if ratio >= 1.05 { return 4 } + if ratio >= 0.95 { return 5 } + return 6 + } + + // MARK: - Monospace Detection + + private func isMonospace(_ paragraph: NSAttributedString) -> Bool { + let text = paragraph.string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return false } + + let range = NSRange(location: 0, length: paragraph.length) + var allMono = true + + paragraph.enumerateAttribute(.font, in: range) { value, attrRange, stop in + guard let font = value as? PlatformFont else { return } + // Check the text in this range — skip whitespace-only runs + let subRange = Range(attrRange, in: paragraph.string) + if let subRange = subRange { + let subText = String(paragraph.string[subRange]) + if subText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } + } + + #if canImport(AppKit) + let traits = NSFontManager.shared.traits(of: font) + if !traits.contains(.fixedPitchFontMask) { + // Check font name as fallback + let name = font.fontName.lowercased() + if !name.contains("menlo") && !name.contains("courier") && !name.contains("mono") && !name.contains("consolas") { + allMono = false + stop.pointee = true + } + } + #elseif canImport(UIKit) + let traits = font.fontDescriptor.symbolicTraits + if !traits.contains(.traitMonoSpace) { + let name = font.fontName.lowercased() + if !name.contains("menlo") && !name.contains("courier") && !name.contains("mono") && !name.contains("consolas") { + allMono = false + stop.pointee = true + } + } + #endif + } + + return allMono + } + + // MARK: - List Detection + + private struct ListItemInfo { + let prefix: String + let content: String + let prefixLength: Int + } + + private func detectListItem(_ text: String) -> ListItemInfo? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + + // Bullet list: starts with bullet character or dash + if trimmed.hasPrefix("\u{2022}") { + let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces) + let prefixLength = text.distance(from: text.startIndex, to: text.firstIndex(of: "\u{2022}")!) + 1 + return ListItemInfo(prefix: "- ", content: content, prefixLength: prefixLength + 1) + } + + if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { + let content = String(trimmed.dropFirst(2)) + return ListItemInfo(prefix: "- ", content: content, prefixLength: 2) + } + + // Ordered list: starts with number followed by . or ) + let pattern = #"^(\d+)[.\)]\s*"# + if let match = trimmed.range(of: pattern, options: .regularExpression) { + let matchedPrefix = String(trimmed[match]) + let content = String(trimmed[match.upperBound...]) + return ListItemInfo(prefix: matchedPrefix, content: content, prefixLength: matchedPrefix.count) + } + + return nil + } + + private func extractContentAfterPrefix(_ paragraph: NSAttributedString, prefixLength: Int) -> NSAttributedString { + let text = paragraph.string + // Find the actual content start by skipping bullet/number and whitespace/tabs + var contentStart = text.startIndex + var skipped = 0 + for char in text { + if skipped >= prefixLength { + // Also skip any tabs after prefix + if char == "\t" || char == " " { + contentStart = text.index(after: contentStart) + continue + } + break + } + skipped += 1 + contentStart = text.index(after: contentStart) + } + + let nsRange = NSRange(contentStart.. 0 else { + return NSAttributedString(string: "") + } + return paragraph.attributedSubstring(from: nsRange) + } + + // MARK: - Blockquote Detection + + private func detectBlockquote(_ paragraph: NSAttributedString) -> Bool { + guard paragraph.length > 0 else { return false } + + let attrs = paragraph.attributes(at: 0, effectiveRange: nil) + if let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle { + return paragraphStyle.headIndent >= 24 || paragraphStyle.firstLineHeadIndent >= 24 + } + return false + } + + // MARK: - Inline Attribute Conversion + + private func convertInlineAttributes(_ attributedString: NSAttributedString, skipBold: Bool = false) -> String { + let text = attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return "" } + + var result = "" + let trimmedStart = attributedString.string.distance( + from: attributedString.string.startIndex, + to: attributedString.string.firstIndex(where: { !$0.isWhitespace && !$0.isNewline }) ?? attributedString.string.startIndex + ) + + let trimmedString = attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedRange = NSRange(location: trimmedStart, length: trimmedString.count) + + guard trimmedRange.location + trimmedRange.length <= attributedString.length else { + return text + } + + let trimmedAttrString = attributedString.attributedSubstring(from: trimmedRange) + + let range = NSRange(location: 0, length: trimmedAttrString.length) + + trimmedAttrString.enumerateAttributes(in: range) { attrs, attrRange, _ in + guard let subRange = Range(attrRange, in: trimmedAttrString.string) else { return } + var segment = String(trimmedAttrString.string[subRange]) + + // Skip empty segments + guard !segment.isEmpty else { return } + + // Detect formatting traits + var isBold = false + var isItalic = false + var isMono = false + var isStrikethrough = false + var linkURL: URL? + + if let font = attrs[.font] as? PlatformFont { + #if canImport(AppKit) + let traits = NSFontManager.shared.traits(of: font) + isBold = traits.contains(.boldFontMask) + isItalic = traits.contains(.italicFontMask) + isMono = traits.contains(.fixedPitchFontMask) || + font.fontName.lowercased().contains("menlo") || + font.fontName.lowercased().contains("courier") || + font.fontName.lowercased().contains("mono") + #elseif canImport(UIKit) + let traits = font.fontDescriptor.symbolicTraits + isBold = traits.contains(.traitBold) + isItalic = traits.contains(.traitItalic) + isMono = traits.contains(.traitMonoSpace) || + font.fontName.lowercased().contains("menlo") || + font.fontName.lowercased().contains("courier") || + font.fontName.lowercased().contains("mono") + #endif + } + + if let strikethrough = attrs[.strikethroughStyle] as? Int, strikethrough != 0 { + isStrikethrough = true + } + + if let link = attrs[.link] { + if let url = link as? URL { + linkURL = url + } else if let urlString = link as? String { + linkURL = URL(string: urlString) + } + } + + // Build markdown wrapping + if skipBold { + isBold = false + } + + if let url = linkURL { + segment = "[\(segment)](\(url.absoluteString))" + } + + if isMono { + segment = "`\(segment)`" + } else { + if isBold && isItalic { + segment = "***\(segment)***" + } else if isBold { + segment = "**\(segment)**" + } else if isItalic { + segment = "*\(segment)*" + } + } + + if isStrikethrough { + segment = "~~\(segment)~~" + } + + result += segment + } + + return result + } +} diff --git a/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGeneratorOptions.swift b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGeneratorOptions.swift new file mode 100644 index 0000000..f08d988 --- /dev/null +++ b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFGeneratorOptions.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Configuration options for converting RTF to Markdown. +public struct RTFGeneratorOptions: ConversionOptions, Sendable { + + // MARK: - ConversionOptions Conformance + + /// Whether to include YAML frontmatter in the Markdown output. + public let includeFrontmatter: Bool + + // MARK: - Heading Detection + + /// Whether to detect headings based on font size and weight (default: true). + public let detectHeadings: Bool + + /// Minimum font size ratio (relative to the most common font size) + /// to consider a paragraph a heading (default: 1.2). + public let headingSizeThreshold: CGFloat + + // MARK: - List Detection + + /// Whether to detect list items based on bullet/number prefixes and indentation (default: true). + public let detectLists: Bool + + // MARK: - Code Detection + + /// Whether to detect code blocks based on monospace font usage (default: true). + public let detectCodeBlocks: Bool + + // MARK: - Initialization + + /// Creates RTF-to-Markdown generation options with the specified settings. + public init( + includeFrontmatter: Bool = false, + detectHeadings: Bool = true, + headingSizeThreshold: CGFloat = 1.2, + detectLists: Bool = true, + detectCodeBlocks: Bool = true + ) { + self.includeFrontmatter = includeFrontmatter + self.detectHeadings = detectHeadings + self.headingSizeThreshold = headingSizeThreshold + self.detectLists = detectLists + self.detectCodeBlocks = detectCodeBlocks + } + + // MARK: - Presets + + /// Default options for RTF-to-Markdown generation. + public static let `default` = RTFGeneratorOptions() +} diff --git a/Sources/MarkdownUtilities/FormatConversion/RichText/RTFOptions.swift b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFOptions.swift new file mode 100644 index 0000000..fede179 --- /dev/null +++ b/Sources/MarkdownUtilities/FormatConversion/RichText/RTFOptions.swift @@ -0,0 +1,83 @@ +import Foundation + +#if canImport(AppKit) +import AppKit +/// Platform-specific font type (NSFont on macOS). +public typealias PlatformFont = NSFont +/// Platform-specific color type (NSColor on macOS). +public typealias PlatformColor = NSColor +#elseif canImport(UIKit) +import UIKit +/// Platform-specific font type (UIFont on iOS/tvOS/watchOS). +public typealias PlatformFont = UIFont +/// Platform-specific color type (UIColor on iOS/tvOS/watchOS). +public typealias PlatformColor = UIColor +#endif + +/// Configuration options for converting Markdown to RTF. +public struct RTFOptions: ConversionOptions, Sendable { + + // MARK: - ConversionOptions Conformance + + /// Whether to include YAML frontmatter in the RTF output. + public let includeFrontmatter: Bool + + // MARK: - Font Options + + /// Base font name for body text (default: "Helvetica"). + public let baseFontName: String + + /// Base font size in points (default: 14). + public let baseFontSize: CGFloat + + /// Monospace font name for code elements (default: "Menlo"). + public let monospaceFontName: String + + // MARK: - Heading Options + + /// Scale factors for heading levels h1–h6 relative to `baseFontSize`. + /// + /// Must contain exactly 6 elements. Index 0 corresponds to h1. + public let headingScales: [CGFloat] + + // MARK: - Spacing Options + + /// Spacing in points after paragraphs (default: 8). + public let paragraphSpacing: CGFloat + + /// Indentation in points per list nesting level (default: 24). + public let listIndent: CGFloat + + // MARK: - Content Options + + /// Whether to preserve hyperlinks as `.link` attributes (default: true). + public let preserveLinks: Bool + + // MARK: - Initialization + + /// Creates RTF conversion options with the specified settings. + public init( + includeFrontmatter: Bool = false, + baseFontName: String = "Helvetica", + baseFontSize: CGFloat = 14, + monospaceFontName: String = "Menlo", + headingScales: [CGFloat] = [2.0, 1.5, 1.25, 1.1, 1.0, 0.9], + paragraphSpacing: CGFloat = 8, + listIndent: CGFloat = 24, + preserveLinks: Bool = true + ) { + self.includeFrontmatter = includeFrontmatter + self.baseFontName = baseFontName + self.baseFontSize = baseFontSize + self.monospaceFontName = monospaceFontName + self.headingScales = headingScales + self.paragraphSpacing = paragraphSpacing + self.listIndent = listIndent + self.preserveLinks = preserveLinks + } + + // MARK: - Presets + + /// Default options for RTF conversion. + public static let `default` = RTFOptions() +} diff --git a/Sources/md-utils/ConvertCommands/ConvertCommands.swift b/Sources/md-utils/ConvertCommands/ConvertCommands.swift index 41f927d..4f440f7 100644 --- a/Sources/md-utils/ConvertCommands/ConvertCommands.swift +++ b/Sources/md-utils/ConvertCommands/ConvertCommands.swift @@ -10,16 +10,15 @@ extension CLIEntry { struct ConvertCommands: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "convert", - abstract: "Convert Markdown files to other formats", + abstract: "Convert Markdown files to and from other formats", discussion: """ Provides format conversion for Markdown files. Available commands: - to-text: Convert Markdown to plain text - - Future formats (planned): - - to-html: Convert Markdown to HTML + - to-csv: Convert Markdown files to CSV - to-rtf: Convert Markdown to RTF + - from-rtf: Convert RTF to Markdown By default, processes directories recursively and outputs converted files with the appropriate extension. @@ -27,6 +26,8 @@ extension CLIEntry { subcommands: [ ToText.self, ToCSV.self, + ToRTF.self, + FromRTF.self, ] ) } diff --git a/Sources/md-utils/ConvertCommands/FromRTF.swift b/Sources/md-utils/ConvertCommands/FromRTF.swift new file mode 100644 index 0000000..cf0e14c --- /dev/null +++ b/Sources/md-utils/ConvertCommands/FromRTF.swift @@ -0,0 +1,274 @@ +// +// FromRTF.swift +// md-utils +// + +import ArgumentParser +import Foundation +import MarkdownUtilities +import PathKit + +extension CLIEntry.ConvertCommands { + /// Convert RTF to Markdown + struct FromRTF: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "from-rtf", + abstract: "Convert RTF files to Markdown", + discussion: """ + Converts RTF (Rich Text Format) files to Markdown by detecting + headings, bold, italic, code, links, and lists from text attributes. + + EXAMPLES: + # Single file to stdout + md-utils convert from-rtf document.rtf + + # Single file to specific file + md-utils convert from-rtf document.rtf -o output.md + + # Single file to directory + md-utils convert from-rtf src/document.rtf -o output/ + + # Batch conversion + md-utils convert from-rtf docs/ -o output/ + + # In-place conversion (.rtf → .md) + md-utils convert from-rtf docs/ --in-place + + # From stdin + cat document.rtf | md-utils convert from-rtf -o output.md + + # Disable detection heuristics + md-utils convert from-rtf file.rtf --no-detect-headings + md-utils convert from-rtf file.rtf --no-detect-lists + md-utils convert from-rtf file.rtf --no-detect-code-blocks + + By default: + - Single file outputs to stdout + - Detects headings from font size and weight + - Detects lists from bullet/number prefixes + - Detects code blocks from monospace fonts + """ + ) + + @OptionGroup var options: GlobalOptions + + @Option( + name: [.short, .long], + help: """ + Output file or directory. For single file: writes to this file or \ + dir/basename.md. For multiple files: must be a directory. \ + If not specified, writes to stdout (single file) or requires \ + --in-place (batch). + """, + completion: .file(), + transform: { Path($0) } + ) + var output: Path? + + @Flag( + name: .long, + help: """ + Convert .rtf files to .md in their original locations. \ + Cannot be used with --output. + """ + ) + var inPlace: Bool = false + + @Flag( + name: .long, + inversion: .prefixedNo, + help: "Detect headings from font size and weight (use --no-detect-headings to disable)" + ) + var detectHeadings: Bool = true + + @Flag( + name: .long, + inversion: .prefixedNo, + help: "Detect lists from bullet/number prefixes (use --no-detect-lists to disable)" + ) + var detectLists: Bool = true + + @Flag( + name: .long, + inversion: .prefixedNo, + help: "Detect code blocks from monospace fonts (use --no-detect-code-blocks to disable)" + ) + var detectCodeBlocks: Bool = true + + mutating func run() async throws { + try validateFlags() + let inputMode = try determineInputMode() + + let generatorOptions = RTFGeneratorOptions( + detectHeadings: detectHeadings, + detectLists: detectLists, + detectCodeBlocks: detectCodeBlocks + ) + + switch inputMode { + case .stdin: + try await processStdin(options: generatorOptions) + case .singleFile(let file): + try await processSingleFile(file, options: generatorOptions) + case .multipleFiles(let files): + try await processMultipleFiles(files, options: generatorOptions) + } + } + + // MARK: - Input Mode Detection + + enum InputMode { + case stdin + case singleFile(Path) + case multipleFiles([Path]) + } + + func determineInputMode() throws -> InputMode { + if options.paths.isEmpty { + if isatty(STDIN_FILENO) == 0 { + return .stdin + } else { + throw ValidationError("No input specified. Provide file paths or pipe input to stdin.") + } + } + + let files = try options.resolvedPaths(defaultExtensions: "rtf") + + if files.isEmpty { + throw ValidationError("No RTF files found to process") + } + + if files.count == 1 { + return .singleFile(files[0]) + } else { + return .multipleFiles(files) + } + } + + // MARK: - Validation + + func validateFlags() throws { + if output != nil && inPlace { + throw ValidationError("Cannot use both --output and --in-place") + } + } + + // MARK: - Processing Methods + + func processStdin(options generatorOptions: RTFGeneratorOptions) async throws { + let stdinData = FileHandle.standardInput.readDataToEndOfFile() + + guard !stdinData.isEmpty else { + throw ValidationError("No input received from stdin") + } + + let markdown = try await MarkdownDocument.fromRTF(data: stdinData, options: generatorOptions) + + if let outputPath = output { + try outputPath.write(markdown) + } else { + print(markdown, terminator: "") + } + } + + func processSingleFile(_ file: Path, options generatorOptions: RTFGeneratorOptions) async throws { + let data: Data = try file.read() + let markdown = try await MarkdownDocument.fromRTF(data: data, options: generatorOptions) + + if let outputPath = output { + let finalPath = resolveOutputPath(outputPath, for: file) + if !finalPath.parent().exists { + try finalPath.parent().mkpath() + } + try finalPath.write(markdown) + } else if inPlace { + let mdPath = file.parent() + "\(file.lastComponentWithoutExtension).md" + try mdPath.write(markdown) + } else { + print(markdown, terminator: "") + } + } + + func processMultipleFiles(_ files: [Path], options generatorOptions: RTFGeneratorOptions) async throws { + if output == nil && !inPlace { + throw ValidationError( + "Multiple input files require --output or --in-place" + ) + } + + if let outputPath = output { + if outputPath.exists && !outputPath.isDirectory { + throw ValidationError( + "Batch conversion requires output directory, not file: \(outputPath)" + ) + } + if !outputPath.exists { + try outputPath.mkpath() + } + } + + var successCount = 0 + var errorCount = 0 + + for file in files { + do { + let data: Data = try file.read() + let markdown = try await MarkdownDocument.fromRTF(data: data, options: generatorOptions) + + let finalPath: Path + if inPlace { + finalPath = file.parent() + "\(file.lastComponentWithoutExtension).md" + } else if let outputDir = output { + finalPath = outputDir + "\(file.lastComponentWithoutExtension).md" + } else { + fatalError("Unreachable: validation should have caught this") + } + + if !finalPath.parent().exists { + try finalPath.parent().mkpath() + } + + try finalPath.write(markdown) + + FileHandle.standardError.write( + "✓ Converted: \(file) → \(finalPath)\n".data(using: .utf8) ?? Data() + ) + successCount += 1 + } catch { + FileHandle.standardError.write( + "✗ Error converting \(file): \(error.localizedDescription)\n".data(using: .utf8) ?? Data() + ) + errorCount += 1 + } + } + + FileHandle.standardError.write( + "\nConversion complete:\n".data(using: .utf8) ?? Data() + ) + FileHandle.standardError.write( + " Success: \(successCount)\n".data(using: .utf8) ?? Data() + ) + if errorCount > 0 { + FileHandle.standardError.write( + " Errors: \(errorCount)\n".data(using: .utf8) ?? Data() + ) + throw ExitCode.failure + } + } + + // MARK: - Helper Methods + + func resolveOutputPath(_ outputPath: Path, for inputFile: Path) -> Path { + if outputPath.isDirectory { + return outputPath + "\(inputFile.lastComponentWithoutExtension).md" + } + + if outputPath.string.hasSuffix("/") { + let dirPath = Path(String(outputPath.string.dropLast())) + return dirPath + "\(inputFile.lastComponentWithoutExtension).md" + } + + return outputPath + } + } +} diff --git a/Sources/md-utils/ConvertCommands/ToRTF.swift b/Sources/md-utils/ConvertCommands/ToRTF.swift new file mode 100644 index 0000000..c9a3c51 --- /dev/null +++ b/Sources/md-utils/ConvertCommands/ToRTF.swift @@ -0,0 +1,292 @@ +// +// ToRTF.swift +// md-utils +// + +import ArgumentParser +import Foundation +import MarkdownUtilities +import PathKit + +extension CLIEntry.ConvertCommands { + /// Convert Markdown to RTF + struct ToRTF: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "to-rtf", + abstract: "Convert Markdown files to RTF", + discussion: """ + Converts Markdown files to RTF (Rich Text Format) with formatted + text including headings, bold, italic, code, and links. + + EXAMPLES: + # Single file to stdout (raw RTF data) + md-utils convert to-rtf README.md + + # Single file to specific file + md-utils convert to-rtf README.md -o output.rtf + + # Single file to directory + md-utils convert to-rtf src/README.md -o output/ + + # Batch conversion + md-utils convert to-rtf docs/ -o output/ + md-utils convert to-rtf *.md -o output/ + + # In-place conversion (.md → .rtf) + md-utils convert to-rtf docs/ --in-place + md-utils convert to-rtf file.md --in-place + + # From stdin + cat README.md | md-utils convert to-rtf -o output.rtf + + # With custom font options + md-utils convert to-rtf file.md -o out.rtf --font-name "Georgia" --font-size 16 + + By default: + - Single file outputs raw RTF to stdout + - Uses Helvetica 14pt for body text + - Uses Menlo for code + - Preserves hyperlinks + """ + ) + + @OptionGroup var options: GlobalOptions + + @Option( + name: [.short, .long], + help: """ + Output file or directory. For single file: writes to this file or \ + dir/basename.rtf. For multiple files: must be a directory. \ + If not specified, writes to stdout (single file) or requires \ + --in-place (batch). + """, + completion: .file(), + transform: { Path($0) } + ) + var output: Path? + + @Flag( + name: .long, + help: """ + Convert .md files to .rtf in their original locations. \ + Cannot be used with --output. + """ + ) + var inPlace: Bool = false + + @Flag( + name: .long, + help: "Include YAML frontmatter in the RTF output" + ) + var includeFrontmatter: Bool = false + + @Option( + name: .long, + help: "Base font name for body text (default: Helvetica)" + ) + var fontName: String = "Helvetica" + + @Option( + name: .long, + help: "Base font size in points (default: 14)" + ) + var fontSize: Double = 14 + + @Option( + name: .long, + help: "Monospace font name for code elements (default: Menlo)" + ) + var monoFont: String = "Menlo" + + @Flag( + name: .long, + inversion: .prefixedNo, + help: "Preserve hyperlinks in RTF output (use --no-preserve-links to disable)" + ) + var preserveLinks: Bool = true + + mutating func run() async throws { + try validateFlags() + let inputMode = try determineInputMode() + + let conversionOptions = RTFOptions( + includeFrontmatter: includeFrontmatter, + baseFontName: fontName, + baseFontSize: CGFloat(fontSize), + monospaceFontName: monoFont, + preserveLinks: preserveLinks + ) + + switch inputMode { + case .stdin: + try await processStdin(options: conversionOptions) + case .singleFile(let file): + try await processSingleFile(file, options: conversionOptions) + case .multipleFiles(let files): + try await processMultipleFiles(files, options: conversionOptions) + } + } + + // MARK: - Input Mode Detection + + enum InputMode { + case stdin + case singleFile(Path) + case multipleFiles([Path]) + } + + func determineInputMode() throws -> InputMode { + if options.paths.isEmpty { + if isatty(STDIN_FILENO) == 0 { + return .stdin + } else { + throw ValidationError("No input specified. Provide file paths or pipe input to stdin.") + } + } + + let files = try options.resolvedPaths() + + if files.isEmpty { + throw ValidationError("No Markdown files found to process") + } + + if files.count == 1 { + return .singleFile(files[0]) + } else { + return .multipleFiles(files) + } + } + + // MARK: - Validation + + func validateFlags() throws { + if output != nil && inPlace { + throw ValidationError("Cannot use both --output and --in-place") + } + } + + // MARK: - Processing Methods + + func processStdin(options conversionOptions: RTFOptions) async throws { + var stdinContent = "" + while let line = readLine(strippingNewline: false) { + stdinContent += line + } + + guard !stdinContent.isEmpty else { + throw ValidationError("No input received from stdin") + } + + let doc = try MarkdownDocument(content: stdinContent) + let rtfData = try await doc.toRTF(options: conversionOptions) + + if let outputPath = output { + try outputPath.write(rtfData) + } else { + FileHandle.standardOutput.write(rtfData) + } + } + + func processSingleFile(_ file: Path, options conversionOptions: RTFOptions) async throws { + let content: String = try file.read() + let doc = try MarkdownDocument(content: content) + let rtfData = try await doc.toRTF(options: conversionOptions) + + if let outputPath = output { + let finalPath = resolveOutputPath(outputPath, for: file) + if !finalPath.parent().exists { + try finalPath.parent().mkpath() + } + try finalPath.write(rtfData) + } else if inPlace { + let rtfPath = file.parent() + "\(file.lastComponentWithoutExtension).rtf" + try rtfPath.write(rtfData) + } else { + FileHandle.standardOutput.write(rtfData) + } + } + + func processMultipleFiles(_ files: [Path], options conversionOptions: RTFOptions) async throws { + if output == nil && !inPlace { + throw ValidationError( + "Multiple input files require --output or --in-place" + ) + } + + if let outputPath = output { + if outputPath.exists && !outputPath.isDirectory { + throw ValidationError( + "Batch conversion requires output directory, not file: \(outputPath)" + ) + } + if !outputPath.exists { + try outputPath.mkpath() + } + } + + var successCount = 0 + var errorCount = 0 + + for file in files { + do { + let content: String = try file.read() + let doc = try MarkdownDocument(content: content) + let rtfData = try await doc.toRTF(options: conversionOptions) + + let finalPath: Path + if inPlace { + finalPath = file.parent() + "\(file.lastComponentWithoutExtension).rtf" + } else if let outputDir = output { + finalPath = outputDir + "\(file.lastComponentWithoutExtension).rtf" + } else { + fatalError("Unreachable: validation should have caught this") + } + + if !finalPath.parent().exists { + try finalPath.parent().mkpath() + } + + try finalPath.write(rtfData) + + FileHandle.standardError.write( + "✓ Converted: \(file) → \(finalPath)\n".data(using: .utf8) ?? Data() + ) + successCount += 1 + } catch { + FileHandle.standardError.write( + "✗ Error converting \(file): \(error.localizedDescription)\n".data(using: .utf8) ?? Data() + ) + errorCount += 1 + } + } + + FileHandle.standardError.write( + "\nConversion complete:\n".data(using: .utf8) ?? Data() + ) + FileHandle.standardError.write( + " Success: \(successCount)\n".data(using: .utf8) ?? Data() + ) + if errorCount > 0 { + FileHandle.standardError.write( + " Errors: \(errorCount)\n".data(using: .utf8) ?? Data() + ) + throw ExitCode.failure + } + } + + // MARK: - Helper Methods + + func resolveOutputPath(_ outputPath: Path, for inputFile: Path) -> Path { + if outputPath.isDirectory { + return outputPath + "\(inputFile.lastComponentWithoutExtension).rtf" + } + + if outputPath.string.hasSuffix("/") { + let dirPath = Path(String(outputPath.string.dropLast())) + return dirPath + "\(inputFile.lastComponentWithoutExtension).rtf" + } + + return outputPath + } + } +} diff --git a/Sources/md-utils/GlobalOptions.swift b/Sources/md-utils/GlobalOptions.swift index 3950af1..76b6a44 100644 --- a/Sources/md-utils/GlobalOptions.swift +++ b/Sources/md-utils/GlobalOptions.swift @@ -46,6 +46,47 @@ struct GlobalOptions: ParsableArguments { ) var noSort: Bool = false + /// Resolve paths to files with the given default extensions. + /// + /// Uses `defaultExtensions` instead of the `--extensions` option default + /// when the user hasn't explicitly set `--extensions`. + /// + /// - Parameter defaultExtensions: Comma-separated extensions to use (e.g., "rtf") + /// - Returns: Array of file paths to process + /// - Throws: If a specified path doesn't exist + func resolvedPaths(defaultExtensions: String) throws -> [Path] { + let pathsToProcess = paths.isEmpty ? [Path.current] : paths + + var resolvedFiles: [Path] = [] + let allowedExtensions = Set(defaultExtensions.split(separator: ",").map(String.init)) + + for path in pathsToProcess { + guard path.exists else { + throw ValidationError("Path does not exist: \(path)") + } + + if path.isDirectory { + let files = try expandDirectory( + path, + recursive: recursive, + includeHidden: includeHidden, + extensions: allowedExtensions + ) + resolvedFiles.append(contentsOf: files) + } else { + if matchesExtension(path, allowedExtensions: allowedExtensions) { + resolvedFiles.append(path) + } + } + } + + if !noSort { + resolvedFiles.sort { $0.string < $1.string } + } + + return resolvedFiles + } + /// Resolve paths to actual Markdown files to process. /// /// Expands directories to their children, applies recursion and hidden file filters, diff --git a/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFConverterTests.swift b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFConverterTests.swift new file mode 100644 index 0000000..620cad7 --- /dev/null +++ b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFConverterTests.swift @@ -0,0 +1,276 @@ +// +// RTFConverterTests.swift +// MarkdownUtilities +// + +import Foundation +import MarkdownUtilities +import Testing + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +@Suite("RTFConverter Tests") +struct RTFConverterTests { + + // MARK: - Helper + + /// Loads RTF data back into an NSAttributedString for inspection. + private func loadRTF(_ data: Data) throws -> NSAttributedString { + #if canImport(AppKit) + guard let attrString = NSAttributedString(rtf: data, documentAttributes: nil) else { + throw RTFConversionError.failedToParseRTF + } + return attrString + #elseif canImport(UIKit) + return try NSAttributedString( + data: data, + options: [.documentType: NSAttributedString.DocumentType.rtf], + documentAttributes: nil + ) + #endif + } + + // MARK: - Basic Conversion + + @Test + func `Convert simple paragraph to RTF`() async throws { + let markdown = "Hello, world!" + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("Hello, world!")) + } + + @Test + func `Convert heading to RTF with larger font`() async throws { + let markdown = "# Main Title" + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("Main Title")) + + // Check that the heading has a larger font size than the default + let attrs = attrString.attributes(at: 0, effectiveRange: nil) + let font = try #require(attrs[.font] as? PlatformFont) + // h1 scale is 2.0 × 14 = 28 + #expect(font.pointSize > 20) + } + + @Test + func `Convert bold text to RTF`() async throws { + let markdown = "This is **bold** text." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("bold")) + + // Find the "bold" text and check its font trait + let boldRange = (attrString.string as NSString).range(of: "bold") + let attrs = attrString.attributes(at: boldRange.location, effectiveRange: nil) + let font = try #require(attrs[.font] as? PlatformFont) + + #if canImport(AppKit) + let traits = NSFontManager.shared.traits(of: font) + #expect(traits.contains(.boldFontMask)) + #elseif canImport(UIKit) + let traits = font.fontDescriptor.symbolicTraits + #expect(traits.contains(.traitBold)) + #endif + } + + @Test + func `Convert italic text to RTF`() async throws { + let markdown = "This is *italic* text." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + + let italicRange = (attrString.string as NSString).range(of: "italic") + let attrs = attrString.attributes(at: italicRange.location, effectiveRange: nil) + let font = try #require(attrs[.font] as? PlatformFont) + + #if canImport(AppKit) + let traits = NSFontManager.shared.traits(of: font) + #expect(traits.contains(.italicFontMask)) + #elseif canImport(UIKit) + let traits = font.fontDescriptor.symbolicTraits + #expect(traits.contains(.traitItalic)) + #endif + } + + @Test + func `Convert inline code to RTF with monospace font`() async throws { + let markdown = "Use `print()` to output." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + + let codeRange = (attrString.string as NSString).range(of: "print()") + let attrs = attrString.attributes(at: codeRange.location, effectiveRange: nil) + let font = try #require(attrs[.font] as? PlatformFont) + + // Check it uses the monospace font + let fontName = font.fontName.lowercased() + #expect(fontName.contains("menlo") || fontName.contains("courier") || fontName.contains("mono")) + } + + @Test + func `Convert fenced code block to RTF`() async throws { + let markdown = """ + Example: + + ```swift + let x = 42 + ``` + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("let x = 42")) + } + + @Test + func `Convert link to RTF with link attribute`() async throws { + let markdown = "Visit [Example](https://example.com) for more." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("Example")) + #expect(!attrString.string.contains("https://example.com")) + + let linkRange = (attrString.string as NSString).range(of: "Example") + let attrs = attrString.attributes(at: linkRange.location, effectiveRange: nil) + #expect(attrs[.link] != nil) + } + + @Test + func `Convert link without link attribute when preserveLinks is false`() async throws { + let markdown = "Visit [Example](https://example.com) for more." + let doc = try MarkdownDocument(content: markdown) + let options = RTFOptions(preserveLinks: false) + let rtfData = try await doc.toRTF(options: options) + + let attrString = try loadRTF(rtfData) + let linkRange = (attrString.string as NSString).range(of: "Example") + let attrs = attrString.attributes(at: linkRange.location, effectiveRange: nil) + #expect(attrs[.link] == nil) + } + + @Test + func `Convert strikethrough to RTF`() async throws { + let markdown = "This is ~~deleted~~ text." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("deleted")) + + let deleteRange = (attrString.string as NSString).range(of: "deleted") + let attrs = attrString.attributes(at: deleteRange.location, effectiveRange: nil) + let strikethrough = attrs[.strikethroughStyle] as? Int + #expect(strikethrough != nil && strikethrough != 0) + } + + @Test + func `Convert unordered list to RTF`() async throws { + let markdown = """ + - First item + - Second item + - Third item + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + // Bullet character should be present + #expect(attrString.string.contains("\u{2022}")) + #expect(attrString.string.contains("First item")) + #expect(attrString.string.contains("Second item")) + #expect(attrString.string.contains("Third item")) + } + + @Test + func `Convert ordered list to RTF`() async throws { + let markdown = """ + 1. First + 2. Second + 3. Third + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("1.")) + #expect(attrString.string.contains("First")) + #expect(attrString.string.contains("2.")) + #expect(attrString.string.contains("Second")) + } + + @Test + func `Convert thematic break to RTF`() async throws { + let markdown = """ + Above the break. + + --- + + Below the break. + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("\u{2500}")) + } + + @Test + func `Convert blockquote to RTF with indentation`() async throws { + let markdown = """ + > This is a quote. + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.contains("This is a quote.")) + + // Check indentation + let attrs = attrString.attributes(at: 0, effectiveRange: nil) + if let paragraphStyle = attrs[.paragraphStyle] as? NSParagraphStyle { + #expect(paragraphStyle.firstLineHeadIndent > 0 || paragraphStyle.headIndent > 0) + } + } + + @Test + func `Convert empty document to RTF`() async throws { + let markdown = "" + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + + let attrString = try loadRTF(rtfData) + #expect(attrString.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + @Test + func `Custom font options are applied`() async throws { + let markdown = "Custom font text." + let doc = try MarkdownDocument(content: markdown) + let options = RTFOptions(baseFontName: "Georgia", baseFontSize: 18) + let rtfData = try await doc.toRTF(options: options) + + let attrString = try loadRTF(rtfData) + let attrs = attrString.attributes(at: 0, effectiveRange: nil) + let font = try #require(attrs[.font] as? PlatformFont) + #expect(font.pointSize == 18) + } +} diff --git a/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFGeneratorTests.swift b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFGeneratorTests.swift new file mode 100644 index 0000000..da56dd7 --- /dev/null +++ b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFGeneratorTests.swift @@ -0,0 +1,198 @@ +// +// RTFGeneratorTests.swift +// MarkdownUtilities +// + +import Foundation +import MarkdownUtilities +import Testing + +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif + +@Suite("RTFGenerator Tests") +struct RTFGeneratorTests { + + // MARK: - Helpers + + /// Creates RTF data from an NSAttributedString. + private func makeRTF(from attrString: NSAttributedString) throws -> Data { + let range = NSRange(location: 0, length: attrString.length) + guard let data = attrString.rtf(from: range, documentAttributes: [ + .documentType: NSAttributedString.DocumentType.rtf, + ]) else { + throw RTFConversionError.failedToGenerateRTF + } + return data + } + + private func baseFont(size: CGFloat = 14) -> PlatformFont { + PlatformFont(name: "Helvetica", size: size) ?? PlatformFont.systemFont(ofSize: size) + } + + private func boldFont(size: CGFloat = 14) -> PlatformFont { + #if canImport(AppKit) + let font = baseFont(size: size) + return NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + #elseif canImport(UIKit) + return PlatformFont.boldSystemFont(ofSize: size) + #endif + } + + private func italicFont(size: CGFloat = 14) -> PlatformFont { + #if canImport(AppKit) + let font = baseFont(size: size) + return NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + #elseif canImport(UIKit) + return PlatformFont.italicSystemFont(ofSize: size) + #endif + } + + private func monoFont(size: CGFloat = 14) -> PlatformFont { + PlatformFont(name: "Menlo", size: size) ?? PlatformFont.systemFont(ofSize: size) + } + + // MARK: - Tests + + @Test + func `Generate markdown from plain text RTF`() async throws { + let attrString = NSAttributedString(string: "Hello, world!", attributes: [ + .font: baseFont(), + ]) + let data = try makeRTF(from: attrString) + + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("Hello, world!")) + } + + @Test + func `Generate markdown with bold detection`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "This is ", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "bold", attributes: [.font: boldFont()])) + result.append(NSAttributedString(string: " text.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("**bold**")) + } + + @Test + func `Generate markdown with italic detection`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "This is ", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "italic", attributes: [.font: italicFont()])) + result.append(NSAttributedString(string: " text.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("*italic*")) + } + + @Test + func `Generate markdown with inline code detection`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "Use ", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "print()", attributes: [.font: monoFont()])) + result.append(NSAttributedString(string: " to output.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("`print()`")) + } + + @Test + func `Generate markdown with link detection`() async throws { + let url = URL(string: "https://example.com") + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "Visit ", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "Example", attributes: [ + .font: baseFont(), + .link: url as Any, + ])) + result.append(NSAttributedString(string: " for info.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("[Example](https://example.com)")) + } + + @Test + func `Generate markdown with strikethrough detection`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "This is ", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "deleted", attributes: [ + .font: baseFont(), + .strikethroughStyle: NSUnderlineStyle.single.rawValue, + ])) + result.append(NSAttributedString(string: " text.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("~~deleted~~")) + } + + @Test + func `Generate markdown with heading detection`() async throws { + let result = NSMutableAttributedString() + // Large bold text simulates a heading + result.append(NSAttributedString(string: "Main Title", attributes: [ + .font: boldFont(size: 28), + ])) + result.append(NSAttributedString(string: "\n", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "Body text.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("# Main Title")) + #expect(markdown.contains("Body text.")) + } + + @Test + func `Generate markdown with code block detection`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "Example:\n", attributes: [.font: baseFont()])) + result.append(NSAttributedString(string: "let x = 42\n", attributes: [.font: monoFont()])) + result.append(NSAttributedString(string: "let y = 43\n", attributes: [.font: monoFont()])) + result.append(NSAttributedString(string: "End.", attributes: [.font: baseFont()])) + + let data = try makeRTF(from: result) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.contains("```")) + #expect(markdown.contains("let x = 42")) + #expect(markdown.contains("let y = 43")) + } + + @Test + func `Generate markdown without heading detection when disabled`() async throws { + let result = NSMutableAttributedString() + result.append(NSAttributedString(string: "Large Text", attributes: [ + .font: boldFont(size: 28), + ])) + + let data = try makeRTF(from: result) + let options = RTFGeneratorOptions(detectHeadings: false) + let markdown = try await MarkdownDocument.fromRTF(data: data, options: options) + #expect(!markdown.contains("#")) + #expect(markdown.contains("Large Text")) + } + + @Test + func `Generate markdown from empty RTF`() async throws { + let attrString = NSAttributedString(string: "", attributes: [.font: baseFont()]) + let data = try makeRTF(from: attrString) + let markdown = try await MarkdownDocument.fromRTF(data: data) + #expect(markdown.isEmpty) + } + + @Test + func `Invalid RTF data throws error`() async throws { + let badData = "not rtf data".data(using: .utf8) ?? Data() + await #expect(throws: RTFConversionError.self) { + try await MarkdownDocument.fromRTF(data: badData) + } + } +} diff --git a/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFRoundTripTests.swift b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFRoundTripTests.swift new file mode 100644 index 0000000..412c5af --- /dev/null +++ b/Tests/MarkdownUtilitiesTests/FormatConversion/RichText/RTFRoundTripTests.swift @@ -0,0 +1,72 @@ +// +// RTFRoundTripTests.swift +// MarkdownUtilities +// + +import Foundation +import MarkdownUtilities +import Testing + +@Suite("RTF Round-Trip Tests") +struct RTFRoundTripTests { + + @Test + func `Round-trip preserves headings`() async throws { + let markdown = "# Title\n\nBody text." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + let result = try await MarkdownDocument.fromRTF(data: rtfData) + + #expect(result.contains("# Title") || result.contains("## Title")) + #expect(result.contains("Body text.")) + } + + @Test + func `Round-trip preserves bold and italic`() async throws { + let markdown = "This has **bold** and *italic* text." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + let result = try await MarkdownDocument.fromRTF(data: rtfData) + + #expect(result.contains("**bold**")) + #expect(result.contains("*italic*")) + } + + @Test + func `Round-trip preserves inline code`() async throws { + let markdown = "Use `print()` for output." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + let result = try await MarkdownDocument.fromRTF(data: rtfData) + + #expect(result.contains("`print()`")) + } + + @Test + func `Round-trip preserves links`() async throws { + let markdown = "Visit [Example](https://example.com) now." + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + let result = try await MarkdownDocument.fromRTF(data: rtfData) + + #expect(result.contains("[Example](https://example.com)")) + } + + @Test + func `Round-trip preserves multiple paragraphs`() async throws { + let markdown = """ + First paragraph. + + Second paragraph. + + Third paragraph. + """ + let doc = try MarkdownDocument(content: markdown) + let rtfData = try await doc.toRTF() + let result = try await MarkdownDocument.fromRTF(data: rtfData) + + #expect(result.contains("First paragraph.")) + #expect(result.contains("Second paragraph.")) + #expect(result.contains("Third paragraph.")) + } +}