Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,11 @@ flowchart TD
Supported --> Citations[Citation attachments]
```

## Footnotes and definitions fallback
## Footnotes and definition-list fallback

Here is a footnote reference.[^streaming]

[^streaming]: Footnote syntax is included to check how unsupported block extensions degrade.
[^streaming]: Footnote definitions render in a notes section at the end of the document.

Term
: Definition list syntax is included as another compatibility check.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL
- [x] Ordered lists
- [x] Unordered lists (with nesting)
- [x] Task lists (`- [ ]` / `- [x]`), display-only
- [x] Footnotes (`[^1]`), superscript references with an end-of-document notes section
- [x] Thematic breaks (`---`)
- [x] Tables with `:---`, `:---:`, `---:` column alignment
- [x] Inline LaTeX math via `\( … \)`
Expand All @@ -116,7 +117,6 @@ The renderer targets the subset of CommonMark + GitHub-flavored Markdown that LL

### Not yet supported

- [ ] Footnotes (`[^1]`)
- [ ] Highlight (`==text==`), superscript (`^x^`), subscript (`~x~`)
- [ ] Raw HTML (`<details>`, `<kbd>`, `<aside>`, …) — kept inline as text
- [ ] GitHub alerts (`> [!NOTE]`) — rendered as plain block quotes
Expand Down
27 changes: 27 additions & 0 deletions Sources/MarkdownText/Inline/Markdown+InlineConvertible.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,34 @@ extension Markdown.InlineCode: InlineConvertible {
return self.code.hasPrefix(LaTexPreProcessorImpl.inlineCodePrefix) && self.code.hasSuffix(LaTexPreProcessorImpl.inlineCodeSuffix)
}

/// The footnote number when this inline code's payload matches the marker
/// syntax and is a positive integer; `nil` for user-authored code that merely
/// resembles one but has a non-numeric payload, which keeps its normal
/// inline-code rendering. A numeric-payload match renders as a footnote
/// superscript regardless of whether the preprocessor emitted it or a user
/// authored it verbatim — the same trade-off the LaTeX marker syntax
/// already makes.
var footnoteReferenceNumber: Int? {
guard self.code.hasPrefix(FootnotePreProcessorImpl.inlineCodePrefix),
self.code.hasSuffix(FootnotePreProcessorImpl.inlineCodeSuffix)
else {
return nil
}
let payload = self.code
.dropFirst(FootnotePreProcessorImpl.inlineCodePrefix.count)
.dropLast(FootnotePreProcessorImpl.inlineCodeSuffix.count)
guard let number = Int(payload), number > 0 else { return nil }
return number
}

func convert(attributeContainer: NSAttributeContainer, config: MarkdownRenderConfig) -> NSMutableAttributedString {
if let footnoteNumber = self.footnoteReferenceNumber {
let baseFont = attributeContainer[NSAttributedString.Key.font] as? MDFont ?? config.paragraphStyle.textFonts.normal
var container = attributeContainer
container[.font] = baseFont.resized(to: baseFont.pointSize * 0.7)
container[.baselineOffset] = baseFont.pointSize * 0.35
return NSMutableAttributedString(string: String(footnoteNumber)).mergingAttributes(container)
}
var codeContent = self.code
if self.isInlineLatex {
codeContent = String(self
Expand Down
242 changes: 242 additions & 0 deletions Sources/MarkdownText/Parser/FootnotePreProcessor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.
//

import Foundation
import RegexBuilder

/// Pre-process GitHub-flavored footnotes (`[^id]` references and `[^id]: text` definitions).
/// swift-markdown attaches no footnote extension and has no footnote node types, so — like
/// `LaTexPreProcessor` — this is a less heavy-weight approach than forking commonmark-gfm
/// and swift-markdown.
///
/// References are numbered by order of first appearance and replaced with a specially
/// marked inline code span that the inline layer renders as a superscript. Definitions
/// are removed from the source and re-emitted as an ordered list after a thematic break
/// at the end of the document. Content inside fenced code blocks and inline code spans
/// is left untouched.
protocol FootnotePreProcessor {
func process(input: String) -> String
}

final class FootnotePreProcessorImpl: FootnotePreProcessor {

static let footnoteID = Reference(Substring.self)
static let footnoteText = Reference(Substring.self)

/// A whole line of the form `[^id]: text`, allowing up to three leading spaces
/// (per CommonMark, four or more make the line an indented code block).
static let definitionLine = Regex {
Repeat(0...3) { " " }
"[^"
Capture(as: footnoteID) {
OneOrMore(CharacterClass.anyOf("] \t").inverted)
}
"]:"
ZeroOrMore(.horizontalWhitespace)
Capture(as: footnoteText) {
ZeroOrMore(.any)
}
}

/// An inline reference of the form `[^id]`.
static let reference = Regex {
"[^"
Capture(as: footnoteID) {
OneOrMore(CharacterClass.anyOf("] \t").inverted)
}
"]"
}

/// Marker wrapped in an inline code span so the reference number survives parsing;
/// `Markdown.InlineCode` detects the prefix/suffix and renders a superscript.
static let inlineCodePrefix = "[[fnref:"
static let inlineCodeSuffix = "]]"

init() {}

func process(input: String) -> String {
guard input.contains("[^") else { return input }

// Normalize CRLF so per-line matching and fence detection see clean lines.
let normalizedInput = input.replacingOccurrences(of: "\r\n", with: "\n")
let (contentLines, definitions) = collectDefinitions(input: normalizedInput)
guard !definitions.isEmpty else { return input }

var numbers: [String: Int] = [:]
let processedLines = contentLines.map { line in
line.isInsideFence ? line.text : replacingReferences(in: line.text, definitions: definitions, numbers: &numbers)
}

var result = processedLines.joined(separator: "\n")
guard !numbers.isEmpty else { return result }

while result.hasSuffix("\n") {
result.removeLast()
}
let items = numbers
.sorted { $0.value < $1.value }
.map { "\($0.value). \(definitions[$0.key] ?? "")" }
return result + "\n\n---\n\n" + items.joined(separator: "\n")
}

/// An open fenced code block: its delimiter character and opening run length.
/// Per CommonMark, the closing fence must use the same character with a run
/// at least as long as the opener.
private struct Fence {
let character: Character
let length: Int
}

/// The fence run opening `trimmed`, if any (three or more backticks or tildes).
private func fenceRun(in trimmed: String) -> Fence? {
guard let first = trimmed.first, first == "`" || first == "~" else { return nil }
let length = trimmed.prefix(while: { $0 == first }).count
guard length >= 3 else { return nil }
return Fence(character: first, length: length)
}

/// `line` with its leading indentation and trailing whitespace stripped, or
/// `nil` when the line is indented four or more columns. Per CommonMark, a
/// leading tab always advances to the next four-column tab stop, so any tab
/// within the leading run reaches column 4 regardless of how many spaces
/// precede it (0-3 spaces then a tab all land on column 4) — such a line,
/// like one with four or more leading spaces, is an indented code block and
/// can never open or close a fence. Mirrors `definitionLine`'s indentation
/// allowance. Trimming all leading whitespace unconditionally (as opposed to
/// only a qualifying leading run) would let an indented code block
/// containing literal backticks be misread as a fence delimiter.
private func fenceCandidate(in line: String) -> String? {
let leading = line.prefix(while: { $0 == " " || $0 == "\t" })
guard !leading.contains("\t"), leading.count <= 3 else { return nil }
return line[leading.endIndex...].trimmingCharacters(in: .whitespaces)
}

/// Whether `trimmed` closes `fence`: same character, a run at least as long,
/// and nothing after the run.
private func isClosing(_ fence: Fence, trimmed: String) -> Bool {
guard let run = fenceRun(in: trimmed),
run.character == fence.character,
run.length >= fence.length
else {
return false
}
return trimmed.dropFirst(run.length).isEmpty
}

/// Walks the input line by line, tracking fenced code blocks, and splits it into
/// surviving content lines plus the collected `id -> text` definitions.
private func collectDefinitions(input: String) -> (lines: [(text: String, isInsideFence: Bool)], definitions: [String: String]) {
var lines: [(text: String, isInsideFence: Bool)] = []
var definitions: [String: String] = [:]
var currentFence: Fence?

for line in input.components(separatedBy: "\n") {
let candidate = fenceCandidate(in: line)
if let fence = currentFence {
lines.append((line, true))
if let candidate, isClosing(fence, trimmed: candidate) {
currentFence = nil
}
continue
}
if let candidate, let fence = fenceRun(in: candidate) {
currentFence = fence
lines.append((line, true))
continue
}
if let match = line.wholeMatch(of: Self.definitionLine) {
let id = String(match[Self.footnoteID])
if definitions[id] == nil {
definitions[id] = String(match[Self.footnoteText])
}
continue
}
lines.append((line, false))
}
return (lines, definitions)
}

/// Replaces defined references in a line, skipping inline code spans. Spans
/// follow CommonMark backtick-run rules: a span opens with a run of N backticks
/// and closes at the next run of exactly N, so single backticks inside a
/// double-backtick span stay part of the span.
private func replacingReferences(in line: String, definitions: [String: String], numbers: inout [String: Int]) -> String {
guard line.contains("[^") else { return line }

var output = ""
var segmentStart = line.startIndex
var index = line.startIndex

while index < line.endIndex {
guard line[index] == "`" else {
index = line.index(after: index)
continue
}
let runStart = index
var runEnd = index
while runEnd < line.endIndex, line[runEnd] == "`" {
runEnd = line.index(after: runEnd)
}
let runLength = line.distance(from: runStart, to: runEnd)
if let closerStart = findClosingRun(in: line, from: runEnd, length: runLength) {
output += replacingReferences(inSegment: line[segmentStart..<runStart], definitions: definitions, numbers: &numbers)
let spanEnd = line.index(closerStart, offsetBy: runLength)
output += line[runStart..<spanEnd]
segmentStart = spanEnd
index = spanEnd
} else {
// Unmatched run: literal backticks, keep scanning after them.
index = runEnd
}
}
output += replacingReferences(inSegment: line[segmentStart...], definitions: definitions, numbers: &numbers)
return output
}

/// The start of the first run of exactly `length` backticks at or after `start`.
private func findClosingRun(in line: String, from start: String.Index, length: Int) -> String.Index? {
var index = start
while index < line.endIndex {
guard line[index] == "`" else {
index = line.index(after: index)
continue
}
let runStart = index
var runEnd = index
while runEnd < line.endIndex, line[runEnd] == "`" {
runEnd = line.index(after: runEnd)
}
if line.distance(from: runStart, to: runEnd) == length {
return runStart
}
index = runEnd
}
return nil
}

private func replacingReferences(inSegment segment: Substring, definitions: [String: String], numbers: inout [String: Int]) -> String {
var output = ""
var index = segment.startIndex
for match in segment.matches(of: Self.reference) {
output += segment[index..<match.range.lowerBound]
let id = String(match[Self.footnoteID])
if definitions[id] != nil {
let number: Int
if let existing = numbers[id] {
number = existing
} else {
number = numbers.count + 1
numbers[id] = number
}
output += "`\(Self.inlineCodePrefix)\(number)\(Self.inlineCodeSuffix)`"
} else {
output += segment[match.range]
}
index = match.range.upperBound
}
output += segment[index...]
return output
}
}
10 changes: 8 additions & 2 deletions Sources/MarkdownText/Parser/MarkdownParserImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@ public final class MarkdownParserImpl: MarkdownParser {
private let imageBlockRewriter = ImageBlockMarkupPostParsingRewriter()

private let latexPreprocessor: LaTexPreProcessor
private let footnotePreprocessor: FootnotePreProcessor

/// Create a new parser instance using the default LaTeX preprocessor.
/// Create a new parser instance using the default LaTeX and footnote preprocessors.
public init() {
self.latexPreprocessor = LaTexPreProcessorImpl()
self.footnotePreprocessor = FootnotePreProcessorImpl()
}

/// Parse `text` into a `MarkdownParseResult`. See `MarkdownParser.parse(text:option:)`.
public func parse(text: String, option: MarkdownParseOption) async -> MarkdownParseResult {
let targetString = latexPreprocessor.process(input: text, matchingRules: option.latexMatchingRules)
// Footnotes run after LaTeX so their fence guard also protects the code
// blocks and inline code spans the LaTeX preprocessor emits.
let targetString = footnotePreprocessor.process(
input: latexPreprocessor.process(input: text, matchingRules: option.latexMatchingRules)
)

var result: MarkdownParseResult = MarkdownParseResult(
document: Document(parsing: targetString),
Expand Down
11 changes: 11 additions & 0 deletions Sources/MarkdownText/Utilities/MDFont.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ import AppKit
/// Cross-platform font type. Resolves to `UIFont` on UIKit platforms and `NSFont` on AppKit platforms.
public typealias MDFont = NSFont
#endif

extension MDFont {
/// Returns a copy of this font with the given point size.
func resized(to size: CGFloat) -> MDFont {
#if canImport(UIKit)
return withSize(size)
#elseif canImport(AppKit)
return NSFont(descriptor: fontDescriptor, size: size) ?? self
#endif
}
}
Loading