diff --git a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift index 1bdcaf94..991a28fb 100644 --- a/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift +++ b/Sources/MarkdownEngine/Configuration/MarkdownEditorConfiguration.swift @@ -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 @@ -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, @@ -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 @@ -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. diff --git a/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift b/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift new file mode 100644 index 00000000..2dc1230b --- /dev/null +++ b/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift @@ -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 /
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 "\(renderInlines(inlines, ns: ns))
" + + 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 "\(escape(ns.substring(with: range).trimmingCharacters(in: .newlines)))" + + case .table(let range): + return renderTable(range: range, ns: ns) + + case .thematicBreak: + return "
\(renderInlines(inlines, ns: stripped as NSString))" + } + + /// 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 `
\(escaped)"
+ }
+ return "\(escaped)"
+ }
+
+ /// 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 "\(escape(raw.trimmingCharacters(in: .newlines)))" + } + let head = parsed.header.map { "
\(escape(ns.substring(with: content)))"
+
+ case .emphasis(let kind, _, _, let children):
+ let inner = renderInlines(children, ns: ns)
+ switch kind {
+ case .italic: return "\(inner)"
+ case .bold: return "\(inner)"
+ case .boldItalic: return "\(inner)"
+ }
+
+ case .link(_, _, let url, _, let children):
+ return "\(renderInlines(children, ns: ns))"
+
+ case .image(_, let alt, let url, _):
+ return "