Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// A deliberately small Markdown renderer/parser. Enough for the formats the
|
||||
/// app offers (headings, emphasis, code, lists, quotes, links, rules, images)
|
||||
/// without pulling in a dependency.
|
||||
enum Markdown {
|
||||
// MARK: - Markdown → HTML
|
||||
|
||||
static func html(from markdown: String, title: String = "Document") -> String {
|
||||
let lines = markdown.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
|
||||
var body: [String] = []
|
||||
var paragraph: [String] = []
|
||||
var listType: String?
|
||||
var inCode = false
|
||||
var codeLines: [String] = []
|
||||
|
||||
func flushParagraph() {
|
||||
guard !paragraph.isEmpty else { return }
|
||||
body.append("<p>" + inline(paragraph.joined(separator: " ")) + "</p>")
|
||||
paragraph = []
|
||||
}
|
||||
|
||||
func closeList() {
|
||||
if let type = listType {
|
||||
body.append("</\(type)>")
|
||||
listType = nil
|
||||
}
|
||||
}
|
||||
|
||||
for rawLine in lines {
|
||||
let line = rawLine
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.hasPrefix("```") {
|
||||
if inCode {
|
||||
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||
codeLines = []
|
||||
inCode = false
|
||||
} else {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
inCode = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inCode {
|
||||
codeLines.append(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.isEmpty {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
continue
|
||||
}
|
||||
|
||||
if let heading = headingLevel(trimmed) {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let text = trimmed.dropFirst(heading).trimmingCharacters(in: .whitespaces)
|
||||
body.append("<h\(heading)>" + inline(text) + "</h\(heading)>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
body.append("<hr/>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix(">") {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||
body.append("<blockquote><p>" + inline(text) + "</p></blockquote>")
|
||||
continue
|
||||
}
|
||||
|
||||
if let item = listItem(trimmed) {
|
||||
flushParagraph()
|
||||
if listType != item.type {
|
||||
closeList()
|
||||
listType = item.type
|
||||
body.append("<\(item.type)>")
|
||||
}
|
||||
body.append("<li>" + inline(item.text) + "</li>")
|
||||
continue
|
||||
}
|
||||
|
||||
if trimmed.hasPrefix("|") && trimmed.hasSuffix("|") {
|
||||
flushParagraph()
|
||||
closeList()
|
||||
let cells = trimmed.dropFirst().dropLast().split(separator: "|").map {
|
||||
inline($0.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
if !cells.allSatisfy({ $0.allSatisfy { $0 == "-" || $0 == ":" } }) {
|
||||
body.append("<tr>" + cells.map { "<td>\($0)</td>" }.joined() + "</tr>")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
paragraph.append(trimmed)
|
||||
}
|
||||
|
||||
if inCode {
|
||||
body.append("<pre><code>" + escape(codeLines.joined(separator: "\n")) + "</code></pre>")
|
||||
}
|
||||
flushParagraph()
|
||||
closeList()
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>\(escape(title))</title>
|
||||
<style>
|
||||
body { font: 16px/1.6 -apple-system, "Helvetica Neue", sans-serif; max-width: 720px; margin: 48px auto; padding: 0 24px; color: #1d1d1f; }
|
||||
h1,h2,h3,h4 { line-height: 1.25; margin-top: 1.6em; }
|
||||
code { background: #f2f2f4; padding: 2px 5px; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: #f2f2f4; padding: 14px; border-radius: 8px; overflow-x: auto; }
|
||||
blockquote { border-left: 3px solid #d0d0d5; margin-left: 0; padding-left: 16px; color: #555; }
|
||||
table { border-collapse: collapse; } td { border: 1px solid #ddd; padding: 6px 10px; }
|
||||
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
||||
</style></head><body>
|
||||
\(body.joined(separator: "\n"))
|
||||
</body></html>
|
||||
"""
|
||||
}
|
||||
|
||||
private static func headingLevel(_ line: String) -> Int? {
|
||||
let hashes = line.prefix { $0 == "#" }
|
||||
guard (1...6).contains(hashes.count) else { return nil }
|
||||
let rest = line.dropFirst(hashes.count)
|
||||
guard rest.isEmpty || rest.first == " " else { return nil }
|
||||
return hashes.count
|
||||
}
|
||||
|
||||
private static func listItem(_ line: String) -> (type: String, text: String)? {
|
||||
for marker in ["- ", "* ", "+ "] where line.hasPrefix(marker) {
|
||||
return ("ul", String(line.dropFirst(2)))
|
||||
}
|
||||
let digits = line.prefix { $0.isNumber }
|
||||
if !digits.isEmpty, line.dropFirst(digits.count).hasPrefix(". ") {
|
||||
return ("ol", String(line.dropFirst(digits.count + 1)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Inline spans: bold, italic, strikethrough, code, links, images.
|
||||
static func inline(_ raw: String) -> String {
|
||||
var text = escape(raw)
|
||||
text = replace(#"!\[([^\]]*)\]\(([^)]+)\)"#, in: text) { match in
|
||||
"<img src=\"\(match[2])\" alt=\"\(match[1])\"/>"
|
||||
}
|
||||
text = replace(#"\[([^\]]+)\]\(([^)]+)\)"#, in: text) { match in
|
||||
"<a href=\"\(match[2])\">\(match[1])</a>"
|
||||
}
|
||||
text = replace(#"`([^`]+)`"#, in: text) { "<code>\($0[1])</code>" }
|
||||
text = replace(#"\*\*([^*]+)\*\*"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||
text = replace(#"__([^_]+)__"#, in: text) { "<strong>\($0[1])</strong>" }
|
||||
text = replace(#"(?<!\*)\*([^*]+)\*(?!\*)"#, in: text) { "<em>\($0[1])</em>" }
|
||||
text = replace(#"(?<!_)_([^_]+)_(?!_)"#, in: text) { "<em>\($0[1])</em>" }
|
||||
text = replace(#"~~([^~]+)~~"#, in: text) { "<del>\($0[1])</del>" }
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - HTML → Markdown
|
||||
|
||||
static func markdown(fromHTML html: String) -> String {
|
||||
var text = html
|
||||
// Drop anything that cannot be represented as text.
|
||||
text = replace(#"(?is)<(head|script|style|noscript)[^>]*>.*?</\1>"#, in: text) { _ in "" }
|
||||
text = replace(#"(?is)<!--.*?-->"#, in: text) { _ in "" }
|
||||
|
||||
// Block level first.
|
||||
for level in (1...6).reversed() {
|
||||
text = replace(#"(?is)<h\#(level)[^>]*>(.*?)</h\#(level)>"#, in: text) { match in
|
||||
"\n\n" + String(repeating: "#", count: level) + " " + stripTags(match[1]).trimmed + "\n\n"
|
||||
}
|
||||
}
|
||||
text = replace(#"(?is)<pre[^>]*>(.*?)</pre>"#, in: text) { match in
|
||||
"\n\n```\n" + stripTags(match[1]).trimmingCharacters(in: .newlines) + "\n```\n\n"
|
||||
}
|
||||
text = replace(#"(?is)<blockquote[^>]*>(.*?)</blockquote>"#, in: text) { match in
|
||||
"\n\n" + stripTags(match[1]).trimmed
|
||||
.split(separator: "\n").map { "> " + $0 }.joined(separator: "\n") + "\n\n"
|
||||
}
|
||||
text = replace(#"(?is)<li[^>]*>(.*?)</li>"#, in: text) { match in
|
||||
"\n- " + stripTags(match[1]).trimmed
|
||||
}
|
||||
text = replace(#"(?is)<(ul|ol)[^>]*>"#, in: text) { _ in "\n" }
|
||||
text = replace(#"(?is)</(ul|ol)>"#, in: text) { _ in "\n" }
|
||||
text = replace(#"(?is)<hr[^>]*/?>"#, in: text) { _ in "\n\n---\n\n" }
|
||||
text = replace(#"(?is)<t[dh][^>]*>(.*?)</t[dh]>"#, in: text) { match in
|
||||
" " + stripTags(match[1]).trimmed + " |"
|
||||
}
|
||||
text = replace(#"(?is)<tr[^>]*>"#, in: text) { _ in "\n|" }
|
||||
text = replace(#"(?is)<br[^>]*/?>"#, in: text) { _ in " \n" }
|
||||
text = replace(#"(?is)</p>"#, in: text) { _ in "\n\n" }
|
||||
text = replace(#"(?is)<p[^>]*>"#, in: text) { _ in "\n\n" }
|
||||
text = replace(#"(?is)</div>"#, in: text) { _ in "\n" }
|
||||
|
||||
// Inline.
|
||||
text = replace(#"(?is)<(strong|b)[^>]*>(.*?)</\1>"#, in: text) { "**\(stripTags($0[2]).trimmed)**" }
|
||||
text = replace(#"(?is)<(em|i)[^>]*>(.*?)</\1>"#, in: text) { "*\(stripTags($0[2]).trimmed)*" }
|
||||
text = replace(#"(?is)<del[^>]*>(.*?)</del>"#, in: text) { "~~\(stripTags($0[1]).trimmed)~~" }
|
||||
text = replace(#"(?is)<code[^>]*>(.*?)</code>"#, in: text) { "`\(stripTags($0[1]))`" }
|
||||
text = replace(#"(?is)<img[^>]*alt=["']([^"']*)["'][^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) {
|
||||
"![\($0[1])](\($0[2]))"
|
||||
}
|
||||
text = replace(#"(?is)<img[^>]*src=["']([^"']+)["'][^>]*/?>"#, in: text) { match in ")" }
|
||||
text = replace(#"(?is)<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>"#, in: text) {
|
||||
"[\(stripTags($0[2]).trimmed)](\($0[1]))"
|
||||
}
|
||||
|
||||
text = stripTags(text)
|
||||
text = decodeEntities(text)
|
||||
// Collapse the whitespace the block replacements left behind.
|
||||
text = replace(#"[ \t]+\n"#, in: text) { _ in "\n" }
|
||||
text = replace(#"\n{3,}"#, in: text) { _ in "\n\n" }
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n"
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func escape(_ string: String) -> String {
|
||||
string
|
||||
.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
}
|
||||
|
||||
static func stripTags(_ html: String) -> String {
|
||||
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
|
||||
}
|
||||
|
||||
static func decodeEntities(_ text: String) -> String {
|
||||
var result = text
|
||||
let map = [
|
||||
" ": " ", "&": "&", "<": "<", ">": ">", """: "\"",
|
||||
"'": "'", "'": "'", "—": "—", "–": "–", "…": "…",
|
||||
"’": "’", "‘": "‘", "“": "“", "”": "”"
|
||||
]
|
||||
for (entity, value) in map {
|
||||
result = result.replacingOccurrences(of: entity, with: value)
|
||||
}
|
||||
// Numeric entities.
|
||||
result = replace(#"&#(\d+);"#, in: result) { match in
|
||||
guard let code = UInt32(match[1]), let scalar = Unicode.Scalar(code) else { return match[0] }
|
||||
return String(Character(scalar))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Regex replacement where the closure receives the full match plus groups.
|
||||
static func replace(_ pattern: String, in string: String, _ transform: ([String]) -> String) -> String {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return string }
|
||||
let range = NSRange(string.startIndex..., in: string)
|
||||
let matches = regex.matches(in: string, range: range)
|
||||
guard !matches.isEmpty else { return string }
|
||||
|
||||
var output = string
|
||||
for match in matches.reversed() {
|
||||
guard let matchRange = Range(match.range, in: output) else { continue }
|
||||
var groups: [String] = []
|
||||
for index in 0..<match.numberOfRanges {
|
||||
if let groupRange = Range(match.range(at: index), in: output) {
|
||||
groups.append(String(output[groupRange]))
|
||||
} else {
|
||||
groups.append("")
|
||||
}
|
||||
}
|
||||
output.replaceSubrange(matchRange, with: transform(groups))
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
}
|
||||
Reference in New Issue
Block a user