Files
Outpost/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Styling/MarkdownStyler.swift
T
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

499 lines
22 KiB
Swift

//
// MarkdownStyler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Applies the Markdown look (bold, links, code, headings, etc.). Most styling
// is now produced by the AST-native styler (`MarkdownASTStyler`); this type
// builds the `StylingContext` and runs the NSImage rendering passes that still
// consume tokens:
// - MarkdownStyler+Latex.swift (block + inline LaTeX rendering)
// - MarkdownStyler+Images.swift (image embeds / image links)
// - MarkdownStyler+Tables.swift (rendered tables)
import AppKit
import Foundation
// MARK: - Styling Context
extension MarkdownStyler {
typealias IndexedToken = (index: Int, token: MarkdownToken)
/// Per-kind token arrays (each with the token's index into the full array,
/// for activeTokenIndices), built ONCE in the parse classification and
/// reused across keystrokes. Lets the NSImage passes iterate a small,
/// scope-sliced array instead of walking every document token per pass.
struct ClassifiedStyleTokens {
let inlineLatex: [IndexedToken]
let blockLatex: [IndexedToken]
let imageEmbed: [IndexedToken]
let imageLink: [IndexedToken]
let table: [IndexedToken]
let code: [MarkdownToken] // codeBlock + inlineCode, for isInsideCodeBlock checks
}
struct StylingContext {
let nsText: NSString
let tokens: [MarkdownToken]
let codeTokens: [MarkdownToken]
let activeTokenIndices: Set<Int>
let baseFont: NSFont
let layoutBridge: LayoutBridge?
let baseDefaultLineHeight: CGFloat
let codeBackgroundColor: NSColor
let latexMarkerFont: NSFont
let configuration: MarkdownEditorConfiguration
let wikiLinkIDProvider: (NSRange) -> String?
/// Union bounds of the restyle's paragraph scope; nil = whole document
/// (initial load). Attribute application clips per paragraph anyway,
/// so the NSImage passes can skip tokens wholly outside these bounds
/// instead of walking every token in the document per keystroke.
var scopeBounds: (lo: Int, hi: Int)? = nil
/// Pre-classified per-kind token arrays; nil for direct callers (tests),
/// which fall back to classifying `tokens` on demand.
var classified: ClassifiedStyleTokens? = nil
var services: MarkdownEditorServices { configuration.services }
// Per-kind indexed arrays: the cached classification, or a one-off
// classification of `tokens` when a direct caller passed none.
var inlineLatexIndexed: [IndexedToken] { classified?.inlineLatex ?? Self.indexed(tokens, .inlineLatex) }
var blockLatexIndexed: [IndexedToken] { classified?.blockLatex ?? Self.indexed(tokens, .blockLatex) }
var imageEmbedIndexed: [IndexedToken] { classified?.imageEmbed ?? Self.indexed(tokens, .imageEmbed) }
var imageLinkIndexed: [IndexedToken] { classified?.imageLink ?? Self.indexed(tokens, .imageLink) }
var tableIndexed: [IndexedToken] { classified?.table ?? Self.indexed(tokens, .table) }
static func indexed(_ tokens: [MarkdownToken], _ kind: MarkdownTokenKind) -> [IndexedToken] {
tokens.enumerated().compactMap { $0.element.kind == kind ? ($0.offset, $0.element) : nil }
}
/// The slice of a location-sorted, non-overlapping per-kind array that
/// intersects the restyle scope — binary-searched so out-of-scope
/// tokens are never even visited. Whole array when scope is nil.
func scoped(_ arr: [IndexedToken]) -> ArraySlice<IndexedToken> {
guard let bounds = scopeBounds else { return arr[...] }
return MarkdownStyler.scopedSlice(arr, lo: bounds.lo, hi: bounds.hi)
}
/// True when `range` lies entirely outside the restyle scope — its
/// attributes would be clipped away at application time.
func outsideScope(_ range: NSRange) -> Bool {
guard let scopeBounds else { return false }
return NSMaxRange(range) <= scopeBounds.lo || range.location >= scopeBounds.hi
}
/// True when iteration (over location-sorted tokens) is past the scope.
func pastScope(_ range: NSRange) -> Bool {
guard let scopeBounds else { return false }
return range.location >= scopeBounds.hi
}
}
/// Binary-searched slice of a location-sorted, non-overlapping per-kind
/// array whose tokens intersect `[lo, hi)` — tokens outside are never
/// visited. Shared by the styler's scope culling and the coordinator's
/// edit-scoped candidate collection (which used to walk each array
/// linearly from the document head to the edit).
static func scopedSlice(_ arr: [IndexedToken], lo bound: Int, hi upper: Int) -> ArraySlice<IndexedToken> {
var lo = 0, hi = arr.count
while lo < hi { // first NSMaxRange > bound
let m = (lo + hi) / 2
if NSMaxRange(arr[m].token.range) > bound { hi = m } else { lo = m + 1 }
}
let start = lo
hi = arr.count
while lo < hi { // first location >= upper
let m = (lo + hi) / 2
if arr[m].token.range.location >= upper { hi = m } else { lo = m + 1 }
}
return arr[start..<lo]
}
}
typealias StyledRange = (range: NSRange, attributes: [NSAttributedString.Key: Any])
// MARK: - Public API
enum MarkdownStyler {
/// Collapses styler output into non-overlapping runs, ascending, so a caller can
/// write each character range to the text storage exactly once.
///
/// The styler emits ranges pass by pass, so they arrive unordered and heavily
/// overlapping. Applying them with one `addAttribute` per key per range mutates
/// the storage once per pair — 89,916 times on a 346k-char note — and every
/// mutation re-splits the attribute-run array, so the cost of a single call grows
/// with the runs already present: measured 0.54µs/call on a 437-char note against
/// 213µs/call on the big one. That quadratic was 19.1s of a 21s open. Writing
/// left to right instead only ever splits the trailing run.
///
/// Semantics are identical to the loop it replaces: later ranges win per key, and
/// `base` fills what no range covers (the caller applies it to the whole document
/// first, so each returned run must carry it too — `setAttributes` replaces).
static func flattenedRuns(
_ ranges: [StyledRange],
base: [NSAttributedString.Key: Any],
documentLength: Int
) -> [StyledRange] {
// (position, isStart, emission index). Ends sort before starts at the same
// position so a range ending where the next begins doesn't briefly overlap it.
var events: [(pos: Int, isStart: Bool, idx: Int)] = []
events.reserveCapacity(ranges.count * 2)
for (i, styled) in ranges.enumerated() {
let r = styled.range
guard r.location != NSNotFound, r.location >= 0, r.length > 0,
NSMaxRange(r) <= documentLength else { continue }
events.append((r.location, true, i))
events.append((NSMaxRange(r), false, i))
}
guard !events.isEmpty else { return [] }
events.sort { a, b in
if a.pos != b.pos { return a.pos < b.pos }
if a.isStart != b.isStart { return !a.isStart }
return a.idx < b.idx
}
var runs: [StyledRange] = []
runs.reserveCapacity(min(events.count, 4096))
// Emission indices of the ranges covering the current position, kept ascending
// so merging them in order reproduces "later range wins".
var active: [Int] = []
var cursor = events[0].pos
var i = 0
while i < events.count {
let pos = events[i].pos
if pos > cursor, !active.isEmpty {
var attrs = base
for idx in active {
attrs.merge(ranges[idx].attributes) { _, newer in newer }
}
let run = NSRange(location: cursor, length: pos - cursor)
// Adjacent runs frequently carry identical attributes — one styling
// pass emits a separate range per character — so coalesce before the
// write instead of paying a storage mutation per character.
if let last = runs.last, NSMaxRange(last.range) == run.location,
(last.attributes as NSDictionary).isEqual(to: attrs) {
runs[runs.count - 1].range.length += run.length
} else {
runs.append((run, attrs))
}
}
while i < events.count, events[i].pos == pos {
let event = events[i]
if event.isStart {
let slot = active.firstIndex { $0 > event.idx } ?? active.count
active.insert(event.idx, at: slot)
} else if let slot = active.firstIndex(of: event.idx) {
active.remove(at: slot)
}
i += 1
}
cursor = pos
}
return runs
}
static func styleAttributes(
text: String,
fontName: String,
fontSize: CGFloat,
layoutBridge: LayoutBridge? = nil,
caretLocation: Int,
selection: NSRange? = nil,
activeTokenIndices: Set<Int>,
wikiLinkIDProvider: @escaping (NSRange) -> String? = { _ in nil },
precomputedTokens: [MarkdownToken]? = nil,
classified: ClassifiedStyleTokens? = nil,
precomputedBlocks: [Block]? = nil,
scopedRanges: [NSRange]? = nil,
configuration: MarkdownEditorConfiguration = .default
) -> [StyledRange] {
let tokens = precomputedTokens ?? MarkdownTokenizer.parseTokensViaAST(in: text, registry: configuration.extensionRegistry)
let nsText = text as NSString
let scopeBounds: (lo: Int, hi: Int)? = scopedRanges.flatMap { ranges in
let valid = ranges.filter { $0.location != NSNotFound && $0.length > 0 }
guard let lo = valid.map(\.location).min(),
let hi = valid.map({ NSMaxRange($0) }).max() else { return nil }
return (lo, hi)
}
let codeTokens = classified?.code ?? tokens.filter { $0.kind == .codeBlock || $0.kind == .inlineCode }
let baseFont = NSFont(name: fontName, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize)
let baseDefaultLineHeight = ceil(
layoutBridge?.defaultLineHeight(for: baseFont)
?? (baseFont.ascender - baseFont.descender + baseFont.leading)
)
let codeBackgroundColor = configuration.services.syntaxHighlighter.backgroundColor()
let hiddenMarkerSize = configuration.markers.hiddenMarkerFontSize
let ctx = StylingContext(
nsText: nsText,
tokens: tokens,
codeTokens: codeTokens,
activeTokenIndices: activeTokenIndices,
baseFont: baseFont,
layoutBridge: layoutBridge,
baseDefaultLineHeight: baseDefaultLineHeight,
codeBackgroundColor: codeBackgroundColor,
latexMarkerFont: NSFont(name: fontName, size: hiddenMarkerSize)
?? NSFont.systemFont(ofSize: hiddenMarkerSize),
configuration: configuration,
wikiLinkIDProvider: wikiLinkIDProvider,
scopeBounds: scopeBounds,
classified: classified
)
var result: [StyledRange] = []
// AST-native styler handles everything but NSImage rendering (incl. the composition fixes).
let astT0 = DispatchTime.now().uptimeNanoseconds
result += MarkdownASTStyler.styleAttributes(
text: text, fontName: fontName, fontSize: fontSize,
caretLocation: caretLocation, selection: selection, wikiLinkIDProvider: wikiLinkIDProvider,
scopedRanges: scopedRanges, precomputedBlocks: precomputedBlocks,
configuration: configuration
)
let astMs = Double(DispatchTime.now().uptimeNanoseconds - astT0) / 1_000_000
// NSImage rendering reuses the existing, proven machinery.
let imgT0 = DispatchTime.now().uptimeNanoseconds
result += styleBlockLatex(ctx)
result += styleInlineLatex(ctx)
result += styleImageEmbeds(ctx)
result += styleImageLinks(ctx)
let imgMs = Double(DispatchTime.now().uptimeNanoseconds - imgT0) / 1_000_000
result += styleTables(ctx)
PerfTrace.note { " styleAttributes: ast=\(String(format: "%.2f", astMs))ms latex+img4=\(String(format: "%.2f", imgMs))ms styledRanges=\(result.count)" }
return result
}
}
// MARK: - Shared helpers used by multiple styling extensions
extension MarkdownStyler {
static func appendSecondaryMarkers(
for token: MarkdownToken,
to attrs: inout [StyledRange],
theme: MarkdownEditorTheme
) {
token.markerRanges.forEach {
attrs.append(($0, [.foregroundColor: theme.mutedText]))
}
}
enum RenderedStandaloneBlockMode {
case collapsedSource(markerTexts: [String])
case visibleSource(imageGap: CGFloat)
/// Wide-table mode: anchor reserves container width, line gains scroller strip, tagged by sourceID.
case collapsedSourceScrollable(
markerTexts: [String],
displayWidth: CGFloat,
sourceID: Int
)
}
static func appendRenderedStandaloneBlock(
for token: MarkdownToken,
rawContent: String,
image: NSImage,
imageBounds: CGRect,
paragraphSpacingBefore: CGFloat,
paragraphSpacing: CGFloat,
alignment: NSTextAlignment,
mode: RenderedStandaloneBlockMode,
restyleOnWidthChange: Bool = false,
ctx: StylingContext,
attrs: inout [StyledRange]
) -> Bool {
guard let paraRange = token.standaloneParagraphRange(in: ctx.nsText) else { return false }
let para = NSMutableParagraphStyle()
let baseLineHeight = layoutBridgeDefaultLineHeight(for: ctx.baseFont, using: ctx.layoutBridge)
para.paragraphSpacingBefore = max(para.paragraphSpacingBefore, paragraphSpacingBefore)
para.alignment = alignment
let widthChangeAnchorAttrs: [NSAttributedString.Key: Any] = restyleOnWidthChange
? [.scrollableBlockFullRange: NSValue(range: paraRange)]
: [:]
switch mode {
case .collapsedSource(let markerTexts):
emitCollapsedAttrs(
token: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacing: paragraphSpacing,
para: para,
paraRange: paraRange,
advanceWidth: imageBounds.width,
neededLineHeight: imageBounds.height,
extraAnchorAttrs: widthChangeAnchorAttrs,
markerTexts: markerTexts,
ctx: ctx,
attrs: &attrs
)
case .collapsedSourceScrollable(let markerTexts, let displayWidth, let sourceID):
let scrollerStrip = MarkdownTextLayoutFragment.scrollableBlockScrollerStrip
let totalHeight = imageBounds.height + scrollerStrip
var anchorAttrs = widthChangeAnchorAttrs
anchorAttrs[.scrollableBlockNaturalWidth] = imageBounds.width
anchorAttrs[.scrollableBlockSourceID] = sourceID
anchorAttrs[.scrollableBlockTotalHeight] = totalHeight
emitCollapsedAttrs(
token: token,
rawContent: rawContent,
image: image,
imageBounds: imageBounds,
paragraphSpacing: paragraphSpacing,
para: para,
paraRange: paraRange,
advanceWidth: displayWidth,
neededLineHeight: totalHeight,
extraAnchorAttrs: anchorAttrs,
markerTexts: markerTexts,
ctx: ctx,
attrs: &attrs
)
case .visibleSource(let imageGap):
para.minimumLineHeight = max(para.minimumLineHeight, baseLineHeight)
para.maximumLineHeight = max(para.maximumLineHeight, baseLineHeight)
para.paragraphSpacing = max(para.paragraphSpacing, imageBounds.height + imageGap + paragraphSpacing)
attrs.append((paraRange, [.paragraphStyle: para]))
attrs.append((token.range, [
.latexImage: image,
.latexBounds: NSValue(rect: imageBounds),
.latexIsBlock: true,
.latexBlockOffsetY: baseLineHeight + imageGap
]))
appendSecondaryMarkers(for: token, to: &attrs, theme: ctx.configuration.theme)
}
return true
}
/// Shared body for collapsed-source modes; hides raw source, plants image on anchor.
private static func emitCollapsedAttrs(
token: MarkdownToken,
rawContent: String,
image: NSImage,
imageBounds: CGRect,
paragraphSpacing: CGFloat,
para: NSMutableParagraphStyle,
paraRange: NSRange,
advanceWidth: CGFloat,
neededLineHeight: CGFloat,
extraAnchorAttrs: [NSAttributedString.Key: Any],
markerTexts: [String],
ctx: StylingContext,
attrs: inout [StyledRange]
) {
let baseLineHeight = layoutBridgeDefaultLineHeight(for: ctx.baseFont, using: ctx.layoutBridge)
let resolved = max(para.minimumLineHeight, neededLineHeight, baseLineHeight)
para.minimumLineHeight = resolved
para.maximumLineHeight = max(para.maximumLineHeight, resolved)
para.paragraphSpacing = max(para.paragraphSpacing, paragraphSpacing)
para.lineBreakMode = .byClipping
let collapsedPara = NSMutableParagraphStyle()
collapsedPara.maximumLineHeight = 1
collapsedPara.paragraphSpacing = 0
collapsedPara.paragraphSpacingBefore = 0
let leadingWhitespaceUnits = rawContent.utf16.prefix { codeUnit in
guard let scalar = UnicodeScalar(UInt32(codeUnit)) else { return false }
return CharacterSet.whitespacesAndNewlines.contains(scalar)
}.count
let contentEnd = NSMaxRange(token.contentRange)
let anchorLocation = min(token.contentRange.location + leadingWhitespaceUnits, contentEnd - 1)
var paragraphAttributes: [StyledRange] = []
ctx.nsText.enumerateSubstrings(in: paraRange, options: .byParagraphs) { _, _, enclosingRange, _ in
if NSLocationInRange(anchorLocation, enclosingRange) {
paragraphAttributes.append((enclosingRange, [.paragraphStyle: para]))
} else {
paragraphAttributes.append((enclosingRange, [.paragraphStyle: collapsedPara]))
}
}
attrs.append(contentsOf: paragraphAttributes)
if leadingWhitespaceUnits > 0 {
let leadingRange = NSRange(location: token.contentRange.location, length: leadingWhitespaceUnits)
let leadingText = ctx.nsText.substring(with: leadingRange)
attrs.append((leadingRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(leadingText, font: ctx.latexMarkerFont)
]))
}
let anchorRange = NSRange(location: anchorLocation, length: 1)
let anchorChar = ctx.nsText.substring(with: anchorRange)
var anchorAttrs: [NSAttributedString.Key: Any] = [
.latexImage: image,
.latexBounds: NSValue(rect: imageBounds),
.latexIsBlock: true,
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: advanceWidth - HeadingHelpers.textWidth(anchorChar, font: ctx.latexMarkerFont)
]
for (key, value) in extraAnchorAttrs { anchorAttrs[key] = value }
attrs.append((anchorRange, anchorAttrs))
let trailingStart = anchorLocation + 1
let trailingLength = contentEnd - trailingStart
if trailingLength > 0 {
let trailingRange = NSRange(location: trailingStart, length: trailingLength)
let trailingText = ctx.nsText.substring(with: trailingRange)
attrs.append((trailingRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(trailingText, font: ctx.latexMarkerFont)
]))
}
for (index, markerRange) in token.markerRanges.enumerated() {
let markerText = markerTexts.indices.contains(index)
? markerTexts[index]
: ctx.nsText.substring(with: markerRange)
attrs.append((markerRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(markerText, font: ctx.latexMarkerFont)
]))
}
let preTokenLength = token.range.location - paraRange.location
if preTokenLength > 0 {
let preTokenRange = NSRange(location: paraRange.location, length: preTokenLength)
let preTokenText = ctx.nsText.substring(with: preTokenRange)
attrs.append((preTokenRange, [
.foregroundColor: NSColor.clear,
.font: ctx.latexMarkerFont,
.kern: -HeadingHelpers.textWidth(preTokenText, font: ctx.latexMarkerFont)
]))
}
}
}
// MARK: - Whole-document & inline-only styling kept inline (small helpers)
extension MarkdownStyler {
/// Line range if `location` is on a thematic-break line (3+ `-`/`*`/`_`), else nil; drives HR restyle.
static func hrLineRange(at location: Int, in text: String) -> NSRange? {
let nsText = text as NSString
let safeLoc = max(0, min(location, nsText.length))
let lineRange = nsText.lineRange(for: NSRange(location: safeLoc, length: 0))
let line = nsText.substring(with: lineRange)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard line.range(
of: #"^[ \t]*(-{3,}|\*{3,}|_{3,})[ \t]*$"#,
options: .regularExpression
) != nil else {
return nil
}
return lineRange
}
}