Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
33add33
feat(copy): AST→HTML renderer for clean rich copy
luca-chen198 Jul 9, 2026
505fac5
feat(copy): copy selection as clean RTF + webarchive, keep raw markdo…
luca-chen198 Jul 9, 2026
1262592
feat(paste): HTML→Markdown converter for smart paste
luca-chen198 Jul 9, 2026
84f65c0
feat(paste): paste rich HTML as Markdown (lists, headings, formatting…
luca-chen198 Jul 9, 2026
dc1b4e9
fix(paste): converter correctness — numeric entities, ol start, hard …
luca-chen198 Jul 9, 2026
d1b1bec
fix(paste): round-trip own copies via private type, guard HTML paste …
luca-chen198 Jul 9, 2026
6a6c541
Update NativeTextView+PasteHandling.swift
luca-chen198 Jul 9, 2026
b945d7a
checkbox
luca-chen198 Jul 9, 2026
e524348
feat(lists): task checkboxes share the bullet slot; fix cursor flicker
luca-chen198 Jul 9, 2026
b4bf3a7
fix(paste): convert bare <li> fragments and multi-paragraph formatted…
luca-chen198 Jul 10, 2026
6df8059
test: condense clipboard test suites — same coverage, fewer cases
luca-chen198 Jul 10, 2026
efce9bf
Update NativeTextView+PasteHandling.swift
luca-chen198 Jul 10, 2026
f3f24b9
fix(copy): build the web archive from our own html; RTF gets rule/che…
luca-chen198 Jul 10, 2026
7424d30
fix(copy): task items drop the list bullet in rich flavors; settle th…
luca-chen198 Jul 10, 2026
63c51f5
fix(paste): indent nested lists with tabs — the editor's native inden…
luca-chen198 Jul 10, 2026
96eb039
chore: TEMP paste diagnostics (Debug-only) — trace clipboard flavors …
luca-chen198 Jul 10, 2026
4dc4b00
chore: TEMP table-render diagnostics (Debug-only)
luca-chen198 Jul 10, 2026
f2ce371
cleanup: prune tests to core pins, drop dead config; fix table restyl…
luca-chen198 Jul 10, 2026
99efabf
fix(copy): keep GFM checkbox markup in the .html flavor, strip it for…
luca-chen198 Jul 10, 2026
7de4e72
Reclaim literal [ ] task prefixes in pasted list items
luca-chen198 Jul 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ public struct MarkdownEditorConfiguration: Sendable {
public var imageEmbed: ImageEmbedStyle
public var blockLatex: BlockLatexStyle
public var inlineLatex: InlineLatexStyle
public var checkbox: CheckboxStyle
public var blockquote: BlockquoteStyle
public var link: LinkStyle
public var paragraph: ParagraphStyle
Expand Down Expand Up @@ -82,7 +81,6 @@ public struct MarkdownEditorConfiguration: Sendable {
imageEmbed: ImageEmbedStyle = .default,
blockLatex: BlockLatexStyle = .default,
inlineLatex: InlineLatexStyle = .default,
checkbox: CheckboxStyle = .default,
blockquote: BlockquoteStyle = .default,
link: LinkStyle = .default,
paragraph: ParagraphStyle = .default,
Expand All @@ -106,7 +104,6 @@ public struct MarkdownEditorConfiguration: Sendable {
self.imageEmbed = imageEmbed
self.blockLatex = blockLatex
self.inlineLatex = inlineLatex
self.checkbox = checkbox
self.blockquote = blockquote
self.link = link
self.paragraph = paragraph
Expand Down Expand Up @@ -398,39 +395,6 @@ public struct InlineLatexStyle: Sendable {
public static let `default` = InlineLatexStyle()
}

// MARK: - Task checkboxes

/// Glyph sizing and spacing for `- [ ]` / `- [x]` task checkboxes.
public struct CheckboxStyle: Sendable {
/// Minimum extra spacing (points) inserted after an unchecked checkbox to
/// optically center the rendered glyph.
public var minimumExtraSpacing: CGFloat
/// Additional spacing as a fraction of the surrounding font's point size.
public var extraSpacingPerFontPointFraction: CGFloat
/// Checkbox glyph size as a fraction of the line's font height.
public var sizeFromFontHeightFactor: CGFloat
/// Checkbox glyph size as a fraction of the `[ ]` marker width.
public var sizeFromMarkerWidthFactor: CGFloat
/// Inset applied inside the checkbox bounding box before drawing the icon.
public var iconInsetFraction: CGFloat

public init(
minimumExtraSpacing: CGFloat = 2.0,
extraSpacingPerFontPointFraction: CGFloat = 0.18,
sizeFromFontHeightFactor: CGFloat = 1.2,
sizeFromMarkerWidthFactor: CGFloat = 1.2,
iconInsetFraction: CGFloat = 0.01
) {
self.minimumExtraSpacing = minimumExtraSpacing
self.extraSpacingPerFontPointFraction = extraSpacingPerFontPointFraction
self.sizeFromFontHeightFactor = sizeFromFontHeightFactor
self.sizeFromMarkerWidthFactor = sizeFromMarkerWidthFactor
self.iconInsetFraction = iconInsetFraction
}

public static let `default` = CheckboxStyle()
}

// MARK: - Blockquote

/// Extra line height added to blockquote lines.
Expand Down
234 changes: 234 additions & 0 deletions Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//
// MarkdownHTMLRenderer.swift
// MarkdownEngine
//
// Created by Luca Chen on 09.07.26.
//
// A clean Markdown → HTML fragment renderer. The editor's NSTextStorage holds
// RAW markdown styled in place (syntax markers merely colored, thematic breaks
// drawn by a layout fragment, tables as image attachments), so the default copy
// serializes junk. This walks the semantic `DocumentAST` and emits a clean HTML
// fragment — a sequence of block elements, no <html>/<body> wrapper — that the
// copy override wraps and places on the pasteboard.
//

import Foundation

public enum MarkdownHTMLRenderer {

/// Render `markdown` to an HTML fragment (block elements joined by newlines).
public static func html(from markdown: String) -> String {
let ns = markdown as NSString
let blocks = DocumentAST.parse(markdown)
let pieces = blocks.compactMap { block(for: $0, ns: ns) }
return pieces.joined(separator: "\n")
}

// MARK: - Blocks

private static func block(for node: BlockNode, ns: NSString) -> String? {
switch node {
case .heading(let level, _, _, let inlines):
let l = min(max(level, 1), 6)
return "<h\(l)>\(renderInlines(inlines, ns: ns))</h\(l)>"

case .paragraph(_, let inlines):
return "<p>\(renderInlines(inlines, ns: ns))</p>"

case .blockquote(let range, _):
return renderBlockquote(range: range, ns: ns)

case .list(_, let items):
return renderList(items: items, ns: ns)

case .codeBlock(let range):
return renderCodeBlock(range: range, ns: ns)

case .blockLatex(let range):
return "<pre>\(escape(ns.substring(with: range).trimmingCharacters(in: .newlines)))</pre>"

case .table(let range):
return renderTable(range: range, ns: ns)

case .thematicBreak:
return "<hr>"

case .blank:
return nil
}
}

/// Blockquote inlines are parsed over the block range *including* the `> `
/// markers, so strip the markers per line and re-parse the content clean.
private static func renderBlockquote(range: NSRange, ns: NSString) -> String {
let raw = ns.substring(with: range)
let stripped = raw
.components(separatedBy: "\n")
.map(stripQuoteMarkers)
.filter { !$0.isEmpty }
.joined(separator: "\n")
let inlines = InlineParser.parse(stripped)
return "<blockquote>\(renderInlines(inlines, ns: stripped as NSString))</blockquote>"
}

/// Drop leading indent (≤3 spaces/tabs) then one-or-more `>` each with an
/// optional trailing space, matching the block styler's marker scan.
private static func stripQuoteMarkers(_ line: String) -> String {
var s = Substring(line)
var indent = 0
while let c = s.first, c == " " || c == "\t", indent < 3 { s = s.dropFirst(); indent += 1 }
while s.first == ">" {
s = s.dropFirst()
if s.first == " " || s.first == "\t" { s = s.dropFirst() }
}
return String(s)
}

/// Emit `<ul>`/`<ol>` groups, switching container when ordered-ness flips.
/// Nesting is flattened to a single level for v1 (see deviations).
private static func renderList(items: [ListItem], ns: NSString) -> String {
var out: [String] = []
var currentOrdered: Bool?
var buffer: [String] = []

func flush() {
guard let ordered = currentOrdered, !buffer.isEmpty else { return }
let tag = ordered ? "ol" : "ul"
out.append("<\(tag)>\n" + buffer.joined(separator: "\n") + "\n</\(tag)>")
buffer.removeAll()
}

for item in items {
if currentOrdered != item.ordered {
flush()
currentOrdered = item.ordered
}
buffer.append(listItem(item, ns: ns))
}
flush()
return out.joined(separator: "\n")
}

private static func listItem(_ item: ListItem, ns: NSString) -> String {
let content = renderInlines(item.inlines, ns: ns)
if item.checkbox != nil {
// GFM task markup so markdown consumers (Obsidian etc.) restore
// `- [ ]` on paste. Rich targets get this stripped to a plain
// bullet by the pasteboard writer (user's call).
let box = item.checked
? "<input type=\"checkbox\" checked disabled> "
: "<input type=\"checkbox\" disabled> "
return "<li>\(box)\(content)</li>"
}
return "<li>\(content)</li>"
}

/// Fenced code: drop the opening ```lang / closing ``` fence lines, escape body.
private static func renderCodeBlock(range: NSRange, ns: NSString) -> String {
let raw = ns.substring(with: range)
var lines = raw.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() } // drop trailing-newline artifact

let language = fenceLanguage(lines.first ?? "")
var body = Array(lines.dropFirst())
if let last = body.last, isFenceLine(last) { body.removeLast() }

let escaped = escape(body.joined(separator: "\n"))
if let language, !language.isEmpty {
return "<pre><code class=\"language-\(escape(language))\">\(escaped)</code></pre>"
}
return "<pre><code>\(escaped)</code></pre>"
}

/// The parser only produces column-0 backtick fences (BlockParser.isFence),
/// so match that contract when stripping the closing fence line.
private static func isFenceLine(_ line: String) -> Bool {
line.hasPrefix("```")
}

/// Language info-string from an opening fence line (chars after the backticks).
private static func fenceLanguage(_ line: String) -> String? {
let lang = line.drop { $0 == "`" }.trimmingCharacters(in: .whitespaces)
return lang.isEmpty ? nil : lang
}

private static func renderTable(range: NSRange, ns: NSString) -> String {
let raw = ns.substring(with: range)
guard let parsed = MarkdownStyler.parseTableSource(raw) else {
return "<pre>\(escape(raw.trimmingCharacters(in: .newlines)))</pre>"
}
let head = parsed.header.map { "<th>\(escape($0))</th>" }.joined()
let body = parsed.rows.map { row in
"<tr>" + row.map { "<td>\(escape($0))</td>" }.joined() + "</tr>"
}.joined()
return "<table><thead><tr>\(head)</tr></thead><tbody>\(body)</tbody></table>"
}

// MARK: - Inlines

private static func renderInlines(_ nodes: [InlineNode], ns: NSString) -> String {
var out = ""
for node in nodes { out += renderInline(node, ns: ns) }
return out
}

private static func renderInline(_ node: InlineNode, ns: NSString) -> String {
switch node {
case .text(let r):
return escape(ns.substring(with: r))

case .code(_, let content):
return "<code>\(escape(ns.substring(with: content)))</code>"

case .emphasis(let kind, _, _, let children):
let inner = renderInlines(children, ns: ns)
switch kind {
case .italic: return "<em>\(inner)</em>"
case .bold: return "<strong>\(inner)</strong>"
case .boldItalic: return "<strong><em>\(inner)</em></strong>"
}

case .link(_, _, let url, _, let children):
return "<a href=\"\(escape(ns.substring(with: url)))\">\(renderInlines(children, ns: ns))</a>"

case .image(_, let alt, let url, _):
return "<img src=\"\(escape(ns.substring(with: url)))\" alt=\"\(escape(ns.substring(with: alt)))\">"

case .wikiLink(_, let name, _, _):
return escape(ns.substring(with: name))

case .imageEmbed(_, let target, _):
let t = escape(ns.substring(with: target))
return "<img src=\"\(t)\" alt=\"\(t)\">"

case .strikethrough(_, _, let children):
return "<del>\(renderInlines(children, ns: ns))</del>"

case .highlight(_, _, let children):
return "<mark>\(renderInlines(children, ns: ns))</mark>"

case .inlineLatex(let range, _, _):
return escape(ns.substring(with: range))

case .escape(_, let character, _):
return escape(ns.substring(with: character))
}
}

// MARK: - Escaping

private static func escape(_ s: String) -> String {
var out = ""
out.reserveCapacity(s.count)
for ch in s {
switch ch {
case "&": out += "&amp;"
case "<": out += "&lt;"
case ">": out += "&gt;"
case "\"": out += "&quot;"
default: out.append(ch)
}
}
return out
}
}
30 changes: 23 additions & 7 deletions Sources/MarkdownEngine/Renderer/MarkdownTextLayoutFragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
/// and block images drawn below text via paragraphSpacing.
override var renderingSurfaceBounds: CGRect {
var bounds = super.renderingSurfaceBounds
if hasCodeBlockBackground || hasThematicBreak || hasBlockquote {
// Task checkboxes too: the box draws left of the first glyph (marker
// slot), outside the default text surface — TextKit would clip it.
if hasCodeBlockBackground || hasThematicBreak || hasBlockquote || hasTaskCheckbox {
let containerWidth = textLayoutManager?.textContainer?.size.width ?? bounds.width
// Extend left to container edge
bounds.origin.x = -layoutFragmentFrame.origin.x
Expand Down Expand Up @@ -185,6 +187,18 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
return found
}

private var hasTaskCheckbox: Bool {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return false }
var found = false
ts.enumerateAttribute(.taskCheckbox, in: range, options: []) { value, _, stop in
if value is Bool {
found = true
stop.pointee = true
}
}
return found
}

private func drawCodeBlockBackground(at point: CGPoint, in context: CGContext) {
guard let ts = textStorage, let range = fragmentNSRange, range.length > 0 else { return }

Expand Down Expand Up @@ -556,14 +570,16 @@ final class MarkdownTextLayoutFragment: NSTextLayoutFragment {
let isChecked = (value as? Bool) ?? false
guard let pos = drawPosition(forDocumentCharAt: attrRange.location, point: point) else { return }

let font = (ts.attribute(.font, at: attrRange.location, effectiveRange: nil) as? NSFont)
?? (textLayoutManager?.textContainer?.textView?.font ?? NSFont.systemFont(ofSize: NSFont.systemFontSize))
// Box collapsed to 0.1pt, so pos.x sits at the content edge; the
// square is right-aligned to it (shared with the click hit-test).
// Use baseFont, NOT NSTextView.font — its getter returns the first
// char's font (0.1pt in a heading-first doc → 1px boxes).
let font = (textLayoutManager?.textContainer?.textView as? NativeTextView)?.baseFont
?? NSFont.systemFont(ofSize: NSFont.systemFontSize)
let ascent = max(0, font.ascender)
let descent = max(0, -font.descender)
let fontHeight = max(1, ceil(ascent + descent))
let markerWidth = ("[ ]" as NSString).size(withAttributes: [.font: font]).width
let size = max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2)))
let boxX = pos.x + max(0, (markerWidth - size) / 2)
let size = TaskCheckboxGeometry.size(for: font)
let boxX = TaskCheckboxGeometry.boxX(contentX: pos.x, size: size)
let centerY = pos.baselineY + (descent - ascent) / 2
let boxY = centerY - size / 2

Expand Down
35 changes: 35 additions & 0 deletions Sources/MarkdownEngine/Renderer/TaskCheckboxGeometry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// TaskCheckboxGeometry.swift
// MarkdownEngine
//
// Created by Luca Chen on 09.07.26.
//
// Shared geometry for the drawn task-checkbox square. The hidden `[ ] ` chars
// are collapsed to ~zero advance by the styler, so `drawPosition`/
// `boundingRect` of the box range sit at the task CONTENT's left edge. The
// square is right-aligned to that edge with a small gap (Obsidian-style),
// occupying the `- ` marker slot. Fragment draw and click hit-test both use
// these functions so their rects can't drift apart.
//

import AppKit

enum TaskCheckboxGeometry {

/// Gap between the box's right edge and the task content's left edge.
static let gap: CGFloat = 2.0

/// Side length of the square for the given (body) font.
static func size(for font: NSFont) -> CGFloat {
let ascent = max(0, font.ascender)
let descent = max(0, -font.descender)
let fontHeight = max(1, ceil(ascent + descent))
let markerWidth = ("[ ]" as NSString).size(withAttributes: [.font: font]).width
return max(1.0, min(floor(fontHeight * 1.2), floor(markerWidth * 1.2)))
}

/// Left edge of the square: right-aligned to the content start x with `gap`.
static func boxX(contentX: CGFloat, size: CGFloat) -> CGFloat {
contentX - size - gap
}
}
Loading
Loading