Files
konverter/Sources/Konverter/Convert/Markdown.swift
T
TZGyn 2d031e58a4 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.
2026-10-20 00:36:40 +08:00

283 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
}