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:
2026-10-20 00:36:40 +08:00
commit 2d031e58a4
47 changed files with 9149 additions and 0 deletions
+282
View File
@@ -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 "![](\(match[1]))" }
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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
}
static func stripTags(_ html: String) -> String {
replace(#"(?is)<[^>]+>"#, in: html) { _ in "" }
}
static func decodeEntities(_ text: String) -> String {
var result = text
let map = [
"&nbsp;": " ", "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&#39;": "'", "&apos;": "'", "&mdash;": "—", "&ndash;": "–", "&hellip;": "…",
"&rsquo;": "’", "&lsquo;": "‘", "&ldquo;": "“", "&rdquo;": "”"
]
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) }
}