From 6292495ce77b88361dced566adef8d17c945c65e Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski ` in its place, at
+ /// its aspect ratio. The height is rounded to whole points, so it is
+ /// pixel-exact at 1× and 2×. The views FRAME the picture with this and
+ /// the measurer sizes the row with it: once a picture's size is known,
+ /// its row is exact at every width and zoom.
+ static func pictureSize(imageSize: CGSize, htmlWidth: ImageWidth?, fontScale: CGFloat,
+ contentWidth: CGFloat) -> CGSize {
+ let cap = displayWidth(fontScale: fontScale, contentWidth: contentWidth)
+ let width: CGFloat
+ if let htmlWidth {
+ width = htmlWidth.displayWidth(cap: cap, fontScale: fontScale)
+ } else if imageSize.width > 0 {
+ width = min(cap, imageSize.width * fontScale)
+ } else {
+ width = cap
+ }
+ guard imageSize.width > 0, imageSize.height > 0 else {
+ return CGSize(width: width, height: placeholderHeight)
+ }
+ return CGSize(width: width, height: (width * imageSize.height / imageSize.width).rounded())
+ }
+
+ /// The alt-text caption under an image (`ImageCaption`): 12 pt × zoom,
+ /// system italic.
+ static let captionFontSize: CGFloat = 12
+
+ static func captionFont(fontScale: CGFloat) -> NSFont {
+ let size = captionFontSize * fontScale
+ let base = NSFont.systemFont(ofSize: size)
+ return NSFont(descriptor: base.fontDescriptor.withSymbolicTraits(.italic), size: size) ?? base
+ }
+
+ /// The caption's height at `contentWidth`: its lines, broken by TextKit
+ /// as SwiftUI breaks them, × `singleLineHeight` — an upper bound of
+ /// SwiftUI's own line height, which no font metric reproduces.
+ /// `ImageCaption` is at least this tall, so the row is exactly this
+ /// whenever the two agree on the lines, and grows (and reports) if
+ /// they ever don't, rather than clipping. 0 without a caption.
+ static func captionHeight(alt: String, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat {
+ guard !alt.isEmpty else { return 0 }
+ let font = captionFont(fontScale: fontScale)
+ // A fresh stack per call: TextKit is not thread-safe and this runs
+ // off-main in the measurer.
+ let storage = NSTextStorage(string: alt, attributes: [.font: font])
+ let layoutManager = NSLayoutManager()
+ storage.addLayoutManager(layoutManager)
+ let container = NSTextContainer(size: NSSize(width: BlockLayout.clampedWidth(contentWidth),
+ height: .greatestFiniteMagnitude))
+ container.lineFragmentPadding = 0
+ layoutManager.addTextContainer(container)
+ layoutManager.ensureLayout(for: container)
+ var lines = 0
+ layoutManager.enumerateLineFragments(forGlyphRange: layoutManager.glyphRange(for: container)) { _, _, _, _, _ in
+ lines += 1
+ }
+ return CGFloat(max(1, lines)) * BlockLayout.singleLineHeight(for: font)
+ }
+
+ /// An image row: the view's body is a two-view tuple (picture, then the
+ /// caption), so the row's vertical padding applies to EACH of them, and
+ /// the row's stack puts `captionSpacing` between them (measured in the
+ /// GUI: a 1024 px icon at 819 pt reports 8 + 600 + 8 + 8 + 8 + 15 + 8 =
+ /// 655). `picture` is `pictureSize(...).height`, or the 100 pt loading
+ /// view while the size is unknown.
+ static func rowHeight(picture: CGFloat, alt: String, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat {
+ let outer = BlockLayout.Document.imageOuterVerticalPadding
+ guard !alt.isEmpty else { return picture + 2 * outer }
+ return picture + 2 * outer + captionSpacing
+ + captionHeight(alt: alt, fontScale: fontScale, contentWidth: contentWidth) + 2 * outer
+ }
}
// MARK: Display math
@@ -441,6 +516,34 @@ enum BlockLayout {
/// Height a diagram starts at when nothing has measured it yet.
static let defaultHeight: CGFloat = 200
static let cornerRadius: CGFloat = 6
+ /// The least height the web view reports (`MermaidWebView`).
+ static let minimumHeight: CGFloat = 50
+
+ /// What a rendered diagram's height follows from, as the inline web view
+ /// posts it (`mermaidGeometry` in mermaid-template.html): the SVG's
+ /// natural size (its viewBox) and what the page adds below it.
+ struct Geometry: Equatable {
+ var naturalWidth: CGFloat
+ var naturalHeight: CGFloat
+ var overhead: CGFloat
+ }
+
+ /// The diagram's height in a web view `contentWidth` wide at
+ /// `fontScale` — the template's `resizeDiagram`: the natural size ×
+ /// zoom, never wider than the view (`window.innerWidth`, whole CSS
+ /// px), plus the page's overhead, and at least `minimumHeight`.
+ static func diagramHeight(_ geometry: Geometry, fontScale: CGFloat, contentWidth: CGFloat) -> CGFloat {
+ let viewport = floor(contentWidth)
+ guard geometry.naturalWidth > 0, geometry.naturalHeight > 0, viewport > 0 else { return defaultHeight }
+ let fit = viewport / geometry.naturalWidth
+ let factor = min(fontScale * min(1, fit), fit)
+ return max(minimumHeight, geometry.naturalHeight * factor + geometry.overhead)
+ }
+
+ /// A diagram row: the diagram, the view's padding and the row's.
+ static func rowHeight(diagram: CGFloat) -> CGFloat {
+ diagram + 2 * verticalPadding + 2 * BlockLayout.Document.mermaidOuterVerticalPadding
+ }
}
// MARK: - Derived text widths
@@ -721,6 +824,30 @@ class DocumentCellTextView: NSTextView {
}
}
+// MARK: - Image caption
+
+/// The italic alt-text line under an image (`ImageBlockView`). Here rather
+/// than in the view's file so the unit tests host the very view the measurer
+/// sizes (`BlockLayout.ImageBlock.captionHeight`): at least that tall, so the
+/// row is exact whenever SwiftUI breaks the caption into the lines TextKit
+/// counted.
+struct ImageCaption: View {
+ let alt: String
+ let color: Color
+ let fontScale: CGFloat
+ let contentWidth: CGFloat
+
+ var body: some View {
+ Text(alt)
+ .font(.system(size: BlockLayout.ImageBlock.captionFontSize * fontScale))
+ .foregroundColor(color)
+ .italic()
+ .frame(minHeight: BlockLayout.ImageBlock.captionHeight(alt: alt, fontScale: fontScale,
+ contentWidth: contentWidth),
+ alignment: .topLeading)
+ }
+}
+
// MARK: - Block Height Cache
/// Remembers the last measured height of each block, keyed by block id.
@@ -737,84 +864,71 @@ class DocumentCellTextView: NSTextView {
/// compile error (caught by CI on the GitHub runner toolchain).
final class BlockHeightCache {
private var heights: [String: CGFloat] = [:]
- /// Row heights reported by placed pictures and diagrams, by
- /// `ReportedHeights.key` — content keys, so a re-parse keeps them.
- private var reported: [String: CGFloat] = [:]
+ /// The size of every image this document has shown (`NSImage.size`, the
+ /// original pixel size), by its link as written. An `NSString` key, like
+ /// `ImageLoader`'s caches: a multi-megabyte `data:` link hashes in
+ /// constant time. Per document, because a relative link names another file
+ /// in another folder. A re-parse keeps them: they are keyed by the link.
+ private var imageSizes: [NSString: CGSize] = [:]
func height(for id: String) -> CGFloat? { heights[id] }
func set(_ height: CGFloat, for id: String) { heights[id] = height }
- /// Forgets the block-id heights (a re-parse: ids are positional). The
- /// reported heights stay — they are keyed by content.
+ /// Forgets the block-id heights (a re-parse: ids are positional).
func removeAll() { heights.removeAll() }
- /// A diagram's height as its web view (or snapshot) gave it, per unit of
- /// zoom (`ReportedHeights.diagramHeight`).
- func setDiagramHeight(_ height: CGFloat, source: String, isDark: Bool, fontScale: CGFloat) {
- guard fontScale > 0, height > 0 else { return }
- setReported(height / fontScale, forKey: ReportedHeights.diagramUnitKey(source: source, isDark: isDark))
- }
-
- func setReported(_ height: CGFloat, forKey key: String) {
- // A live resize reports at every width it passes through: keep the
- // cache small rather than complete.
- if reported.count >= Self.reportedLimit, reported[key] == nil { reported.removeAll() }
- reported[key] = height
+ func setImageSize(_ size: CGSize, for url: String) {
+ guard size.width > 0, size.height > 0 else { return }
+ imageSizes[url as NSString] = size
}
- static let reportedLimit = 512
+ func imageSize(for url: String) -> CGSize? { imageSizes[url as NSString] }
/// A value copy for `BlockHeightMeasurer.measure(heightSeeds:)` — the
/// measurer runs off-main and must not read this reference type from there.
var snapshot: [String: CGFloat] { heights }
- /// The same for `measure(reportedHeights:)`.
- var reportedSnapshot: [String: CGFloat] { reported }
+ /// The same for `measure(imageSizes:)`.
+ var imageSizesSnapshot: [NSString: CGSize] { imageSizes }
}
-// MARK: - Reported heights
-
-/// The heights the placed views of pictures and diagrams reported, by what
-/// they show: a block id is a position, which a re-parse (zoom, an edit
-/// elsewhere) reassigns, and a width change re-measures every row — the
-/// image went back to its 116 pt placeholder and the diagram to the 200 pt
-/// default each time, swinging the table by hundreds of points and moving
-/// the reader with it. Keyed by the block's content and the width, zoom and
-/// theme it was laid out at, a reported height is valid wherever the key
-/// matches: Reading Mode on and off, a zoom and back.
-enum ReportedHeights {
- /// The key of `block`'s row at `contentWidth`, `fontScale` and `theme`;
- /// nil for the kinds the measurer computes exactly (or estimates from
- /// text, a heading).
- static func key(for block: MarkdownBlock, contentWidth: CGFloat, fontScale: CGFloat,
- theme: MarkdownTheme) -> String? {
- let place = "\(Int(contentWidth.rounded()))|\(fontScale)|\(theme.name)|\(theme.isDark)"
- switch block.content {
- case .image(let url, let alt, let width):
- return "image|\(place)|\(url)|\(alt)|\(String(describing: width))"
- case .svgImage(let source):
- return "svg|\(place)|\(source)"
- case .mermaidDiagram(let source):
- return "mermaid|\(place)|\(source)"
- default:
- return nil
- }
+// MARK: - Mermaid geometry
+
+/// The geometry (`BlockLayout.Mermaid.Geometry`) of every diagram a web view
+/// has rendered, by theme and source. With it a diagram that rendered once
+/// never starts at the 200 pt default again: the measurer and the view's
+/// first frame compute its height at any width and zoom. Process-wide, like
+/// `MermaidSnapshotStore`, because a diagram re-created from a snapshot never
+/// runs its web view — in this document or another. Locked: the measurer
+/// reads it off-main.
+enum MermaidGeometryStore {
+ private static let lock = NSLock()
+ private static var geometries: [NSString: BlockLayout.Mermaid.Geometry] = [:]
+ /// Entries are tiny; the cap only keeps a session-long store bounded.
+ static let limit = 1024
+
+ private static func key(source: String, isDark: Bool) -> NSString { "\(isDark)|\(source)" as NSString }
+
+ static func set(_ geometry: BlockLayout.Mermaid.Geometry, source: String, isDark: Bool) {
+ guard geometry.naturalWidth > 0, geometry.naturalHeight > 0 else { return }
+ let key = key(source: source, isDark: isDark)
+ lock.lock()
+ defer { lock.unlock() }
+ if geometries.count >= limit, geometries[key] == nil { geometries.removeAll() }
+ geometries[key] = geometry
}
- /// A Mermaid diagram's height per unit of zoom, by what it shows. One that
- /// fits its column is drawn at `fontScale` times its natural size
- /// (mermaid-template.html), so a zoom it was never placed at starts from
- /// the last one it was placed at, scaled — not from the 200 pt default
- /// (which swung the table by ~120 pt on every zoom step). One wider than
- /// its column is drawn smaller than that and reports it.
- static func diagramUnitKey(source: String, isDark: Bool) -> String {
- "mermaid-unit|\(isDark)|\(source)"
+ static func geometry(source: String, isDark: Bool) -> BlockLayout.Mermaid.Geometry? {
+ let key = key(source: source, isDark: isDark)
+ lock.lock()
+ defer { lock.unlock() }
+ return geometries[key]
}
- /// The diagram height (without the block's padding) `source` is expected
- /// to have at `fontScale`; nil if it was never placed. Shared by the
- /// measurer and `MermaidBlockView`'s first frame, so the two agree.
+ /// The diagram's height at `fontScale` and `contentWidth`, if it ever
+ /// rendered in this theme.
static func diagramHeight(source: String, isDark: Bool, fontScale: CGFloat,
- in reported: [String: CGFloat]) -> CGFloat? {
- // 50: the web view's minimum (`MermaidWebView`).
- reported[diagramUnitKey(source: source, isDark: isDark)].map { max(50, $0 * fontScale) }
+ contentWidth: CGFloat) -> CGFloat? {
+ geometry(source: source, isDark: isDark).map {
+ BlockLayout.Mermaid.diagramHeight($0, fontScale: fontScale, contentWidth: contentWidth)
+ }
}
}
@@ -999,10 +1113,11 @@ enum BlockHeightMeasurer {
/// the list column width minus `2 * Document.contentHorizontalPadding`.
/// - heightSeeds: last-known heights (`BlockHeightCache`) used to seed
/// Mermaid rows so a re-created diagram doesn't start at the default.
- /// - reportedHeights: row heights the placed views of pictures and
- /// diagrams reported, by `ReportedHeights.key` — what the block shows,
- /// at which width, zoom and theme. Such a row starts at the height it
- /// had there instead of the placeholder.
+ /// - imageSizes: the sizes of the images this document has shown
+ /// (`BlockHeightCache.imageSizesSnapshot`): such an image's row is
+ /// computed (`BlockLayout.ImageBlock`) instead of starting at the
+ /// loading view. SVG sizes come from `SVGImageDecoder`, diagram
+ /// geometry from `MermaidGeometryStore`.
/// - math: the math engine, or `nil` when the call is NOT on the main
/// thread — see the thread rule in the file header. With `nil`, rows
/// that need the engine come back at a placeholder height, listed in
@@ -1022,7 +1137,7 @@ enum BlockHeightMeasurer {
fontScale: CGFloat,
contentWidth: CGFloat,
heightSeeds: [String: CGFloat] = [:],
- reportedHeights: [String: CGFloat] = [:],
+ imageSizes: [NSString: CGSize] = [:],
math: MathRendering?) -> MeasuredBlocks {
#if DEBUG
let signpostID = heightSignpost.makeSignpostID()
@@ -1159,39 +1274,42 @@ enum BlockHeightMeasurer {
}
kinds.append(.reported)
- case .image:
- // Only the image itself: when `alt` is non-empty the view's body
- // is a two-view tuple (image + italic caption `Text`), so the
- // placed view reports a taller row than this. It is `.reported`
- // precisely because the real size — decoded bitmap, caption or
- // no caption — only exists once the view is placed. Unless it
- // was placed before at this width and zoom (`reportedHeights`).
- heights.append(reportedHeights[ReportedHeights.key(for: block, contentWidth: contentWidth,
- fontScale: fontScale, theme: theme) ?? ""]
- ?? BlockLayout.ImageBlock.placeholderHeight
- + 2 * BlockLayout.Document.imageOuterVerticalPadding)
+ case .image(let url, let alt, let htmlWidth):
+ // An image this document has shown has a known size, and the
+ // row is what the view draws for it (`ImageBlock.rowHeight`) at
+ // any width and zoom. Until then: the loading view, 100 pt
+ // plus the caption — also exact, until the image arrives. Still
+ // `.reported`: a link whose file changed, or failed, reports.
+ let picture = imageSizes[url as NSString].map {
+ BlockLayout.ImageBlock.pictureSize(imageSize: $0, htmlWidth: htmlWidth, fontScale: fontScale,
+ contentWidth: contentWidth).height
+ } ?? BlockLayout.ImageBlock.placeholderHeight
+ heights.append(BlockLayout.ImageBlock.rowHeight(picture: picture, alt: alt, fontScale: fontScale,
+ contentWidth: contentWidth))
kinds.append(.reported)
- case .svgImage:
- // Same shape as `.image`: the decoded size only exists once the
- // view is placed (decode is off-main), so the row is reported.
- heights.append(reportedHeights[ReportedHeights.key(for: block, contentWidth: contentWidth,
- fontScale: fontScale, theme: theme) ?? ""]
- ?? BlockLayout.ImageBlock.placeholderHeight
- + 2 * BlockLayout.Document.imageOuterVerticalPadding)
+ case .svgImage(let source):
+ // The markup is in the block, so its size is known here: decoded
+ // once (off-main safe) and cached by `SVGImageDecoder`, where the
+ // view finds it too. Markup that does not decode shows a notice
+ // and reports.
+ let picture = SVGImageDecoder.size(of: source).map {
+ BlockLayout.ImageBlock.pictureSize(imageSize: $0, htmlWidth: nil, fontScale: fontScale,
+ contentWidth: contentWidth).height
+ } ?? BlockLayout.ImageBlock.placeholderHeight
+ heights.append(picture + 2 * BlockLayout.Document.imageOuterVerticalPadding)
kinds.append(.reported)
case .mermaidDiagram(let source):
- // This block's last height at this zoom (a width-only re-measure),
- // else the diagram's height at another zoom, scaled.
- let diagram = heightSeeds[block.id]
- ?? ReportedHeights.diagramHeight(source: source, isDark: theme.isDark,
- fontScale: fontScale, in: reportedHeights)
+ // A diagram that rendered once (in either document, from either
+ // its web view or the snapshot that came of it) has its height
+ // computed for this width and zoom; else this block's last
+ // height (a width-only re-measure), else the default.
+ let diagram = MermaidGeometryStore.diagramHeight(source: source, isDark: theme.isDark,
+ fontScale: fontScale, contentWidth: contentWidth)
+ ?? heightSeeds[block.id]
?? BlockLayout.Mermaid.defaultHeight
- heights.append(reportedHeights[ReportedHeights.key(for: block, contentWidth: contentWidth,
- fontScale: fontScale, theme: theme) ?? ""]
- ?? diagram + 2 * BlockLayout.Mermaid.verticalPadding
- + 2 * BlockLayout.Document.mermaidOuterVerticalPadding)
+ heights.append(BlockLayout.Mermaid.rowHeight(diagram: diagram))
kinds.append(.reported)
}
rhythm.append(textEdges.map { rhythmRow(block, edges: $0) }
diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift
index 732a384..d4e3f18 100644
--- a/QuickMD/QuickMD/MarkdownView.swift
+++ b/QuickMD/QuickMD/MarkdownView.swift
@@ -651,8 +651,8 @@ struct MarkdownView: View {
// first layout — then the heights task below picks it up as soon as
// the coordinator reports one.
let width = contentWidth
- // Content-keyed (`ReportedHeights`): valid across this re-parse.
- let reported = heightCache.reportedSnapshot
+ // Keyed by the link, so valid across this re-parse.
+ let imageSizes = heightCache.imageSizesSnapshot
let out: ParsedAndMeasured = await Task.detached(priority: .userInitiated) {
let blocks = MarkdownBlockParser(theme: currentTheme, fontScale: scale).parse(text)
// Pre-compute per-text-block metadata on the background thread so the
@@ -676,12 +676,13 @@ struct MarkdownView: View {
// the rows it defers are finished on main just below.
// No `heightSeeds`: `heightCache` is cleared for this parse, and
// block ids are positional, so the previous document's diagram
- // heights would seed the wrong diagrams. `reportedHeights` are
- // keyed by content, width and zoom instead.
+ // heights would seed the wrong diagrams. Pictures and diagrams
+ // that were shown before are computed (`imageSizes`, the SVG
+ // decoder, `MermaidGeometryStore`).
let measured = width > 0
? BlockHeightMeasurer.measure(blocks: blocks, theme: currentTheme,
fontScale: scale, contentWidth: width,
- reportedHeights: reported, math: nil)
+ imageSizes: imageSizes, math: nil)
: MeasuredBlocks.empty
return ParsedAndMeasured(parsed: ParsedDocument(blocks: blocks, textMeta: meta),
measured: measured)
@@ -753,9 +754,8 @@ struct MarkdownView: View {
// Mermaid rows: seed from the heights the WebViews actually reported,
// so a re-measure doesn't send every diagram back to the 200 pt default.
let seeds = heightCache.snapshot
- // Pictures and diagrams placed at this width before (Reading Mode
- // off again, a window back to its size): their reported heights.
- let reported = heightCache.reportedSnapshot
+ // Images shown before: their rows are computed at the new width.
+ let imageSizes = heightCache.imageSizesSnapshot
// Inline-math paragraphs: hand the previous pass's strings back so the
// main-actor step re-wraps them instead of re-rendering every `$…$`
// segment through SwiftMath. Safe only because this task never runs
@@ -766,7 +766,7 @@ struct MarkdownView: View {
let raw: MeasuredBlocks = await Task.detached(priority: .userInitiated) {
BlockHeightMeasurer.measure(blocks: blocks, theme: currentTheme, fontScale: scale,
contentWidth: width, heightSeeds: seeds,
- reportedHeights: reported, math: nil)
+ imageSizes: imageSizes, math: nil)
}.value
// A parse that landed while we were measuring owns the state now —
// its own transaction published a matching table.
@@ -1157,7 +1157,7 @@ struct MarkdownView: View {
case .image(let url, let alt, let width):
ImageBlockView(url: url, alt: alt, width: width, theme: theme, documentURL: documentURL,
- fontScale: scale, contentWidth: contentWidth,
+ fontScale: scale, contentWidth: contentWidth, heightCache: heightCache,
onEnlarge: presentGraphicPreview)
.padding(.vertical, Metrics.imageOuterVerticalPadding)
@@ -1408,11 +1408,6 @@ struct MarkdownView: View {
// ignores such reports too — this is the same rule on the parent's side,
// so a stale report can never overwrite an exact height).
guard row < table.kinds.count, table.kinds[row] == .reported else { return }
- // Remembered by content, width and zoom for the next measure there.
- if let key = ReportedHeights.key(for: cachedBlocks[row], contentWidth: table.contentWidth,
- fontScale: CGFloat(renderedFontScale), theme: theme) {
- heightCache.setReported(height, forKey: key)
- }
guard row < table.heights.count, abs(table.heights[row] - height) >= 0.5 else { return }
// The block's height only: its space above stays as measured.
measured = measured.replacingHeight(height, at: row)
diff --git a/QuickMD/QuickMD/Resources/mermaid-template.html b/QuickMD/QuickMD/Resources/mermaid-template.html
index bc18e24..7094888 100644
--- a/QuickMD/QuickMD/Resources/mermaid-template.html
+++ b/QuickMD/QuickMD/Resources/mermaid-template.html
@@ -36,6 +36,21 @@
resizeDiagram = null;
container.innerHTML = '';
+ // What the height follows from at ANY width and zoom: the natural size and
+ // what the page adds around the SVG. The host computes the same formula as
+ // resizeDiagram (BlockLayout.Mermaid.diagramHeight), so a diagram that has
+ // rendered once never starts at a default height again. Same guard as
+ // reportHeight: hosts without the handler ignore it.
+ function reportGeometry(naturalWidth, naturalHeight, svgHeight) {
+ try {
+ window.webkit.messageHandlers.mermaidGeometry.postMessage({
+ naturalWidth: naturalWidth,
+ naturalHeight: naturalHeight,
+ overhead: container.getBoundingClientRect().height - svgHeight
+ });
+ } catch (e) { /* no handler in this host -- fine */ }
+ }
+
// Report height back to Swift. The zoom viewer hosts this same template
// WITHOUT a mermaidHeight handler -- the report must never break rendering
// (an unguarded postMessage throws, and the catch() below would replace a
@@ -69,6 +84,7 @@
svg.style.maxWidth = 'none';
svg.style.width = (naturalWidth * factor) + 'px';
svg.style.height = (naturalHeight * factor) + 'px';
+ if (!fitWindow) reportGeometry(naturalWidth, naturalHeight, naturalHeight * factor);
}
reportHeight();
};
diff --git a/QuickMD/QuickMD/SVGImageDecoder.swift b/QuickMD/QuickMD/SVGImageDecoder.swift
index 76471c3..a79082a 100644
--- a/QuickMD/QuickMD/SVGImageDecoder.swift
+++ b/QuickMD/QuickMD/SVGImageDecoder.swift
@@ -27,6 +27,56 @@ enum SVGImageDecoder {
return image
}
+ // MARK: - Fenced-block cache
+
+ /// Decoded ```svg blocks, by their markup (an `NSString` key: hashing does
+ /// not read the whole markup). NSCache is thread-safe and may evict;
+ /// the sizes below never are, so a row keeps its height either way.
+ private static let images = NSCache
` takes the place of the image's own width (T-C).
- private func displayWidth(for image: NSImage) -> CGFloat {
- let cap = Metrics.displayWidth(fontScale: fontScale, contentWidth: contentWidth)
- if let width { return width.displayWidth(cap: cap, fontScale: fontScale) }
- guard image.size.width > 0 else { return cap }
- return min(cap, image.size.width * fontScale)
+ /// `
` takes the place of the image's own width (T-C). The one
+ /// rule the measurer sizes the row with (`ImageBlock.pictureSize`).
+ private func pictureSize(for imageSize: CGSize) -> CGSize {
+ Metrics.pictureSize(imageSize: imageSize, htmlWidth: width, fontScale: fontScale,
+ contentWidth: contentWidth)
}
/// The window-filling preview re-decodes at 4096 px. Local files keep the
@@ -185,6 +195,7 @@ struct ImageBlockView: View {
// of a live view changes, and the old bitmap must not linger.
let seeded = ImageLoader.cachedImage(for: url, source: source, maxPixel: ImageLoader.displayMaxPixel)
if image !== seeded { image = seeded }
+ if let seeded { heightCache?.setImageSize(seeded.size, for: url) }
let first = await ImageLoader.load(raw: url, source: source, maxPixel: ImageLoader.displayMaxPixel)
if case .failure(.unreadable(let fileURL)) = first {
@@ -208,6 +219,7 @@ struct ImageBlockView: View {
private func apply(_ result: Result
`, `
`, ``, …), possibly an // `> quoted
`). + func testAQuoteMarkerAfterAUnicodeSpaceIsTextAndTheParserReturns() throws { + for space in ["\u{00A0}", "\u{2003}", "\u{3000}"] { + let name = String(format: "U+%04X", space.unicodeScalars.first!.value) + let blocks = try XCTUnwrap(parseReturning("Intro\n\n\(space)> quoted\n"), name) + XCTAssertEqual(blocks.map(plain), ["Intro", "\(space)> quoted"], name) + // Inside a quote run too: the run ends there, and the parser goes on. + let inside = try XCTUnwrap(parseReturning("> a\n\(space)> b\n\nAfter."), name) + if case .blockquote(let content, _) = inside.first?.content { + XCTAssertEqual(content, "a", name) + } else { + XCTFail("\(name): \(String(describing: inside.first?.content))") + } + XCTAssertEqual(inside.last.flatMap(plain), "After.", name) + } + // Spaces still indent a quote marker. + let indented = try XCTUnwrap(parseReturning("Intro\n\n > quoted\n")) + if case .blockquote(let content, _) = indented.last?.content { + XCTAssertEqual(content, "quoted") + } else { + XCTFail("\(String(describing: indented.last?.content))") + } + } + + /// Every other block marker the same way, as GitHub reads them + /// (`gh api markdown`, 2026-11-04): a no-break space before a fence, + /// `$$`, a setext underline, a table separator, a list marker, a rule or + /// a link definition — or after `#` — leaves the line paragraph text. + func testBlockMarkersAfterAUnicodeSpaceAreText() throws { + let nbsp = "\u{00A0}" + func kinds(_ markdown: String) throws -> [String] { + try XCTUnwrap(parseReturning(markdown)).map { block in + switch block.content { + case .text: return "text" + case .codeBlock: return "code" + case .mathBlock: return "math" + case .heading: return "heading" + case .table: return "table" + case .blockquote, .alert: return "quote" + default: return "other" + } + } + } + // A fence: ```` code
` then the bare ``` opens an empty one. + XCTAssertEqual(try kinds("\(nbsp)```\ncode\n```\n"), ["text", "code"]) + // Not a closing fence: one before it, one with a no-break space after. + let closers = try XCTUnwrap(parseReturning("```\ncode\n\(nbsp)```\nmid\n```\(nbsp)\nend\n```\n")) + XCTAssertEqual(closers.count, 1) + if case .codeBlock(let code, _) = closers.first?.content { + XCTAssertEqual(code, "code\n\(nbsp)```\nmid\n```\(nbsp)\nend") + } else { + XCTFail("\(String(describing: closers.first?.content))") + } + XCTAssertFalse(try kinds("\(nbsp)$$\nx^2\n$$\n").contains("math")) + XCTAssertEqual(try kinds("Title\n\(nbsp)===\n"), ["text"]) + XCTAssertEqual(try kinds("#\(nbsp)Title\n"), ["text"]) + XCTAssertEqual(try kinds("| a | b |\n\(nbsp)|---|---|\n| 1 | 2 |\n"), ["text"]) + // Text, not an item or a rule: the characters are drawn as written. + let blocks = try XCTUnwrap(parseReturning("\(nbsp)- item\n\npara\n\n\(nbsp)---\n\n1.\(nbsp)one\n")) + XCTAssertEqual(blocks.map(plain), ["\(nbsp)- item", "para", "\(nbsp)---", "1.\(nbsp)one"]) + XCTAssertFalse(MarkdownRenderer.isListItem("\(nbsp)- item")) + XCTAssertFalse(MarkdownRenderer.isListItem("1.\(nbsp)one")) + XCTAssertFalse(MarkdownRenderer.isThematicBreak("\(nbsp)---")) + XCTAssertFalse(MarkdownRenderer.isThematicBreak("---\(nbsp)")) + // A link definition is not one: it stays in the text. + let definition = try XCTUnwrap(parseReturning("[x]\n\n\(nbsp)[x]: https://example.com\n")) + XCTAssertEqual(definition.count, 2) + XCTAssertEqual(definition.last.flatMap(plain), "\(nbsp)[x]: https://example.com") + + // Spaces and tabs still indent every one of them. + XCTAssertEqual(try kinds(" ```\ncode\n ```\n"), ["code"]) + XCTAssertEqual(try kinds("Title\n ===\n"), ["heading"]) + XCTAssertEqual(try kinds("# Title\n"), ["heading"]) + XCTAssertEqual(try kinds("#\tTitle\n"), ["heading"]) + XCTAssertEqual(try kinds("| a | b |\n |---|---|\n| 1 | 2 |\n"), ["table"]) + XCTAssertTrue(MarkdownRenderer.isListItem(" - item")) + XCTAssertTrue(MarkdownRenderer.isListItem("1.\tone")) + XCTAssertTrue(MarkdownRenderer.isThematicBreak(" ---\t")) + } + // MARK: - Anchor signatures /// The scroll anchor is found again after a reload by the block's content From 636de0e6774027f992467ab377eb400575271337 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski`), not a + /// blank line; `1. x` is paragraph text "1. x", never a list item + /// (references are decoded after block parsing). + func testMergedRowRenderingWithLinkedImagesAndReferences() { + func hangs(_ md: String) -> [CGFloat] { + parse(md).compactMap { block -> CGFloat? in + guard case .text(let attributed) = block.content, + let style = attributed.runs.first?[AttributeScopes.AppKitAttributes.ParagraphStyleAttribute.self] + else { return nil } + return style.headIndent + } + } + let linked = "1. one\n2. two\n\n [](https://x)\n\n10. ten" + let plain = "1. one\n2. two\n\n \n\n10. ten" + let linkedHangs = hangs(linked) + XCTAssertEqual(linkedHangs.count, 2) + XCTAssertEqual(linkedHangs, hangs(plain), "a linked image stands in like a plain image") + XCTAssertEqual(linkedHangs[0], linkedHangs[1], accuracy: 0.01, "one list: 1. and 10. share the widest hang") + let split = hangs("1. one\n2. two\n\n[](https://x)\n\n10. ten") + XCTAssertNotEqual(split[0], split[1], "control: an unindented picture ends the list") + + let blocks = parse("a\n\n \n\n1. x") + XCTAssertEqual(blocks.compactMap(plainText), ["a", "\u{00A0}", "1. x"]) + guard case .text(let item) = blocks[2].content else { return XCTFail("text") } + XCTAssertNil(item.runs.first?[AttributeScopes.AppKitAttributes.ParagraphStyleAttribute.self]?.headIndent + .nonZero, "no list hang") + } + // MARK: - Reference links in quotes, alerts and table cells /// Quotes, alerts and table cells keep their markdown and are rendered @@ -1335,3 +1367,7 @@ final class ParserTests: XCTestCase { XCTAssertEqual(gapBlocks[1].sourceLine, 81) } } + +private extension CGFloat { + var nonZero: CGFloat? { self == 0 ? nil : self } +} From e71f11cad174fb3b46b0fd35d7390771f12fa98c Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski
text` is inline HTML.
+ static func startsHTMLBlock(_ marker: String) -> Bool {
+ guard marker.hasPrefix("<") else { return false }
+ if marker.hasPrefix("" || rest.hasPrefix("/>")
+ if ends, htmlBlockTags.contains(name) { return true }
+ return marker.range(of: #"^?[A-Za-z][A-Za-z0-9-]*(\s[^<>]*)?/?>[ \t]*$"#,
+ options: .regularExpression) != nil
+ }
+
+ /// CommonMark HTML block kinds 1 and 6.
+ private static let htmlBlockTags: Set = [
+ "pre", "script", "style", "textarea",
+ "address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center",
+ "col", "colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption",
+ "figure", "footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head",
+ "header", "hr", "html", "iframe", "legend", "li", "link", "main", "menu", "menuitem", "nav",
+ "noframes", "ol", "optgroup", "option", "p", "param", "search", "section", "summary", "table",
+ "tbody", "td", "tfoot", "th", "thead", "title", "tr", "track", "ul",
+ ]
+
/// A text row waiting for `renderPendingRows`: its soft-break joined lines
/// and where its block sits in the block list.
struct PendingTextRow {
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 62a24e1..bb3e623 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -381,7 +381,9 @@ final class VerticalRhythmTests: XCTestCase {
}
func testNestedQuoteGroupsJoinTheirQuote() {
- let blocks = parse("Text.\n\n> outer\n>> inner\n> outer again\n\n> separate quote")
+ // The empty `>` line closes the inner quote's paragraph: without it
+ // `> outer again` would continue that paragraph lazily (CommonMark).
+ let blocks = parse("Text.\n\n> outer\n>> inner\n>\n> outer again\n\n> separate quote")
XCTAssertEqual(blocks.count, 5)
XCTAssertEqual(blocks.map(\.joinsPrevious), [false, false, true, true, false])
let measured = measure(blocks)
@@ -532,10 +534,12 @@ final class VerticalRhythmTests: XCTestCase {
let name = String(format: "U+%04X", space.unicodeScalars.first!.value)
let blocks = try XCTUnwrap(parseReturning("Intro\n\n\(space)> quoted\n"), name)
XCTAssertEqual(blocks.map(plain), ["Intro", "\(space)> quoted"], name)
- // Inside a quote run too: the run ends there, and the parser goes on.
+ // Inside a quote run too: no marker, so the line is the quote
+ // paragraph's lazy continuation, text and all (GitHub:
+ // `a > b
`), and the parser goes on.
let inside = try XCTUnwrap(parseReturning("> a\n\(space)> b\n\nAfter."), name)
if case .blockquote(let content, _) = inside.first?.content {
- XCTAssertEqual(content, "a", name)
+ XCTAssertEqual(content, "a\n\(space)> b", name)
} else {
XCTFail("\(name): \(String(describing: inside.first?.content))")
}
@@ -606,6 +610,106 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertTrue(MarkdownRenderer.isThematicBreak(" ---\t"))
}
+ // MARK: - Quote laziness
+
+ /// The parse in short: `q:` for a quote, `alert::`,
+ /// `t:`, `c:`, `h:`, `img`, `table`.
+ private func quoteShape(_ markdown: String, file: StaticString = #filePath,
+ line: UInt = #line) throws -> [String] {
+ try XCTUnwrap(parseReturning(markdown, file: file, line: line), file: file, line: line).map { block in
+ switch block.content {
+ case .blockquote(let content, let level): return "q\(level):\(content)"
+ case .alert(let kind, let content): return "alert:\(kind.rawValue):\(content)"
+ case .text(let attributed): return "t:" + String(attributed.characters)
+ case .codeBlock(let code, _): return "c:\(code)"
+ case .heading(_, let title, _): return "h:\(title)"
+ case .image: return "img"
+ case .table: return "table"
+ default: return "other"
+ }
+ }
+ }
+
+ /// CommonMark laziness: a line without the quote marker that would be a
+ /// paragraph's continuation continues the paragraph the quote ends in —
+ /// in a nested quote, a list item or an alert too, and on a line with
+ /// fewer markers than the quote. Our parser ended the quote and made the
+ /// line a paragraph of its own. Every expected shape is GitHub's
+ /// (`gh api markdown`, scratchpad/audit/gfm/lazy_*.html).
+ func testALazyLineContinuesTheParagraphAQuoteEndsIn() throws {
+ // Continues: a plain line, a hard-wrapped quote, `===`, an indented
+ // line, an item that may not start a list here (none may: a lazy
+ // line is checked against the document, not the paragraph).
+ XCTAssertEqual(try quoteShape("> a\nb\n"), ["q1:a\nb"])
+ XCTAssertEqual(try quoteShape("> line one\nline two\nline three\n\nAfter\n"),
+ ["q1:line one\nline two\nline three", "t:After"])
+ XCTAssertEqual(try quoteShape("> a\n===\n"), ["q1:a\n==="])
+ XCTAssertEqual(try quoteShape("> a\n b\n"), ["q1:a\n b"])
+ XCTAssertEqual(try quoteShape("> a\nx\n").first, "q1:a\nx")
+ XCTAssertEqual(try quoteShape("> a\nb\n> c\n"), ["q1:a\nb\nc"])
+ // In a list item, a nested quote, an alert; and with fewer markers.
+ XCTAssertEqual(try quoteShape("> - a\nb\n"), ["q1:- a\nb"])
+ XCTAssertEqual(try quoteShape("> > a\nb\n"), ["q2:a\nb"])
+ XCTAssertEqual(try quoteShape("> > a\n> b\n"), ["q2:a\nb"])
+ XCTAssertEqual(try quoteShape("> > > a\n> b\n"), ["q3:a\nb"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\n> text\nlazy\n"), ["alert:note:text\nlazy"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\nlazy\n"), ["alert:note:lazy"])
+
+ // Ends the quote: a blank line, an empty quote line, a block start.
+ XCTAssertEqual(try quoteShape("> a\n\nb\n"), ["q1:a", "t:b"])
+ XCTAssertEqual(try quoteShape("> a\n>\nb\n"), ["q1:a", "t:b"])
+ XCTAssertEqual(try quoteShape("> a\n```\ncode\n```\n"), ["q1:a", "c:code"])
+ XCTAssertEqual(try quoteShape("> a\n~~~\ncode\n~~~\n"), ["q1:a", "c:code"])
+ XCTAssertEqual(try quoteShape("> a\n# H\n"), ["q1:a", "h:H"])
+ XCTAssertEqual(try quoteShape("> a\n---\n").count, 2)
+ XCTAssertEqual(try quoteShape("> a\n***\n").count, 2)
+ XCTAssertEqual(try quoteShape("> a\n- b\n"), ["q1:a", "t:• b"])
+ XCTAssertEqual(try quoteShape("> a\n2. b\n").first, "q1:a")
+ XCTAssertEqual(try quoteShape("> a\n2. b\n").count, 2)
+ XCTAssertEqual(try quoteShape("> a\nx\n").first, "q1:a")
+ XCTAssertEqual(try quoteShape("> a\n\n").first, "q1:a")
+ XCTAssertEqual(try quoteShape("> > a\n> - b\n"), ["q2:a", "q1:- b"])
+ // Our own block starts end it too: a picture line, `$$` (GitHub reads
+ // both as the paragraph's text; here they stay the picture and the
+ // formula they are everywhere else).
+ XCTAssertEqual(try quoteShape("> a\n\n"), ["q1:a", "img"])
+ XCTAssertEqual(try quoteShape("> a\n$$\nx\n$$\n").first, "q1:a")
+
+ // Nothing to continue: the quote does not end in a paragraph.
+ XCTAssertEqual(try quoteShape("> ```\n> code\nb\n"), ["q1:```\ncode", "t:b"])
+ XCTAssertEqual(try quoteShape("> # H\nb\n"), ["q1:# H", "t:b"])
+ XCTAssertEqual(try quoteShape("> a\n> ===\nb\n"), ["q1:a\n===", "t:b"])
+ XCTAssertEqual(try quoteShape("> | x | y |\n> |---|---|\n> | 1 | 2 |\nlazy\n"),
+ ["q1:| x | y |\n|---|---|\n| 1 | 2 |", "t:lazy"])
+ }
+
+ /// The lines a quote absorbed are the quote's: its source line is its
+ /// first, the next block starts after them, the tint spans them, search
+ /// finds and copy reads them in the quote — and a long hard-wrapped quote
+ /// with a marker on its first line only parses (the hang guard) into one
+ /// quote row.
+ func testTheLinesAQuoteAbsorbedAreTheQuotes() throws {
+ let markdown = "Intro\n\n> Quoted start\nlazy middle word\nlazy end\n\nAfter\n"
+ let blocks = try XCTUnwrap(parseReturning(markdown))
+ XCTAssertEqual(blocks.count, 3)
+ XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 6])
+ let ns = markdown as NSString
+ let spans = SourceTint.spans(for: blocks, lineStarts: SourceEditSupport.lineStarts(in: ns), in: ns)
+ XCTAssertEqual(spans.map { ns.substring(with: $0.range) }, ["> Quoted start\nlazy middle word\nlazy end"])
+ XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "middle").matchBlockIds, [blocks[1].id])
+ let measured = measure(blocks)
+ XCTAssertEqual(measured.converted[blocks[1].id].map(\.string), "Quoted start lazy middle word lazy end")
+
+ let long = "> first\n" + String(repeating: "a hard-wrapped line of the quote\n", count: 5_000) + "\nAfter\n"
+ let longBlocks = try XCTUnwrap(parseReturning(long, within: 10))
+ XCTAssertEqual(longBlocks.count, 2)
+ if case .blockquote(let content, _) = longBlocks[0].content {
+ XCTAssertEqual(content.components(separatedBy: "\n").count, 5_001)
+ } else {
+ XCTFail("\(longBlocks[0].content)")
+ }
+ }
+
// MARK: - Anchor signatures
/// The scroll anchor is found again after a reload by the block's content
From c0e3378fe90b8d4a02aaf8d9442609c3e1325b05 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:00:10 +0100
Subject: [PATCH 066/105] fix(math): a formula keeps its LaTeX escapes
Review R11: inline math is found in the rendered text, after every
backslash escape ran, and a633405 made all ASCII punctuation escapable:
$50\%$ reached SwiftMath as "50%" (a TeX comment, typeset "50"),
\, \; \: thin spaces were lost, \{ \} \_ never survived.
Generalises f06ad86: the escape tags EVERY escaped character
(EscapedCharacterAttribute). InlineMathSegmenter.pieces(of:) splits with
those offsets (an escaped $ never delimits) and builds each formula's
LaTeX with a backslash put back before every escaped character, by
offset. Text outside formulas is unchanged. BlockTextConverter (screen,
measurer, search, the copy tag) and PrintableMath (print/PDF) both use
pieces(of:).
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/BlockHeightMeasurer.swift | 15 ++--
QuickMD/QuickMD/InlineMathSegmenter.swift | 93 ++++++++++++++++-------
QuickMD/QuickMD/MarkdownExport.swift | 14 ++--
QuickMD/QuickMD/MarkdownRenderer.swift | 5 +-
QuickMD/QuickMDTests/RendererTests.swift | 35 ++++++++-
5 files changed, 113 insertions(+), 49 deletions(-)
diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift
index faea09f..b818a9c 100644
--- a/QuickMD/QuickMD/BlockHeightMeasurer.swift
+++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift
@@ -737,16 +737,13 @@ enum BlockTextConverter {
?? NSAttributedString(string: String(attributed.characters))
}
- let plain = String(attributed.characters)
- let segments = InlineMathSegmenter.split(plain,
- literalDollars: InlineMathSegmenter.literalDollars(in: attributed))
let result = NSMutableAttributedString()
var attrIndex = attributed.startIndex
- for segment in segments {
- switch segment {
+ for piece in InlineMathSegmenter.pieces(of: attributed) {
+ switch piece.segment {
case .text(let str):
- if let end = attributed.characters.index(attrIndex, offsetBy: str.count,
+ if let end = attributed.characters.index(attrIndex, offsetBy: piece.length,
limitedBy: attributed.endIndex) {
let slice = AttributedString(attributed[attrIndex.. Set {
+ /// The Character offsets, in `String(attributed.characters)`, of every
+ /// character the author escaped (`EscapedCharacterAttribute`). Empty for
+ /// almost every paragraph: only tagged runs are walked.
+ static func escapedCharacters(in attributed: AttributedString) -> Set {
var offsets: Set = []
let characters = attributed.characters
- for run in attributed.runs where run[LiteralDollarAttribute.self] == true {
+ for run in attributed.runs where run[EscapedCharacterAttribute.self] == true {
var offset = characters.distance(from: characters.startIndex, to: run.range.lowerBound)
- for character in characters[run.range] {
- if character == "$" { offsets.insert(offset) }
+ for _ in characters[run.range] {
+ offsets.insert(offset)
offset += 1
}
}
return offsets
}
- /// The LaTeX of a math segment: a `$` inside one can only be an escaped
- /// dollar (any other would have closed it), so it goes back to `\$`
- /// (`$a\$b$` typesets "a$b"). The segment's SOURCE length is still
- /// `latex.count + 2`.
- static func tex(_ latex: String) -> String {
- latex.contains("$") ? latex.replacingOccurrences(of: "$", with: "\\$") : latex
- }
-
enum Segment: Equatable {
case text(String)
case math(String)
}
- /// `literalDollars`: Character offsets of escaped dollars
- /// (`literalDollars(in:)`) — never a delimiter, so they stay text or, inside
- /// a formula, part of its LaTeX (`tex`).
- static func split(_ text: String, literalDollars: Set = []) -> [Segment] {
+ /// A segment as the views draw it: text as is, a formula as the LaTeX to
+ /// typeset (escaped characters with their backslash back, `tex`), with
+ /// the number of Characters it spans in the rendered text (`$…$`).
+ struct Piece: Equatable {
+ let segment: Segment
+ let length: Int
+ }
+
+ /// The paragraph's text and formulas, from the renderer's string: the one
+ /// split the screen, the measurer, search and print share.
+ static func pieces(of attributed: AttributedString) -> [Piece] {
+ let plain = String(attributed.characters)
+ let escaped = escapedCharacters(in: attributed)
+ var offset = 0
+ return split(plain, escaped: escaped).map { segment in
+ switch segment {
+ case .text(let text):
+ offset += text.count
+ return Piece(segment: segment, length: text.count)
+ case .math(let source):
+ let piece = Piece(segment: .math(tex(source, escaped: escaped, contentStart: offset + 1)),
+ length: source.count + 2)
+ offset += piece.length
+ return piece
+ }
+ }
+ }
+
+ /// A formula's LaTeX: `source` (as rendered) with a backslash put back
+ /// before every character that was escaped — by offset, never by
+ /// character, so a `$` or `%` the author did not escape stays as it is.
+ /// `contentStart` is the offset of `source`'s first character.
+ static func tex(_ source: String, escaped: Set, contentStart: Int) -> String {
+ guard !escaped.isEmpty else { return source }
+ var latex = ""
+ for (index, character) in source.enumerated() {
+ if escaped.contains(contentStart + index) { latex.append("\\") }
+ latex.append(character)
+ }
+ return latex
+ }
+
+ /// `escaped`: Character offsets of escaped characters
+ /// (`escapedCharacters(in:)`). An escaped `$` is never a delimiter, so it
+ /// stays text or, inside a formula, part of its LaTeX.
+ static func split(_ text: String, escaped: Set = []) -> [Segment] {
var segments: [Segment] = []
var remaining = text[...]
var buffer = ""
- let literal: Set = Set(literalDollars.compactMap {
+ let literal: Set = Set(escaped.compactMap {
text.index(text.startIndex, offsetBy: $0, limitedBy: text.endIndex)
})
/// The next delimiter `$` at or after `from`.
diff --git a/QuickMD/QuickMD/MarkdownExport.swift b/QuickMD/QuickMD/MarkdownExport.swift
index dbceaba..68f484b 100644
--- a/QuickMD/QuickMD/MarkdownExport.swift
+++ b/QuickMD/QuickMD/MarkdownExport.swift
@@ -935,21 +935,19 @@ enum PrintableMath {
var result = Text("")
var index = attributed.startIndex
- for segment in InlineMathSegmenter.split(plain,
- literalDollars: InlineMathSegmenter.literalDollars(in: attributed)) {
- switch segment {
- case .text(let str):
- if let end = attributed.characters.index(index, offsetBy: str.count,
+ for piece in InlineMathSegmenter.pieces(of: attributed) {
+ switch piece.segment {
+ case .text:
+ if let end = attributed.characters.index(index, offsetBy: piece.length,
limitedBy: attributed.endIndex) {
result = result + Text(AttributedString(attributed[index..`
+ /// escape ran; the formula must still reach the engine with its LaTeX
+ /// escapes (`$50\%$` typesets "50%": a bare `%` starts a TeX comment).
+ /// Text outside the formula keeps Markdown's escapes. The copy tag carries
+ /// the same LaTeX, and print uses the same `pieces(of:)`.
+ func testInlineMathKeepsItsLaTeXEscapes() throws {
+ for (source, latex) in [("$50\\%$", "50\\%"), ("$a\\,b$", "a\\,b"), ("$\\{x\\}$", "\\{x\\}"),
+ ("$a\\_b$", "a\\_b"), ("$a\\$b$", "a\\$b"), ("$a\\;b\\:c$", "a\\;b\\:c")] {
+ XCTAssertEqual(shownParagraph("x " + source + " y").formulas, [latex], source)
+ }
+ let mixed = shownParagraph("Price \\$5, \\* and $a\\,b + \\{c\\}$ then \\_")
+ XCTAssertEqual(mixed.formulas, ["a\\,b + \\{c\\}"])
+ XCTAssertEqual(mixed.text, "Price $5, * and \u{FFFC} then _")
+
+ // The copy tag (DocumentCopyBuilder writes `$$`) is the LaTeX too.
+ let theme = MarkdownTheme.cached(for: .light)
+ guard case .text(let attributed)? = MarkdownBlockParser(theme: theme).parse("t $50\\%$").first?.content else {
+ return XCTFail("paragraph")
+ }
+ let ns = BlockTextConverter.makeNSAttributedString(
+ from: attributed, hasInlineMath: true, theme: theme, fontScale: 1,
+ math: MathRendering(inlineImage: { _, _, _ in NSImage(size: NSSize(width: 1, height: 1)) },
+ displayHeight: { _, _, _ in 0 }))
+ var tags: [String] = []
+ ns.enumerateAttribute(.qmdInlineMathSource, in: NSRange(location: 0, length: ns.length)) { value, _, _ in
+ if let value = value as? String { tags.append(value) }
+ }
+ XCTAssertEqual(tags, ["50\\%"])
+ XCTAssertEqual(InlineMathSegmenter.pieces(of: attributed).map(\.length), [2, 5], "spans in the rendered text: $50%$")
+ }
+
// MARK: - References in link destinations (review R10)
/// A destination's character references are decoded (hrefs from
@@ -908,7 +941,7 @@ final class RendererTests: XCTestCase {
XCTAssertTrue(cell.contains("$5 and $10\n"))
XCTAssertEqual(String(renderer.renderQuotedBody("Pay \\$5").characters), "Pay $5")
// The segmenter itself: offsets of escaped dollars never delimit.
- XCTAssertEqual(InlineMathSegmenter.split("a $5 and $6", literalDollars: [2, 9]), [.text("a $5 and $6")])
+ XCTAssertEqual(InlineMathSegmenter.split("a $5 and $6", escaped: [2, 9]), [.text("a $5 and $6")])
}
}
From fa3f125f42f0c20894283718d7a0614ea9b86a33 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:01:20 +0100
Subject: [PATCH 067/105] fix(alerts): an alert marker takes spaces and tabs
around it, nothing else
AlertKind trimmed its marker line with .whitespaces, so `> [!NOTE]`
followed by a no-break space was an alert here and a plain quote on
GitHub (gh api markdown, gfm mode). It trims spaces and tabs only now
(MarkdownRenderer.markerWhitespace); two trailing spaces or a tab still
make the alert, on both.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlock.swift | 4 +++-
QuickMD/QuickMDTests/VerticalRhythmTests.swift | 12 ++++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/QuickMD/QuickMD/MarkdownBlock.swift b/QuickMD/QuickMD/MarkdownBlock.swift
index 23c2a74..e36d1be 100644
--- a/QuickMD/QuickMD/MarkdownBlock.swift
+++ b/QuickMD/QuickMD/MarkdownBlock.swift
@@ -12,8 +12,10 @@ enum AlertKind: String, Sendable, CaseIterable {
/// Parses a marker line (already stripped of `>` prefixes). The marker must
/// be the only thing on the line — `[!NOTE] text` is a regular blockquote.
/// Case-insensitive, matching GitHub's behavior.
+ /// Spaces and tabs around the marker only (`MarkdownRenderer.markerWhitespace`):
+ /// GitHub reads `[!NOTE]` + a no-break space as a plain quote.
init?(markerLine: String) {
- let trimmed = markerLine.trimmingCharacters(in: .whitespaces)
+ let trimmed = markerLine.trimmingCharacters(in: MarkdownRenderer.markerWhitespace)
guard trimmed.hasPrefix("[!"), trimmed.hasSuffix("]") else { return nil }
let name = trimmed.dropFirst(2).dropLast().lowercased()
self.init(rawValue: name)
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index bb3e623..955d088 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -495,6 +495,18 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertNil(aim.blockId) // a new table
}
+ /// An alert marker is `[!KIND]` with spaces or tabs around it (GitHub,
+ /// gh api markdown in gfm mode): a no-break space after it leaves a
+ /// plain quote; two trailing spaces still make the alert.
+ func testAnAlertMarkerTakesSpacesAndTabsOnly() throws {
+ XCTAssertEqual(try quoteShape("> [!NOTE]\u{00A0}\n> body\n"), ["q1:[!NOTE]\u{00A0}\nbody"])
+ XCTAssertEqual(try quoteShape("> [!NOTE] \n> body\n"), ["alert:note:body"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\t\n> body\n"), ["alert:note:body"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\n> body\n"), ["alert:note:body"])
+ XCTAssertNil(AlertKind(markerLine: "\u{2003}[!TIP]"))
+ XCTAssertEqual(AlertKind(markerLine: " [!TIP] "), .tip)
+ }
+
// MARK: - Block markers: spaces and tabs only
/// Parses on a worker thread: a parser that never returns fails the test
From 9894f25ac387283400241f8f429fd13bcb8fdae4 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:02:17 +0100
Subject: [PATCH 068/105] fix(search): only a wheel event that scrolls drops a
jump's aim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
BlockScrollView.scrollWheel dropped the pending aim at the match for
every wheel event, including fingers resting on the trackpad (phase
.mayBegin), a gesture's end and zero-delta events: ⌘G with fingers on
the trackpad landed on the block's centre instead of the match.
PendingMatchAim.isReaderScroll: a vertical delta, or a gesture or its
momentum beginning or going on. The other events leave the aim alone.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/VerticalRhythm.swift | 11 +++++++++++
QuickMD/QuickMD/Views/VirtualBlockList.swift | 4 +++-
.../QuickMDTests/VerticalRhythmTests.swift | 19 +++++++++++++++++++
3 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/QuickMD/QuickMD/VerticalRhythm.swift b/QuickMD/QuickMD/VerticalRhythm.swift
index ca9d9a0..5bfc14c 100644
--- a/QuickMD/QuickMD/VerticalRhythm.swift
+++ b/QuickMD/QuickMD/VerticalRhythm.swift
@@ -356,6 +356,17 @@ struct PendingMatchAim: Equatable {
/// Taken, given up, or a new table.
mutating func drop() { blockId = nil }
+
+ /// Whether a scroll-wheel event is the reader's scroll: it moves the
+ /// document (a vertical delta), or a gesture or its momentum begins or
+ /// goes on. Fingers resting on the trackpad (`.mayBegin`), a gesture's
+ /// end and zero-delta events move nothing and leave the aim alone.
+ static func isReaderScroll(_ phase: NSEvent.Phase, _ momentumPhase: NSEvent.Phase,
+ _ deltaY: CGFloat) -> Bool {
+ if deltaY != 0 { return true }
+ let moving: NSEvent.Phase = [.began, .changed]
+ return !phase.isDisjoint(with: moving) || !momentumPhase.isDisjoint(with: moving)
+ }
}
// MARK: - Print / PDF
diff --git a/QuickMD/QuickMD/Views/VirtualBlockList.swift b/QuickMD/QuickMD/Views/VirtualBlockList.swift
index b96e247..8f5926e 100644
--- a/QuickMD/QuickMD/Views/VirtualBlockList.swift
+++ b/QuickMD/QuickMD/Views/VirtualBlockList.swift
@@ -1522,7 +1522,9 @@ final class BlockScrollView: NSScrollView {
var onReaderScroll: (() -> Void)?
override func scrollWheel(with event: NSEvent) {
- onReaderScroll?()
+ if PendingMatchAim.isReaderScroll(event.phase, event.momentumPhase, event.scrollingDeltaY) {
+ onReaderScroll?()
+ }
super.scrollWheel(with: event)
}
/// Clicks in the content insets above the first / below the last row land
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 955d088..df6aa2b 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -507,6 +507,25 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(AlertKind(markerLine: " [!TIP] "), .tip)
}
+ /// Which wheel events are the reader's scroll and drop a jump's aim: the
+ /// ones that move the document. Fingers resting on the trackpad
+ /// (`.mayBegin`), a gesture's end and zero-delta events used to drop it
+ /// too, so ⌘G with fingers on the trackpad landed on the block's centre.
+ func testOnlyAWheelEventThatScrollsDropsTheAim() {
+ let scrolls = PendingMatchAim.isReaderScroll
+ XCTAssertFalse(scrolls([.mayBegin], [], 0)) // fingers resting
+ XCTAssertFalse(scrolls([], [], 0)) // a zero-delta event
+ XCTAssertFalse(scrolls([.ended], [], 0)) // the gesture lifts
+ XCTAssertFalse(scrolls([.cancelled], [], 0))
+ XCTAssertFalse(scrolls([.stationary], [], 0))
+ XCTAssertFalse(scrolls([], [.ended], 0)) // momentum runs out
+ XCTAssertTrue(scrolls([], [], -3)) // a mouse wheel notch
+ XCTAssertTrue(scrolls([.began], [], 0)) // a gesture starts
+ XCTAssertTrue(scrolls([.changed], [], 1.5))
+ XCTAssertTrue(scrolls([], [.began], 0)) // momentum
+ XCTAssertTrue(scrolls([], [.changed], -12))
+ }
+
// MARK: - Block markers: spaces and tabs only
/// Parses on a worker thread: a parser that never returns fails the test
From 1ed16c1afc43686b3206163d55aef5471e94c398 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:02:21 +0100
Subject: [PATCH 069/105] fix(links): a link's text with a backslash escape is
inline-parsed
Review R12: link text was drawn raw unless it held an image, so
[cost \$5 and \$6](u) showed "cost \" plus a formula, and [a \* b](u)
kept its backslash; GitHub shows "cost $5 and $6" and "a * b".
MarkdownRenderer.parsesLinkText (an image or a backslash) decides it for
the renderer and for ReferenceLinks' rewrite alike; the text is parsed
with links disabled, as the image case always was.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownRenderer.swift | 18 ++++++++++++++----
QuickMD/QuickMD/ReferenceLinks.swift | 4 ++--
QuickMD/QuickMDTests/RendererTests.swift | 21 +++++++++++++++++++++
3 files changed, 37 insertions(+), 6 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index b3dd341..abf5d01 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -981,15 +981,25 @@ struct MarkdownRenderer: Sendable {
return nil
}
+ /// Whether a link's text is inline-parsed (with links disabled) rather
+ /// than drawn as written: when it holds an image (`[](link)`)
+ /// or a backslash escape (`[cost \$5 and \$6](u)`, `[a \* b](u)` — GitHub
+ /// shows "cost $5 and $6", "a * b"). `ReferenceLinks` follows the same
+ /// rule when it rewrites a link's text.
+ static func parsesLinkText(_ text: String) -> Bool {
+ text.contains("](link)`): the text is rendered
// inline so the image becomes its "[Image: b]" placeholder, and the
// whole run carries the OUTER link (set below, over the image's
- // own). Only for text holding an image, so every other link keeps
- // its exact plain-text rendering. Rendered with links disabled:
- // a link inside a link is literal text (bounds the recursion).
+ // own); an escape loses its backslash (and an escaped `$` is never
+ // math). Every other link keeps its exact plain-text rendering.
+ // Rendered with links disabled: a link inside a link is literal
+ // text (bounds the recursion).
var linkText = self
linkText.linksDisabled = true
attr = linkText.renderInlineFormatting(text)
diff --git a/QuickMD/QuickMD/ReferenceLinks.swift b/QuickMD/QuickMD/ReferenceLinks.swift
index 7b5e67b..d116b04 100644
--- a/QuickMD/QuickMD/ReferenceLinks.swift
+++ b/QuickMD/QuickMD/ReferenceLinks.swift
@@ -153,7 +153,7 @@ enum ReferenceLinks {
if remaining[afterBracket...].hasPrefix("(") {
guard let inline = InlineSyntaxScanner.parenthesizedDestination(remaining[afterBracket...],
pairs: pairs) else { return nil }
- guard linkText.contains("![") else {
+ guard MarkdownRenderer.parsesLinkText(String(linkText)) else {
return (String(remaining[.. [a \\* b][d] and [c \\$5][d]\n\n[d]: https://d.example")
+ guard case .blockquote(let quote, _) = blocks[0].content else { return XCTFail("quote") }
+ XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "a * b and c $5")
+ }
+
// MARK: - References in link destinations (review R10)
/// A destination's character references are decoded (hrefs from
From 3dccab028df3759a145afe326fb3ae2cd4985964 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:03:08 +0100
Subject: [PATCH 070/105] =?UTF-8?q?fix(source-edit):=20the=20editor's=20fo?=
=?UTF-8?q?nt=20follows=20=E2=8C=98+=20at=20once,=20its=20column=20the=20l?=
=?UTF-8?q?ist?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
636de0e fed renderedFontScale to the whole editor style, so the editor's
text changed size only when the zoom's re-parse landed: about 2 s on an
11.7K-line document, and with ⌘+⌘+⌘+ (earlier parses superseded) once,
at the end. Before it the editor zoomed at once.
The style has two zooms now: fontScale (the font, the zoom asked for)
and columnFontScale (the Reading Mode column cap, the zoom the list
shows). Style.overDocument(theme:fontScale:shownFontScale:isReadingMode:)
sets both; the editor's column stays the list's at every moment.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownView.swift | 3 +-
QuickMD/QuickMD/SourceEditorController.swift | 30 ++++++++-----
.../SourceEditorControllerTests.swift | 45 ++++++++++++++++---
3 files changed, 60 insertions(+), 18 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift
index d8af58d..eaea016 100644
--- a/QuickMD/QuickMD/MarkdownView.swift
+++ b/QuickMD/QuickMD/MarkdownView.swift
@@ -324,7 +324,8 @@ struct MarkdownView: View {
Group {
if editSession.isActive {
SourceEditorView(controller: editSession.editor,
- style: .overDocument(theme: theme, shownFontScale: renderedFontScale,
+ style: .overDocument(theme: theme, fontScale: fontScale,
+ shownFontScale: renderedFontScale,
isReadingMode: isReadingMode))
}
}
diff --git a/QuickMD/QuickMD/SourceEditorController.swift b/QuickMD/QuickMD/SourceEditorController.swift
index 23ac428..a025d65 100644
--- a/QuickMD/QuickMD/SourceEditorController.swift
+++ b/QuickMD/QuickMD/SourceEditorController.swift
@@ -103,14 +103,20 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD
/// `apply(style:)`, so SwiftUI may hand the same one on every update.
struct Style: Equatable {
var theme: MarkdownTheme
+ /// The editor's font size (the zoom asked for: it follows ⌘+ at once).
var fontScale: Double
var isReadingLayout: Bool
+ /// The zoom the Reading Mode column is capped at (`columnGeometry`);
+ /// nil = `fontScale`. Over a rendered document, the zoom the list
+ /// SHOWS (`overDocument`).
+ var columnFontScale: Double? = nil
/// `MarkdownTheme` is not `Equatable`; compare what the editor reads
/// from it. Colours as well as the name: a custom theme re-imported
/// under the same name may change them.
static func == (lhs: Style, rhs: Style) -> Bool {
lhs.fontScale == rhs.fontScale
+ && lhs.columnFontScale == rhs.columnFontScale
&& lhs.isReadingLayout == rhs.isReadingLayout
&& lhs.theme.name == rhs.theme.name
&& lhs.theme.isDark == rhs.theme.isDark
@@ -120,16 +126,18 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD
&& lhs.hasSameTintColors(as: rhs)
}
- /// The editor's style over a rendered document: at the zoom the
- /// document is SHOWN at (`MarkdownView.renderedFontScale` — the scale
- /// its blocks were parsed at and its Reading Mode column is capped
- /// at), not the zoom just asked for. Between ⌘+ and the parse that
- /// follows it the two differ; the editor's column (`columnGeometry`)
- /// has to be the list's then too, or leaving Source Edit before the
- /// parse lands moves the text sideways. The editor's font follows the
- /// zoom when the parse lands, with the document's.
- static func overDocument(theme: MarkdownTheme, shownFontScale: Double, isReadingMode: Bool) -> Style {
- Style(theme: theme, fontScale: shownFontScale, isReadingLayout: isReadingMode)
+ /// The editor's style over a rendered document. Two zooms, because
+ /// between ⌘+ and the re-parse that follows it they differ:
+ /// - the font follows `fontScale`, the zoom asked for, at once;
+ /// - the Reading Mode column follows `shownFontScale`
+ /// (`MarkdownView.renderedFontScale` — the zoom the list's blocks
+ /// were parsed at and its column is capped at), so the editor's
+ /// column is the list's at every moment and leaving Source Edit
+ /// before the parse lands does not move the text sideways.
+ static func overDocument(theme: MarkdownTheme, fontScale: Double, shownFontScale: Double,
+ isReadingMode: Bool) -> Style {
+ Style(theme: theme, fontScale: fontScale, isReadingLayout: isReadingMode,
+ columnFontScale: shownFontScale)
}
/// The colours of the block tint (`paintTint`).
@@ -479,7 +487,7 @@ final class SourceEditorController: NSObject, NSTextViewDelegate, NSTextStorageD
defer { isUpdatingGeometry = false }
let clip = scrollView.contentView.bounds.size
let geometry = Self.columnGeometry(clipWidth: clip.width, isReadingLayout: style.isReadingLayout,
- fontScale: CGFloat(style.fontScale))
+ fontScale: CGFloat(style.columnFontScale ?? style.fontScale))
let inset = NSSize(width: geometry.horizontalInset, height: geometry.verticalInset)
// At least as tall as the clip, so a click below a short text still
// lands in the text view (and places the caret at the end).
diff --git a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift
index 9c95bde..6cea7b8 100644
--- a/QuickMD/QuickMDTests/SourceEditorControllerTests.swift
+++ b/QuickMD/QuickMDTests/SourceEditorControllerTests.swift
@@ -613,15 +613,16 @@ final class SourceEditorControllerTests: XCTestCase {
/// ⌘+ in Reading Mode with the editor open: the list keeps showing the
/// blocks of the old zoom, with that zoom's column cap, until the zoom's
- /// parse lands. The editor took the REQUESTED zoom (`fontScale`), so for
- /// that while its column was 1440 pt over a 720 pt list column, and
- /// leaving Source Edit then moved the text sideways. It follows the
- /// zoom the document is shown at now.
+ /// parse lands. The editor's COLUMN follows the zoom the document is
+ /// shown at (1.14.4 took the requested one: a 1440 pt column over a
+ /// 720 pt list column, and leaving Source Edit then moved the text
+ /// sideways); its FONT follows the requested zoom at once
+ /// (`testTheEditorFontFollowsTheRequestedZoomAtOnce`).
func testTheEditorColumnFollowsTheZoomTheDocumentIsShownAt() {
let requested = 2.0, shown = 1.0
let (controller, scrollView, _) = makeEditor(
"text", width: 1800,
- style: .overDocument(theme: light, shownFontScale: shown, isReadingMode: true))
+ style: .overDocument(theme: light, fontScale: requested, shownFontScale: shown, isReadingMode: true))
let inner = floor(scrollView.contentView.bounds.width) - 64
func listColumn(_ scale: Double) -> CGFloat {
BlockLayout.Document.columnWidth(inner: inner,
@@ -631,10 +632,42 @@ final class SourceEditorControllerTests: XCTestCase {
XCTAssertEqual(listColumn(requested), 1440) // the column the editor used to take
XCTAssertEqual(controller.textView.textContainer!.size.width, listColumn(shown))
// The zoom's parse lands: the list and the editor move together.
- controller.apply(style: .overDocument(theme: light, shownFontScale: requested, isReadingMode: true))
+ controller.apply(style: .overDocument(theme: light, fontScale: requested, shownFontScale: requested,
+ isReadingMode: true))
XCTAssertEqual(controller.textView.textContainer!.size.width, listColumn(requested))
}
+ /// The editor's text changes size the moment ⌘+ is pressed, as before
+ /// 636de0e — not when the zoom's re-parse lands (about 2 s on an
+ /// 11.7K-line document, and only once at the end of ⌘+⌘+⌘+, whose earlier
+ /// parses are superseded). Only the Reading Mode column waits for it.
+ func testTheEditorFontFollowsTheRequestedZoomAtOnce() {
+ let (controller, scrollView, _) = makeEditor(
+ "text", width: 1800,
+ style: .overDocument(theme: light, fontScale: 1, shownFontScale: 1, isReadingMode: true))
+ let base = BlockLayout.Code.codeFontSize
+ XCTAssertEqual(controller.textView.font?.pointSize, base)
+ let inner = floor(scrollView.contentView.bounds.width) - 64
+ for requested in [1.25, 1.5, 2.0] {
+ // ⌘+ pressed, the parse still out: new font, the list's column.
+ controller.apply(style: .overDocument(theme: light, fontScale: requested, shownFontScale: 1,
+ isReadingMode: true))
+ XCTAssertEqual(controller.textView.font?.pointSize, base * CGFloat(requested), "× \(requested)")
+ XCTAssertEqual(controller.textView.textContainer!.size.width, 720, "× \(requested)")
+ }
+ // The last parse lands: the column follows; the font stays.
+ controller.apply(style: .overDocument(theme: light, fontScale: 2, shownFontScale: 2, isReadingMode: true))
+ XCTAssertEqual(controller.textView.font?.pointSize, base * 2)
+ XCTAssertEqual(controller.textView.textContainer!.size.width,
+ BlockLayout.Document.columnWidth(inner: inner,
+ cap: BlockLayout.Document.readingColumnCap(fontScale: 2)))
+ // Outside Reading Mode there is no cap to wait for.
+ let standard = SourceEditorController.Style.overDocument(theme: light, fontScale: 1.5, shownFontScale: 1,
+ isReadingMode: false)
+ XCTAssertEqual(standard.fontScale, 1.5)
+ XCTAssertEqual(standard.columnFontScale, 1)
+ }
+
func testTextContainerFollowsTheViewWidthAndTheLayoutStyle() {
let (controller, scrollView, window) = makeEditor("text", width: 1000)
let view = controller.textView
From fef7f52f82be9bc99c48c8e1cbe8f32567b4268f Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:06:40 +0100
Subject: [PATCH 071/105] fix(links): a link's text is inline Markdown, as on
GitHub
Review R13: link text was drawn as written unless it held an image or a
backslash, so [**Docs**](u), [*plain*](v), [`npm install`](u) and
[~~old~~](u) showed their asterisks, backticks and tildes; GitHub renders
Docs and so on. makeLink now always inline-parses
the text with links disabled (bold, italic, code, strikethrough,
references, escapes, image placeholders) and the outer link covers the
whole run; ReferenceLinks rewrites every link's text by the same rule
(parsesLinkText is gone). Search, copy, the measurer, selection and print
all use the renderer's string, so they follow.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownRenderer.swift | 36 ++++++++----------------
QuickMD/QuickMD/ReferenceLinks.swift | 18 +++++-------
QuickMD/QuickMDTests/RendererTests.swift | 30 +++++++++++++++++++-
3 files changed, 47 insertions(+), 37 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index abf5d01..8c1603a 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -981,32 +981,18 @@ struct MarkdownRenderer: Sendable {
return nil
}
- /// Whether a link's text is inline-parsed (with links disabled) rather
- /// than drawn as written: when it holds an image (`[](link)`)
- /// or a backslash escape (`[cost \$5 and \$6](u)`, `[a \* b](u)` — GitHub
- /// shows "cost $5 and $6", "a * b"). `ReferenceLinks` follows the same
- /// rule when it rewrites a link's text.
- static func parsesLinkText(_ text: String) -> Bool {
- text.contains("](link)`): the text is rendered
- // inline so the image becomes its "[Image: b]" placeholder, and the
- // whole run carries the OUTER link (set below, over the image's
- // own); an escape loses its backslash (and an escaped `$` is never
- // math). Every other link keeps its exact plain-text rendering.
- // Rendered with links disabled: a link inside a link is literal
- // text (bounds the recursion).
- var linkText = self
- linkText.linksDisabled = true
- attr = linkText.renderInlineFormatting(text)
- } else {
- attr = AttributedString(decodedText(text))
- attr.setDualFont(size: scaled(14), fonts: theme.fonts)
- }
+ // A link's text is inline Markdown, as on GitHub: `[**Docs**](u)` is
+ // bold, ``[`npm install`](u)`` code, `[~~old~~](u)` struck through, an
+ // escape loses its backslash (an escaped `$` is never math), an image
+ // becomes its "[Image: b]" placeholder (`[](link)`). The
+ // whole run then carries the OUTER link (set below, over any inner
+ // one, an autolink's or an image's). Rendered with links disabled: a
+ // link inside a link is literal text (bounds the recursion).
+ // `ReferenceLinks` rewrites a link's text by the same rule.
+ var linkText = self
+ linkText.linksDisabled = true
+ var attr = linkText.renderInlineFormatting(text)
attr.setDualForeground(theme.linkColor)
attr.setDualUnderline()
diff --git a/QuickMD/QuickMD/ReferenceLinks.swift b/QuickMD/QuickMD/ReferenceLinks.swift
index d116b04..2a603e8 100644
--- a/QuickMD/QuickMD/ReferenceLinks.swift
+++ b/QuickMD/QuickMD/ReferenceLinks.swift
@@ -146,26 +146,22 @@ enum ReferenceLinks {
let afterBracket = remaining.index(after: closeBracket)
let linkText = String(remaining[remaining.index(after: remaining.startIndex).. (String, Substring) {
let destination = inlineDestination(url)
- // The renderer parses a link's text only when it holds an image
- // (and then with links disabled) — rewrite it under the same rule.
- let text = MarkdownRenderer.parsesLinkText(String(linkText))
- ? inlinedLine(linkText, sources: sources, linksDisabled: true) : linkText
+ // The renderer parses a link's text (links disabled): rewrite it
+ // under the same rule.
+ let text = inlinedLine(linkText, sources: sources, linksDisabled: true)
return ("[\(text)](\(destination))", rest)
}
diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift
index 55e2306..8dfefc3 100644
--- a/QuickMD/QuickMDTests/RendererTests.swift
+++ b/QuickMD/QuickMDTests/RendererTests.swift
@@ -861,6 +861,34 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(InlineMathSegmenter.pieces(of: attributed).map(\.length), [2, 5], "spans in the rendered text: $50%$")
}
+ // MARK: - Formatting inside link text (review R13)
+
+ /// A link's text is inline Markdown, as on GitHub (`gh api markdown`):
+ /// `Docs`, `plain`,
+ /// `npm install`, `old`,
+ /// `a b c`, `https://x.com`,
+ /// `a & b` — and the whole visible text carries the link.
+ func testLinkTextIsInlineMarkdown() {
+ let line = "[**Docs**](u) [*plain*](v) [`npm install`](u) [~~old~~](u) [a **b** c](u) [https://x.com](u) [a & b](u)"
+ let attributed = renderer.renderInline(line)
+ XCTAssertEqual(String(attributed.characters), "Docs plain npm install old a b c https://x.com a & b")
+ func run(_ text: String) -> AttributedString.Runs.Run? {
+ attributed.runs.first { String(attributed[$0.range].characters) == text }
+ }
+ func font(_ text: String) -> NSFont? { run(text)?[AttributeScopes.AppKitAttributes.FontAttribute.self] }
+ XCTAssertTrue(font("Docs")?.fontDescriptor.symbolicTraits.contains(.bold) ?? false)
+ XCTAssertTrue(font("plain")?.fontDescriptor.symbolicTraits.contains(.italic) ?? false)
+ XCTAssertTrue(font("npm install")?.fontDescriptor.symbolicTraits.contains(.monoSpace) ?? false)
+ XCTAssertNotNil(run("old")?[AttributeScopes.AppKitAttributes.StrikethroughStyleAttribute.self])
+ XCTAssertTrue(font("b")?.fontDescriptor.symbolicTraits.contains(.bold) ?? false)
+ // Every visible character outside the spaces between links is linked;
+ // the autolink-looking text links to the OUTER destination.
+ for linked in ["Docs", "plain", "npm install", "old", "a ", "b", " c", "a & b"] {
+ XCTAssertNotNil(run(linked)?.link, linked)
+ }
+ XCTAssertEqual(run("https://x.com")?.link?.absoluteString, "u")
+ }
+
// MARK: - Escapes in link text (review R12)
/// A link's text with a backslash escape is parsed like the image case:
@@ -871,7 +899,7 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(cost.text, "cost $5 and $6")
XCTAssertEqual(cost.formulas, [])
let star = renderer.renderInline("[a \\* b](u) and [*plain*](v)")
- XCTAssertEqual(String(star.characters), "a * b and *plain*")
+ XCTAssertEqual(String(star.characters), "a * b and plain", "R13: link text is inline Markdown")
XCTAssertEqual(Set(star.runs.compactMap { $0.link?.absoluteString }), ["u", "v"])
XCTAssertTrue(star.runs.filter { String(star[$0.range].characters).contains("*") }.allSatisfy { $0.link != nil },
"the whole link text stays linked")
From e2fd34d3b1aee9c06c39c0c60829443d07e233b3 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:08:48 +0100
Subject: [PATCH 072/105] fix(parser): a lazy quote line is paragraph text,
never structure
a32f42d passed lazily absorbed lines through QuoteTip.take, which still
read structure into them: a lazy `|---|` or `===` closed the quote's
paragraph as a table separator or a Setext underline, and the next lazy
line fell out of the quote. CommonMark never reads a lazy line as
structure; GitHub keeps "> a\n| x | y |\n|---|---|\n| 1 | 2 |" and
"> a\nb\n===\nc" in one quoted paragraph (gh api markdown).
QuoteTip.takeLazy keeps the paragraph open; take is for the group's own
marked lines only. A marked table followed by an unmarked row still ends
the quote, as on GitHub.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 14 ++++++++++++--
QuickMD/QuickMDTests/VerticalRhythmTests.swift | 17 +++++++++++++++++
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 7b93f8e..f9fda93 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -395,7 +395,7 @@ struct MarkdownBlockParser: Sendable {
references: referenceDefinitions)
else { break }
groupLines.append(lines[i])
- tip.take(lines[i])
+ tip.takeLazy()
i += 1
continue
}
@@ -421,7 +421,7 @@ struct MarkdownBlockParser: Sendable {
references: referenceDefinitions)
else { break }
groupLines.append(content)
- tip.take(content)
+ tip.takeLazy()
i += 1
continue
}
@@ -1004,6 +1004,16 @@ struct MarkdownBlockParser: Sendable {
private var inTable = false
private var previousHadPipe = false
+ /// A lazily continued line (no marker, or fewer markers than the
+ /// group): paragraph text whatever it looks like — CommonMark never
+ /// reads a lazy line as a table separator, a Setext underline or any
+ /// other structure, so the paragraph stays open for the next one.
+ mutating func takeLazy() {
+ endsInParagraph = true
+ previousHadPipe = false
+ }
+
+ /// A line of the group itself (marked at the group's level).
mutating func take(_ content: String) {
let marker = MarkdownBlockParser.markerLine(content)
if let open = fence {
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index df6aa2b..531b0e2 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -714,6 +714,23 @@ final class VerticalRhythmTests: XCTestCase {
["q1:| x | y |\n|---|---|\n| 1 | 2 |", "t:lazy"])
}
+ /// A lazy line is paragraph text, never structure: a lazy `|---|` or
+ /// `===` read as a table separator or a Setext underline closed the
+ /// quote's paragraph, and the NEXT lazy line fell out of the quote.
+ /// GitHub keeps every one of them in the paragraph (gfm/g5_*.html).
+ func testALazyLineIsNeverReadAsStructure() throws {
+ XCTAssertEqual(try quoteShape("> a\n| x | y |\n|---|---|\n| 1 | 2 |\n"),
+ ["q1:a\n| x | y |\n|---|---|\n| 1 | 2 |"])
+ XCTAssertEqual(try quoteShape("> a\nb\n===\nc\n"), ["q1:a\nb\n===\nc"])
+ XCTAssertEqual(try quoteShape("> > a\n> ===\n> c\n"), ["q2:a\n===\nc"])
+ XCTAssertEqual(try quoteShape("> > a\n> | x |\n> |---|\n> c\n"), ["q2:a\n| x |\n|---|\nc"])
+ // Marked lines still are structure: a marked table, then an
+ // unmarked row, ends the quote (GitHub too).
+ XCTAssertEqual(try quoteShape("> | x | y |\n> |---|---|\n> | 1 | 2 |\nlazy\n"),
+ ["q1:| x | y |\n|---|---|\n| 1 | 2 |", "t:lazy"])
+ XCTAssertEqual(try quoteShape("> a\n> ===\nb\n"), ["q1:a\n===", "t:b"])
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From 9a288872734c4e58f2a42413faa1804858af47c5 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:10:13 +0100
Subject: [PATCH 073/105] fix(headings): a formula in a heading shows its
source with its LaTeX escapes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review R14: headings typeset no inline math and show a formula's source,
and since a633405 the Markdown escape pass removed its backslashes:
"# H \% $a\,b$" showed "H % $a,b$". renderHeader now rebuilds every
$…$ segment from InlineMathSegmenter.pieces(of:) ("$" + LaTeX + "$",
backslashes back by offset); text outside formulas is unchanged. The
view, measurer, title cache, ids, Contents sidebar, search, copy and print
all read renderHeader, so they show the same text. Math rendering in
headings stays a 1.15 candidate.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownRenderer.swift | 37 +++++++++++++++++++++++-
QuickMD/QuickMDTests/RendererTests.swift | 20 +++++++++++++
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index 8c1603a..a8cee0c 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -466,12 +466,47 @@ struct MarkdownRenderer: Sendable {
// MARK: - Block Renderers
func renderHeader(_ text: String, level: Int) -> AttributedString {
- var attr = renderInlineFormatting(text)
+ var attr = Self.formulaSourceWithEscapes(renderInlineFormatting(text))
attr.setDualFont(size: headerFontSize(level: level), bold: true, fonts: theme.fonts)
attr.setDualForeground(theme.textColor)
return attr
}
+ /// Headings typeset no inline math (the title view converts with
+ /// `hasInlineMath: false`), so a `$…$` shows its source — and that source
+ /// must keep its LaTeX escapes, which the Markdown escape pass removed:
+ /// `# H \% $a\,b$` shows "H % $a\,b$" (text outside the formula loses
+ /// its backslash as everywhere). The same split as math paragraphs
+ /// (`InlineMathSegmenter.pieces(of:)`); every heading consumer — view,
+ /// measurer, title cache, ids, Contents, search, copy, print — reads
+ /// `renderHeader`, so they all see this text.
+ private static func formulaSourceWithEscapes(_ attributed: AttributedString) -> AttributedString {
+ guard attributed.characters.contains("$") else { return attributed }
+ let pieces = InlineMathSegmenter.pieces(of: attributed)
+ guard pieces.contains(where: { if case .math = $0.segment { return true } else { return false } }) else {
+ return attributed
+ }
+ var result = AttributedString()
+ var index = attributed.startIndex
+ for piece in pieces {
+ guard let end = attributed.characters.index(index, offsetBy: piece.length,
+ limitedBy: attributed.endIndex) else { break }
+ switch piece.segment {
+ case .text:
+ result.append(attributed[index.. CGFloat {
let sizes: [CGFloat] = [32, 26, 22, 18, 16, 14]
diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift
index 8dfefc3..02e4015 100644
--- a/QuickMD/QuickMDTests/RendererTests.swift
+++ b/QuickMD/QuickMDTests/RendererTests.swift
@@ -910,6 +910,26 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "a * b and c $5")
}
+ // MARK: - Formula source in headings (review R14)
+
+ /// Headings show a formula's SOURCE (no inline math there yet), and it
+ /// keeps its LaTeX escapes: `# H \% $a\,b$` shows "H % $a\,b$". The
+ /// heading view, ids, the Contents sidebar and search all read this text.
+ func testHeadingFormulaSourceKeepsItsEscapes() {
+ let title = "H \\% $a\\,b$ and $50\\%$"
+ let shown = String(renderer.renderHeader(title, level: 1).characters)
+ XCTAssertEqual(shown, "H % $a\\,b$ and $50\\%$")
+ XCTAssertEqual(String(renderer.renderHeader("Price \\$5 and \\$6", level: 2).characters), "Price $5 and $6",
+ "escaped dollars are no formula")
+ let entry = ToCEntry(id: "heading-0", level: 1, title: title, sourceLine: 0)
+ XCTAssertEqual(entry.shownTitle(renderer: renderer), shown)
+ let theme = MarkdownTheme.cached(for: .light)
+ let blocks = MarkdownBlockParser(theme: theme).parse("# " + title)
+ XCTAssertEqual(HeadingAnchors.headings(of: blocks, renderer: renderer).map(\.text), [shown])
+ XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "a\\,b").matchBlockIds.count, 1,
+ "search finds what the heading shows")
+ }
+
// MARK: - References in link destinations (review R10)
/// A destination's character references are decoded (hrefs from
From 9cd2e27a0edb52492d5257935b041e436ecb030a Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:13:02 +0100
Subject: [PATCH 074/105] fix(parser): a definition inside a fenced code block
is the block's text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pre-pass took link and footnote definitions out of the document
wherever they stood, inside fenced code too: "```\n[x]: https://…\n```"
rendered an EMPTY code block, and the definition linked `[x]` in the
rest of the document. GitHub keeps it as code (gh api markdown), with
`~~~`, a longer opening fence and an unclosed fence alike.
The pre-pass now follows the document's fenced code and `$$` blocks the
way the block loop reads them (VerbatimRegions) and leaves their lines
alone. One set of predicates — fenceOpening, closesFence,
opensMultilineMath, closesMath — serves the block loop, quote groups
and the pre-pass.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 109 +++++++++++++-----
.../QuickMDTests/VerticalRhythmTests.swift | 24 ++++
2 files changed, 106 insertions(+), 27 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index f9fda93..115f018 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -84,9 +84,17 @@ struct MarkdownBlockParser: Sendable {
// Headings carry the original line number so section copy can slice the
// raw text without re-detecting headings itself.
var lineMap: [Int] = []
+ // Fenced code and `$$` blocks, read as the block loop reads them: a
+ // definition inside one is that block's text (GitHub).
+ var verbatim = VerbatimRegions()
for originalIndex in firstContentLine.. Fence? {
+ guard let first = marker.first, first == "`" || first == "~" else { return nil }
+ let length = marker.prefix(while: { $0 == first }).count
+ return length >= 3 ? (first, length) : nil
+ }
+
+ /// Whether `marker` closes `fence`: the same character, at least as many,
+ /// and nothing after them but spaces and tabs.
+ static func closesFence(_ marker: String, _ fence: Fence) -> Bool {
+ marker.hasPrefix(String(repeating: fence.marker, count: fence.length))
+ && MarkdownRenderer.isBlankLine(marker.drop(while: { $0 == fence.marker }))
+ }
+
+ /// Whether `marker` opens a `$$` block that runs to a `$$` line — not a
+ /// one-line `$$…$$` formula.
+ static func opensMultilineMath(_ marker: String) -> Bool {
+ marker.hasPrefix("$$")
+ && !String(marker.dropFirst(2)).trimmingCharacters(in: .whitespaces).hasSuffix("$$")
+ }
+
+ /// Whether `marker` closes a `$$` block.
+ static func closesMath(_ marker: String) -> Bool { marker == "$$" }
+
+ /// The fenced code and `$$` blocks of a document, followed line by line
+ /// the way the block loop reads them (fence first, then `$$`; inside one,
+ /// only its closing line matters). The definition pre-pass leaves their
+ /// lines alone: a definition inside one is that block's text.
+ struct VerbatimRegions {
+ private var fence: Fence?
+ private var inMath = false
+
+ /// Whether the line (its `markerLine`) opens, continues or closes
+ /// such a block.
+ mutating func contains(_ marker: String) -> Bool {
+ if let open = fence {
+ if MarkdownBlockParser.closesFence(marker, open) { fence = nil }
+ return true
+ }
+ if inMath {
+ if MarkdownBlockParser.closesMath(marker) { inMath = false }
+ return true
+ }
+ if let opening = MarkdownBlockParser.fenceOpening(marker) {
+ fence = opening
+ return true
+ }
+ if marker.hasPrefix("$$") {
+ inMath = MarkdownBlockParser.opensMultilineMath(marker)
+ return true
+ }
+ return false
+ }
+ }
+
// MARK: - Quote laziness
/// What a quote group's lines end in, as CommonMark reads them: an OPEN
@@ -1000,7 +1061,7 @@ struct MarkdownBlockParser: Sendable {
/// underline, a rule and a table.
struct QuoteTip {
private(set) var endsInParagraph = false
- private var fence: (marker: Character, length: Int)?
+ private var fence: Fence?
private var inTable = false
private var previousHadPipe = false
@@ -1018,10 +1079,7 @@ struct MarkdownBlockParser: Sendable {
let marker = MarkdownBlockParser.markerLine(content)
if let open = fence {
// Inside the quote's fence: only its closing line matters.
- if marker.hasPrefix(String(repeating: open.marker, count: open.length)),
- MarkdownRenderer.isBlankLine(marker.drop(while: { $0 == open.marker })) {
- fence = nil
- }
+ if MarkdownBlockParser.closesFence(marker, open) { fence = nil }
endsInParagraph = false
return
}
@@ -1031,14 +1089,11 @@ struct MarkdownBlockParser: Sendable {
inTable = false
return
}
- if let first = marker.first, first == "`" || first == "~" {
- let length = marker.prefix(while: { $0 == first }).count
- if length >= 3 {
- fence = (first, length)
- endsInParagraph = false
- inTable = false
- return
- }
+ if let opening = MarkdownBlockParser.fenceOpening(marker) {
+ fence = opening
+ endsInParagraph = false
+ inTable = false
+ return
}
if inTable, content.contains("|") {
endsInParagraph = false
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 531b0e2..8763c41 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -731,6 +731,30 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(try quoteShape("> a\n> ===\nb\n"), ["q1:a\n===", "t:b"])
}
+ // MARK: - Link and footnote definitions
+
+ /// A definition inside a fenced code block is the block's text: the
+ /// pre-pass took it out wherever it stood, so the code block came out
+ /// EMPTY and the definition linked `[x]` in the rest of the document.
+ /// GitHub (gfm/def_fence*.html, def_fn_fence.html) keeps it as code —
+ /// `~~~`, a longer opening fence and an unclosed one alike. A `$$` block
+ /// is read the way the block loop reads it.
+ func testADefinitionInsideAFencedBlockIsCode() throws {
+ XCTAssertEqual(try quoteShape("```\n[x]: https://example.com\n```\n\n[x]\n"),
+ ["c:[x]: https://example.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("~~~~\n[x]: https://example.com\n~~~\nstill code\n~~~~\n\n[x]\n"),
+ ["c:[x]: https://example.com\n~~~\nstill code", "t:[x]"])
+ XCTAssertEqual(try quoteShape("```\n[x]: https://example.com\n\n[x]"), // unclosed: to the end
+ ["c:[x]: https://example.com\n\n[x]"])
+ XCTAssertEqual(try quoteShape("Text[^1]\n\n```\n[^1]: in code\n```\n"),
+ ["t:Text[^1]", "c:[^1]: in code"])
+ // A fence inside a `$$` block is the formula's; the definition after
+ // the block is one.
+ XCTAssertEqual(try quoteShape("$$\n```\n$$\n\n[x]: http://e.com\n\n[x]\n"), ["other", "t:x"])
+ // Outside the fence it still is one.
+ XCTAssertEqual(try quoteShape("```\ncode\n```\n\n[x]: http://e.com\n\n[x]\n"), ["c:code", "t:x"])
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From 24a23d9d788b9293a96552cc93766489b79123f7 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:14:50 +0100
Subject: [PATCH 075/105] fix(parser): a link definition cannot interrupt a
paragraph
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pre-pass took a link definition out wherever it stood. CommonMark
(and GitHub, gh api markdown) do not let one interrupt a paragraph: on a
line that would continue a paragraph — plain text, a list item's, a
table's rows, a quote's lazy line — it is that text, and `[x]` stays
literal. "> a\n[x]: http://e.com" is one quoted paragraph on GitHub.
DefinitionPlacement follows, line by line, whether a paragraph would go
on (a quote's through QuoteTip, its lazy lines included); a link
definition is taken out only where none would. Footnote definitions
may interrupt a paragraph on GitHub and are taken as before (outside
fenced code). The set of definitions only shrinks.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 92 +++++++++++++++++--
.../QuickMDTests/VerticalRhythmTests.swift | 24 +++++
2 files changed, 109 insertions(+), 7 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 115f018..5cd127a 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -87,12 +87,15 @@ struct MarkdownBlockParser: Sendable {
// Fenced code and `$$` blocks, read as the block loop reads them: a
// definition inside one is that block's text (GitHub).
var verbatim = VerbatimRegions()
+ // A link definition cannot interrupt a paragraph (`DefinitionPlacement`).
+ var placement = DefinitionPlacement()
for originalIndex in firstContentLine.. Bool {
- guard !Self.isBlank(line), !htmlImageLine else { return false }
- let marker = Self.markerLine(line)
- if marker.hasPrefix("```") || marker.hasPrefix("~~~") || marker.hasPrefix("$$") { return false }
+ guard !htmlImageLine, Self.startsNoBlock(line) else { return false }
+ if InlineSyntaxScanner.standaloneImages(in: line[...], references: references) != nil { return false }
+ return true
+ }
+
+ /// CommonMark's part of `continuesQuoteLazily`: not blank, and no block
+ /// starts on the line (fence, `$$`, ATX heading, rule, list item, HTML
+ /// block) — checked against the document, as cmark does.
+ static func startsNoBlock(_ line: String) -> Bool {
+ guard !isBlank(line) else { return false }
+ let marker = markerLine(line)
+ if fenceOpening(marker) != nil || marker.hasPrefix("$$") { return false }
let nsRange = NSRange(line.startIndex..., in: line)
- if Self.headerRegex.firstMatch(in: line, range: nsRange) != nil { return false }
+ if headerRegex.firstMatch(in: line, range: nsRange) != nil { return false }
if MarkdownRenderer.isThematicBreak(line) || MarkdownRenderer.isListItem(line) { return false }
if ["-", "*", "+"].contains(marker) { return false } // an empty item
- if Self.startsHTMLBlock(marker) { return false }
- if InlineSyntaxScanner.standaloneImages(in: line[...], references: references) != nil { return false }
+ if startsHTMLBlock(marker) { return false }
return true
}
+ /// Whether the definition pre-pass may take a LINK definition out on the
+ /// next line: not where it would continue a paragraph. CommonMark: a
+ /// link definition cannot interrupt a paragraph; GitHub keeps such a
+ /// line as the paragraph's text — a list item's, a table's (its rows run
+ /// on) and a quote's lazy line included. (A footnote definition may
+ /// interrupt one, GitHub: the pre-pass takes those wherever they stand
+ /// outside fenced code.)
+ struct DefinitionPlacement {
+ private(set) var paragraphOpen = false
+ /// The quote being read: what its lines end in (`QuoteTip`).
+ private var quote: QuoteTip?
+
+ /// A line of a fenced code or `$$` block (`VerbatimRegions`).
+ mutating func verbatim() {
+ paragraphOpen = false
+ quote = nil
+ }
+
+ /// A definition the pre-pass took out: it ends what came before.
+ mutating func definition() {
+ paragraphOpen = false
+ quote = nil
+ }
+
+ /// Any other line, kept for the block loop.
+ mutating func kept(_ line: String) {
+ if MarkdownRenderer.isBlankLine(line) {
+ paragraphOpen = false
+ quote = nil
+ return
+ }
+ let marker = MarkdownBlockParser.markerLine(line)
+ if marker.hasPrefix(">") {
+ var content = marker[...]
+ while content.hasPrefix(">") {
+ content = content.dropFirst()
+ if content.hasPrefix(" ") { content = content.dropFirst() }
+ }
+ var tip = quote ?? QuoteTip()
+ tip.take(String(content))
+ quote = tip
+ paragraphOpen = tip.endsInParagraph
+ return
+ }
+ if var tip = quote, tip.endsInParagraph, MarkdownBlockParser.startsNoBlock(line) {
+ tip.takeLazy()
+ quote = tip
+ paragraphOpen = true
+ return
+ }
+ quote = nil
+ let nsRange = NSRange(line.startIndex..., in: line)
+ if MarkdownBlockParser.headerRegex.firstMatch(in: line, range: nsRange) != nil
+ || MarkdownRenderer.isThematicBreak(line)
+ || (paragraphOpen && marker.range(of: MarkdownTheme.setextH1Pattern,
+ options: .regularExpression) != nil) {
+ paragraphOpen = false
+ return
+ }
+ paragraphOpen = true
+ }
+ }
+
/// CommonMark's HTML block starts (kinds 1-7), as far as a lazy line needs
/// them: ``, `text` is inline HTML.
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 8763c41..e79136c 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -755,6 +755,30 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(try quoteShape("```\ncode\n```\n\n[x]: http://e.com\n\n[x]\n"), ["c:code", "t:x"])
}
+ /// A link definition cannot interrupt a paragraph (CommonMark): on a
+ /// line that would continue one — plain text, a list item's, a quote's
+ /// lazy line, a table's — it is that text, and `[x]` stays literal.
+ /// The pre-pass took it out anywhere. A footnote definition MAY
+ /// interrupt a paragraph (GitHub). Every shape: gfm/def_*.html, fn_*.html.
+ func testALinkDefinitionCannotInterruptAParagraph() throws {
+ XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("- a\n[x]: http://e.com\n\n[x]\n"), ["t:• a [x]: http://e.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("| a |\n|---|\n| 1 |\n[x]: http://e.com\n\n[x]\n").last, "t:[x]")
+ // Where no paragraph goes on, it is a definition.
+ XCTAssertEqual(try quoteShape("Para\n\n[x]: http://e.com\n\n[x]\n"), ["t:Para", "t:x"])
+ XCTAssertEqual(try quoteShape("# H\n[x]: http://e.com\n\n[x]\n"), ["h:H", "t:x"])
+ XCTAssertEqual(try quoteShape("---\n[x]: http://e.com\n\n[x]\n").last, "t:x")
+ XCTAssertEqual(try quoteShape("Title\n===\n[x]: http://e.com\n\n[x]\n"), ["h:Title", "t:x"])
+ XCTAssertEqual(try quoteShape("```\ncode\n```\n[x]: http://e.com\n\n[x]\n"), ["c:code", "t:x"])
+ XCTAssertEqual(try quoteShape("[x]: http://e.com\n[y]: http://f.com\n\n[x] [y]\n"), ["t:x y"])
+ XCTAssertEqual(try quoteShape("> a\n>\n[x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"])
+ XCTAssertEqual(try quoteShape("> ```\n> code\n[x]: http://e.com\n\n[x]\n"), ["q1:```\ncode", "t:x"])
+ // Footnote definitions interrupt a paragraph, as before.
+ XCTAssertEqual(try quoteShape("Text[^1]\n\nPara\n[^1]: note\n").prefix(2), ["t:Text¹", "t:Para"])
+ XCTAssertEqual(try quoteShape("Text[^1]\n\n> a\n[^1]: note\n").prefix(2), ["t:Text¹", "q1:a"])
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From 82c34c71afa68536a555817c72f4c5b56c38d2db Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:17:42 +0100
Subject: [PATCH 076/105] fix(parser): an indented fence drops its indentation
from its lines
CommonMark: a code fence indented N columns drops up to N columns of
indentation from each of its lines. The parser kept them, so every fenced
block of a numbered-steps list ("1. Step\n\n ```bash\n cmd") was
drawn, searched, copied and printed shifted right (" cmd"); GitHub
shows "cmd" (gh api markdown). The block's content is the stripped text
now, so the view, search, copy and print all read it; the Source Edit
tint goes by lines and is unchanged.
Source Edit's selection carry-over matched the rendered selection in the
source literally; a multi-line selection in such a block reads "a\nb"
where the source has "a\n b". It now falls back to a match that skips
the spaces and tabs at the start of each source line after the first.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 45 ++++++++++++++++++-
QuickMD/QuickMD/SourceEditSupport.swift | 16 ++++++-
.../QuickMDTests/VerticalRhythmTests.swift | 34 ++++++++++++++
3 files changed, 93 insertions(+), 2 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 5cd127a..75e0e0d 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -161,6 +161,10 @@ struct MarkdownBlockParser: Sendable {
if let opening = Self.fenceOpening(trimmedLine) {
flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows)
let fenceLength = opening.length
+ // CommonMark: a fence indented N columns drops up to N columns
+ // of indentation from each of its lines (a fence under a list
+ // item is not drawn shifted right).
+ let fenceIndent = Self.indentColumns(line)
// Extract language after fence
let language = String(trimmedLine.dropFirst(fenceLength)).trimmingCharacters(in: .whitespaces)
@@ -173,7 +177,7 @@ struct MarkdownBlockParser: Sendable {
i += 1
break
}
- codeLines.append(lines[i])
+ codeLines.append(Self.removingIndentation(lines[i], upTo: fenceIndent))
i += 1
}
@@ -999,6 +1003,45 @@ struct MarkdownBlockParser: Sendable {
// MARK: - Fences and `$$` blocks
+ /// Columns of the spaces and tabs a line starts with (a tab runs to the
+ /// next multiple of 4, CommonMark).
+ static func indentColumns(_ line: String) -> Int {
+ var columns = 0
+ for character in line {
+ if character == " " {
+ columns += 1
+ } else if character == "\t" {
+ columns += 4 - columns % 4
+ } else {
+ break
+ }
+ }
+ return columns
+ }
+
+ /// `line` without up to `columns` columns of its leading spaces and tabs
+ /// (a tab that would reach past them stays).
+ static func removingIndentation(_ line: String, upTo columns: Int) -> String {
+ guard columns > 0 else { return line }
+ var used = 0
+ var index = line.startIndex
+ while index < line.endIndex {
+ let character = line[index]
+ let width: Int
+ if character == " " {
+ width = 1
+ } else if character == "\t" {
+ width = 4 - used % 4
+ } else {
+ break
+ }
+ guard used + width <= columns else { break }
+ used += width
+ index = line.index(after: index)
+ }
+ return String(line[index...])
+ }
+
/// A code fence's marker character and length.
typealias Fence = (marker: Character, length: Int)
diff --git a/QuickMD/QuickMD/SourceEditSupport.swift b/QuickMD/QuickMD/SourceEditSupport.swift
index 5c1bf0c..5389084 100644
--- a/QuickMD/QuickMD/SourceEditSupport.swift
+++ b/QuickMD/QuickMD/SourceEditSupport.swift
@@ -192,7 +192,21 @@ enum SourceEditSupport {
if wantBefore.isEmpty && wantAfter.isEmpty { break }
searchFrom = found.location + 1
}
- return best
+ return best ?? indentedMatch(of: needle, in: text, scope: scope)
+ }
+
+ /// A multi-line selection that is not in the source literally, found
+ /// with the spaces and tabs at the start of each source line after the
+ /// first skipped: the renderer drops a fence's indentation from its
+ /// lines (CommonMark), so a selection inside a fenced block under a list
+ /// item reads "a\nb" where the source has "a\n b". The first match.
+ private static func indentedMatch(of needle: String, in text: NSString, scope: NSRange) -> NSRange? {
+ guard needle.contains("\n") else { return nil }
+ let pattern = needle.components(separatedBy: "\n")
+ .map { NSRegularExpression.escapedPattern(for: $0) }
+ .joined(separator: "\n[ \t]*")
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
+ return regex.firstMatch(in: text as String, range: scope)?.range
}
static func sourceRange(ofSelection selectedText: String, before: String, after: String,
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index e79136c..c65e0df 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -779,6 +779,40 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(try quoteShape("Text[^1]\n\n> a\n[^1]: note\n").prefix(2), ["t:Text¹", "q1:a"])
}
+ // MARK: - Indented fences
+
+ /// A fence indented N spaces drops up to N spaces from each of its lines
+ /// (CommonMark; GitHub, gfm/b1_*.html). It kept them: every code block
+ /// of a numbered-steps list was drawn, searched and copied shifted right.
+ func testAnIndentedFenceDropsItsIndentationFromItsLines() throws {
+ XCTAssertEqual(try quoteShape("1. Step\n\n ```bash\n cmd\n nested\n ```\n"),
+ ["t:1. Step", "c:cmd\n nested"])
+ XCTAssertEqual(try quoteShape(" ```\n code\n code2\nflush\n ```\n"), ["c:code\n code2\nflush"])
+ XCTAssertEqual(try quoteShape("1. Step\n ```\n cmd\n ```\n"), ["t:1. Step", "c:cmd"])
+ XCTAssertEqual(try quoteShape("```\n keep\n```\n"), ["c: keep"])
+ XCTAssertEqual(try quoteShape(" ~~~\n a\n\n b\n ~~~\n"), ["c:a\n\nb"])
+ // Search counts, and copy reads, what is drawn.
+ let blocks = try XCTUnwrap(parseReturning("1. Step\n\n ```\n let needle = 1\n ```\n"))
+ XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: " let").matchBlockIds, [])
+ XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "let needle").matchBlockIds, [blocks[1].id])
+ }
+
+ /// Source Edit finds a selection made in such a block in the source,
+ /// whose lines still carry the indentation: a multi-line selection is
+ /// matched with the spaces and tabs at the start of each source line
+ /// skipped (a literal match finds it no longer).
+ func testASelectionInAnIndentedFenceIsFoundInTheSource() {
+ let source = "1. Step\n\n ```\n cmd\n nested\n ```\n" as NSString
+ let range = SourceEditSupport.sourceRange(ofSelection: "cmd\n nested", before: "", after: "",
+ in: source, lines: 3..<5)
+ XCTAssertEqual(range.map { source.substring(with: $0) }, "cmd\n nested")
+ // A literal match wins where there is one.
+ let flat = "```\ncmd\n nested\n```\n" as NSString
+ let literal = SourceEditSupport.sourceRange(ofSelection: "cmd\n nested", before: "", after: "",
+ in: flat, lines: 1..<3)
+ XCTAssertEqual(literal.map { flat.substring(with: $0) }, "cmd\n nested")
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From e29a51cf4acfb9fb9f6273d9ee7c141584f59147 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:19:17 +0100
Subject: [PATCH 077/105] perf(links): plain link text skips the inline pass
Review R15: since fef7f52 every link's text ran a full
renderInlineFormatting (+25 % parse time on a link-heavy document).
renderLinkText takes the parsed path only when the text holds a
character an inline parser starts on (\ ` * _ ~ ! [ or an http(s)://
autolink); otherwise it builds the one plain run that parse produces
(decoded references, the renderer's font). A test asserts the two paths
are identical for plain texts at two zoom levels, and the
ReferenceLinks equivalence test is unchanged.
5,000 paragraphs with one plain link each, Debug test build, median of
7 parses: 970 ms before, 817 ms after.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownRenderer.swift | 41 ++++++++++++++++++++++--
QuickMD/QuickMDTests/RendererTests.swift | 29 +++++++++++++++++
2 files changed, 67 insertions(+), 3 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index a8cee0c..53450d2 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -1025,9 +1025,7 @@ struct MarkdownRenderer: Sendable {
// one, an autolink's or an image's). Rendered with links disabled: a
// link inside a link is literal text (bounds the recursion).
// `ReferenceLinks` rewrites a link's text by the same rule.
- var linkText = self
- linkText.linksDisabled = true
- var attr = linkText.renderInlineFormatting(text)
+ var attr = renderLinkText(text)
attr.setDualForeground(theme.linkColor)
attr.setDualUnderline()
@@ -1037,6 +1035,43 @@ struct MarkdownRenderer: Sendable {
return (attr, remaining)
}
+ /// A link's text before the link colour, underline and URL go on:
+ /// inline-parsed with links disabled — or, when `parse` is false, the one
+ /// plain run that parse produces for text with no syntax in it (the
+ /// renderer's font, the colour is replaced by the caller). `parse: nil`
+ /// picks the plain run exactly when `linkTextNeedsParsing` is false, which
+ /// keeps the common `[Install](…)` from a full inline pass (+25 % on a
+ /// link-heavy document, review R15). Tests compare both paths.
+ func renderLinkText(_ text: String, parse: Bool? = nil) -> AttributedString {
+ if parse ?? Self.linkTextNeedsParsing(text) {
+ var linkText = self
+ linkText.linksDisabled = true
+ return linkText.renderInlineFormatting(text)
+ }
+ var attr = AttributedString(decodedText(text))
+ attr.setDualFont(size: scaled(14), fonts: theme.fonts)
+ attr.setDualForeground(theme.textColor)
+ return attr
+ }
+
+ /// Whether `renderInlineFormatting` could do anything but one plain run:
+ /// a character some inline parser starts on — `\` escape, `` ` `` code,
+ /// `*` / `_` emphasis, `~` strikethrough, `!` image, `[` footnote or
+ /// link, `h` an autolink (`http://`, `https://`). Character references
+ /// (`&`) are decoded on the plain run too; `$` is only looked at later
+ /// (inline math, on the rendered text), as on the parsed path.
+ static func linkTextNeedsParsing(_ text: String) -> Bool {
+ text.utf8.contains { byte in
+ switch byte {
+ case UInt8(ascii: "\\"), UInt8(ascii: "`"), UInt8(ascii: "*"), UInt8(ascii: "_"),
+ UInt8(ascii: "~"), UInt8(ascii: "!"), UInt8(ascii: "["):
+ return true
+ default:
+ return false
+ }
+ } || text.contains("http://") || text.contains("https://")
+ }
+
/// The URL of a link destination as written.
///
/// `URL(string:)` first, exactly as before 1.14.4: on macOS 14+ it encodes
diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift
index 02e4015..d7827e3 100644
--- a/QuickMD/QuickMDTests/RendererTests.swift
+++ b/QuickMD/QuickMDTests/RendererTests.swift
@@ -910,6 +910,35 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "a * b and c $5")
}
+ // MARK: - Plain link text fast path (review R15)
+
+ /// Link text with no inline syntax skips the inline pass; the run it gets
+ /// must be exactly the parsed one (characters and every attribute), at any
+ /// zoom, and text with syntax must take the parsed path.
+ func testPlainLinkTextFastPathEqualsTheParsedPath() {
+ let plain = ["Install", "Read the guide", "Zażółć gęślą", "a & b", "AT&T ©", "50% off $5",
+ "x < y > z", "e-mail: a@b.c", "1. not a list", "# not a heading", "", " spaced ", "emoji 🚀"]
+ for scale in [1.0, 1.5] as [CGFloat] {
+ let renderer = MarkdownRenderer(theme: MarkdownTheme.cached(for: .dark), fontScale: scale)
+ for text in plain {
+ XCTAssertFalse(MarkdownRenderer.linkTextNeedsParsing(text), text)
+ XCTAssertEqual(renderer.renderLinkText(text, parse: false), renderer.renderLinkText(text, parse: true),
+ "\(text) @\(scale)")
+ let link = renderer.renderInline("[\(text)](https://x.example)")
+ XCTAssertEqual(link, {
+ var parsed = renderer.renderLinkText(text, parse: true)
+ parsed.setDualForeground(renderer.theme.linkColor)
+ parsed.setDualUnderline()
+ parsed.link = URL(string: "https://x.example")
+ return parsed
+ }(), text)
+ }
+ }
+ for text in ["**b**", "`c`", "a_b", "~~s~~", "", "[^1]", "a \\* b", "see https://x.y", "http://a"] {
+ XCTAssertTrue(MarkdownRenderer.linkTextNeedsParsing(text), text)
+ }
+ }
+
// MARK: - Formula source in headings (review R14)
/// Headings show a formula's SOURCE (no inline math there yet), and it
From cbd0d7c5172c42b53b8563baf8338a71228ea3db Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:20:37 +0100
Subject: [PATCH 078/105] fix(parser): a Setext underline makes the whole
paragraph above it the heading
CommonMark (and GitHub, gh api markdown): the heading is the paragraph
the underline ends, its lines joined like soft breaks, anchored on the
paragraph's first line. Only the last line was the heading here; the
lines above it stayed a paragraph ("Para one\nline two\n===" drew
"Para one" as text and "line two" as the heading).
- The block loop takes the paragraph out of the text buffer (after its
last blank line or rule) as the heading's title; its source line is
the paragraph's first, so Section copy, the ToC and the anchor
(para-one-line-two) see the whole heading.
- A list item's paragraph is no heading: "- a\nb\n===" is the item's
text, "- a\nb\n---" the list and a rule (setextParagraphStart, by the
rule rows are cut by). A quote's lazy lines stay the quote's (F6).
- The Source Edit tint finds a Setext heading's underline from its first
line on, so it spans every line of the title.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 69 ++++++++++++-------
QuickMD/QuickMD/SourceTint.swift | 38 ++++++----
.../QuickMDTests/VerticalRhythmTests.swift | 46 +++++++++++++
3 files changed, 117 insertions(+), 36 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 75e0e0d..4136156 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -294,33 +294,37 @@ struct MarkdownBlockParser: Sendable {
continue
}
- // Setext headers (underlined headers). The title is paragraph
- // text: a quote line or a list item never is one (GitHub: `> a`
- // or `- a` over `---` is the quote or list, then a rule; over
- // `===`, the quote's or item's text goes on).
+ // Setext headers (underlined headers): the WHOLE paragraph above
+ // the underline is the heading (CommonMark, GitHub), on the
+ // paragraph's first line, its lines joined like soft breaks. Not a
+ // list item's paragraph (`- a` over `===` is the item's text, over
+ // `---` the list and a rule) nor a quote line's (the quote branch
+ // takes those, lazy lines included).
+ //
+ // NOTE: no `!isTableSeparator(nextLine)` guard — a line of pure
+ // dashes ALWAYS matches the table-separator pattern (pipes are
+ // optional in it), which once made setext H2 unreachable. A
+ // pipe-bearing separator can't match setextH2Pattern anyway.
+ // (Caught by ParserTests.testSetextHeadings.)
let trimmed = trimmedLine
- if i + 1 < lines.count && !trimmed.isEmpty && !trimmed.contains("|")
- && !trimmed.hasPrefix(">") && !MarkdownRenderer.isListItem(line) {
+ if i + 1 < lines.count && !trimmed.isEmpty && !trimmed.contains("|") && !trimmed.hasPrefix(">") {
let nextLine = Self.markerLine(lines[i + 1])
-
+ let level: Int?
if nextLine.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil {
- flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows)
- // Setext: the TEXT line owns the heading, not the `===` underline.
- blocks.append(.heading(index: blockIndex, level: 1, title: trimmed, sourceLine: sourceLine))
- blockIndex += 1
- i += 2
- continue
+ level = 1
+ } else if nextLine.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil {
+ level = 2
+ } else {
+ level = nil
}
-
- // NOTE: no `!isTableSeparator(nextLine)` guard here — a line of
- // pure dashes ALWAYS matches the table-separator pattern (pipes
- // are optional in it), which made this branch unreachable and
- // silently disabled setext H2 for years. A pipe-bearing table
- // separator can't match setextH2Pattern anyway, so the guard
- // added nothing. (Caught by ParserTests.testSetextHeadings.)
- if nextLine.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil {
- flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, pending: &pendingRows)
- blocks.append(.heading(index: blockIndex, level: 2, title: trimmed, sourceLine: sourceLine))
+ if let level, let start = Self.setextParagraphStart(in: textBuffer, endingWith: line) {
+ let title = (textBuffer[start...].map { Self.markerLine($0.line) } + [trimmed])
+ .joined(separator: " ")
+ let headingLine = start < textBuffer.count ? textBuffer[start].originalIndex : sourceLine
+ var preceding = Array(textBuffer[.. Int? {
+ var start = buffer.count
+ while start > 0, !isBlank(buffer[start - 1].line), !MarkdownRenderer.isThematicBreak(buffer[start - 1].line) {
+ start -= 1
+ }
+ let paragraph = buffer[start...].map(\.line) + [line]
+ for (offset, text) in paragraph.enumerated() where MarkdownRenderer.isListItem(text) {
+ if offset == 0 || MarkdownRenderer.canInterruptParagraph(text) { return nil }
+ }
+ return start
+ }
+
// MARK: - Fences and `$$` blocks
/// Columns of the spaces and tabs a line starts with (a tab runs to the
diff --git a/QuickMD/QuickMD/SourceTint.swift b/QuickMD/QuickMD/SourceTint.swift
index 1b9f2cd..9505e5e 100644
--- a/QuickMD/QuickMD/SourceTint.swift
+++ b/QuickMD/QuickMD/SourceTint.swift
@@ -94,12 +94,9 @@ enum SourceTint {
let boundary = blocks[(index + 1)...].first(where: { $0.sourceLine > first })?.sourceLine ?? lineCount
let limit = min(boundary, lineCount) - 1
var last: Int
- if case .heading(_, let title, _) = block.content {
- last = first
- if first + 1 <= limit, isSetext(line: first, title: title, in: text, contentEnd: contentEnd(first),
- lineStarts: lineStarts) {
- last = first + 1
- }
+ if case .heading = block.content {
+ last = setextUnderline(from: first, through: limit, in: text, lineStarts: lineStarts,
+ contentEnd: contentEnd) ?? first
} else {
last = limit
while last > first, isBlank(last) { last -= 1 }
@@ -134,12 +131,27 @@ enum SourceTint {
}
}
- /// Whether the heading on `line` is a Setext one: its line, trimmed the
- /// way the parser trims it, IS the parser's title.
- private static func isSetext(line: Int, title: String, in text: NSString, contentEnd: Int,
- lineStarts: [Int]) -> Bool {
- let start = lineStarts[line]
- let content = text.substring(with: NSRange(location: start, length: max(0, contentEnd - start)))
- return MarkdownBlockParser.markerLine(content) == title
+ /// The underline of the heading starting on `first` when it is a Setext
+ /// one — its title may run over several lines (the whole paragraph
+ /// above the underline, CommonMark) — or nil for an ATX heading, whose
+ /// one line is all of it. The first underline line before `limit`.
+ private static func setextUnderline(from first: Int, through limit: Int, in text: NSString,
+ lineStarts: [Int], contentEnd: (Int) -> Int) -> Int? {
+ func line(_ index: Int) -> String {
+ let start = lineStarts[index]
+ return text.substring(with: NSRange(location: start, length: max(0, contentEnd(index) - start)))
+ }
+ let opening = line(first)
+ guard MarkdownBlockParser.headerRegex.firstMatch(in: opening, range: NSRange(opening.startIndex...,
+ in: opening)) == nil,
+ first < limit else { return nil }
+ for index in (first + 1)...limit {
+ let marker = MarkdownBlockParser.markerLine(line(index))
+ if marker.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil
+ || marker.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil {
+ return index
+ }
+ }
+ return nil
}
}
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index c65e0df..abdb95a 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -813,6 +813,52 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(literal.map { flat.substring(with: $0) }, "cmd\n nested")
}
+ // MARK: - Setext headings over a paragraph
+
+ /// A Setext underline makes the WHOLE paragraph above it the heading
+ /// (CommonMark; GitHub, gfm/g4_*.html), its lines joined like soft
+ /// breaks, on the paragraph's first line. Only the last line was the
+ /// heading; the lines above stayed a paragraph. A paragraph that is a
+ /// list item's is no heading (its `===` goes on as text, its `---` is a
+ /// rule), and a quote's lazy lines are the quote's (F6).
+ func testASetextUnderlineMakesTheWholeParagraphTheHeading() throws {
+ XCTAssertEqual(try quoteShape("Para one\nline two\n===\n"), ["h:Para one line two"])
+ XCTAssertEqual(try quoteShape("one\ntwo\nthree\n---\n"), ["h:one two three"])
+ let markdown = "Intro\n\nPara one\nline two\n---\n\nAfter\n"
+ let blocks = try XCTUnwrap(parseReturning(markdown))
+ XCTAssertEqual(try quoteShape(markdown), ["t:Intro", "h:Para one line two", "t:After"])
+ XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 6])
+ if case .heading(let level, _, let line) = blocks[1].content {
+ XCTAssertEqual(level, 2)
+ XCTAssertEqual(line, 2)
+ }
+ // After blocks that close a paragraph.
+ XCTAssertEqual(try quoteShape("# H\nline a\nline b\n===\n"), ["h:H", "h:line a line b"])
+ XCTAssertEqual(try quoteShape("```\nx\n```\nline a\nline b\n===\n"), ["c:x", "h:line a line b"])
+ XCTAssertEqual(try quoteShape("***\nline a\nline b\n===\n").last, "h:line a line b")
+ // A list item's paragraph is no heading.
+ XCTAssertEqual(try quoteShape("- a\nb\n===\n"), ["t:• a b ==="])
+ XCTAssertEqual(try quoteShape("- a\nb\n---\n").first, "t:• a b")
+ XCTAssertEqual(try quoteShape("- a\nb\n---\n").count, 2)
+ XCTAssertEqual(try quoteShape("Para\n- item\nb\n===\n"), ["t:Para", "t:• item b ==="])
+ XCTAssertEqual(try quoteShape("1984. A year\nline\n===\n"), ["t:1984. A year line ==="])
+ XCTAssertEqual(try quoteShape("> a\nb\n===\n"), ["q1:a\nb\n==="])
+
+ // The tint spans the paragraph and its underline; Section copy
+ // starts at its first line; the anchor is the whole title's.
+ let ns = markdown as NSString
+ let spans = SourceTint.spans(for: blocks, lineStarts: SourceEditSupport.lineStarts(in: ns), in: ns)
+ XCTAssertEqual(spans.filter { $0.kind == .heading }.map { ns.substring(with: $0.range) },
+ ["Para one\nline two\n---"])
+ let entries = blocks.compactMap { block -> ToCEntry? in
+ guard case .heading(let level, let title, let line) = block.content else { return nil }
+ return ToCEntry(id: block.id, level: level, title: title, sourceLine: line)
+ }
+ XCTAssertEqual(SectionExtractor.extractSection(from: markdown, entry: entries[0], headings: entries),
+ "Para one\nline two\n---\n\nAfter")
+ XCTAssertEqual(HeadingAnchors.slug(entries[0].title), "para-one-line-two")
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From 4c0afbfb01528f6493c9896edf58a55558ec66e8 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:25:02 +0100
Subject: [PATCH 079/105] fix(links): code spans outrank brackets
Review R16: the bracket pair table and the lookup-less bracket scan
paired a ] inside a code span, so [a `](` b](u) was no link (GitHub: a
link whose text holds the code "](") and [x `]` y] lost its brackets.
Both now skip what MarkdownRenderer.tryParseInlineCode draws as code,
with its exact rules (1-2 backtick runs close on the first later run of
that size, a 2-run closer followed by a third backtick opens nothing,
3+ runs are literal), remembering a failed closer search per run length
so unclosed backticks stay linear. Parens are still paired inside code
(a destination is not code). Links, reference rewrites and image lines
share the scanner.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/InlineSyntaxScanner.swift | 51 +++++++++++++++++++++++
QuickMD/QuickMDTests/RendererTests.swift | 32 ++++++++++++++
2 files changed, 83 insertions(+)
diff --git a/QuickMD/QuickMD/InlineSyntaxScanner.swift b/QuickMD/QuickMD/InlineSyntaxScanner.swift
index 6907ede..3638063 100644
--- a/QuickMD/QuickMD/InlineSyntaxScanner.swift
+++ b/QuickMD/QuickMD/InlineSyntaxScanner.swift
@@ -58,9 +58,26 @@ enum InlineSyntaxScanner {
InlineSyntaxScanner.withBytes(text[...]) { bytes in
var brackets: [Int: Int] = [:], parens: [Int: Int] = [:]
var openBrackets: [Int] = [], openParens: [Int] = []
+ var codeSpans = CodeSpanScanner()
var i = 0
while i < bytes.count {
if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
+ // Code spans outrank brackets: a `]` or `[` inside one pairs
+ // with nothing. Parens are not skipped — a destination is
+ // not code — so only brackets look at the span.
+ if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) {
+ var j = i
+ while j < end {
+ switch bytes[j] {
+ case UInt8(ascii: "("): openParens.append(j)
+ case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = j }
+ default: break
+ }
+ j += 1
+ }
+ i = end
+ continue
+ }
switch bytes[i] {
case UInt8(ascii: "["): openBrackets.append(i)
case UInt8(ascii: "]"): if let open = openBrackets.popLast() { brackets[open] = i }
@@ -238,6 +255,38 @@ enum InlineSyntaxScanner {
}
private static let backslash = UInt8(ascii: "\\")
+ private static let backtick = UInt8(ascii: "`")
+
+ /// Where `MarkdownRenderer.tryParseInlineCode` would end a code span
+ /// starting at a backtick — so bracket matching skips what the renderer
+ /// will draw as code (CommonMark: code spans outrank brackets;
+ /// `[a `](` b](u)` is a link whose text holds the code "](").
+ /// Its rules exactly: a run of 1 or 2 backticks closes at the FIRST later
+ /// occurrence of that many backticks (a 2-run whose closer is followed by
+ /// a third backtick opens nothing); a run of 3+ is literal and skipped
+ /// whole. No span → nil (the caller moves on one byte, as the renderer
+ /// moves on one character). A search that found no closer is remembered
+ /// per run length, so many unclosed backticks stay linear.
+ fileprivate struct CodeSpanScanner {
+ private var noCloserAfter: [Int: Int] = [:] // run length → offset searched from
+
+ mutating func end(_ bytes: Bytes, at start: Int) -> Int? {
+ var run = 0
+ while start + run < bytes.count, bytes[start + run] == backtick { run += 1 }
+ if run > 2 { return start + run }
+ if let from = noCloserAfter[run], from <= start + run { return nil }
+ var i = start + run
+ while i + run <= bytes.count {
+ if bytes[i] == backtick, run == 1 || bytes[i + 1] == backtick {
+ if run == 2, i + 2 < bytes.count, bytes[i + 2] == backtick { return nil }
+ return i + run
+ }
+ i += 1
+ }
+ noCloserAfter[run] = start + run
+ return nil
+ }
+ }
private static let ampersand = UInt8(ascii: "&")
/// CommonMark whitespace for these purposes: space, tab, LF, VT, FF, CR.
@@ -274,8 +323,10 @@ enum InlineSyntaxScanner {
if let lookup { return lookup.closingBracket(open).flatMap { $0 < bytes.count ? $0 : nil } }
var depth = 0
var i = open
+ var codeSpans = CodeSpanScanner()
while i < bytes.count {
if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
+ if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) { i = end; continue }
switch bytes[i] {
case UInt8(ascii: "["): depth += 1
case UInt8(ascii: "]"):
diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift
index d7827e3..f7cf077 100644
--- a/QuickMD/QuickMDTests/RendererTests.swift
+++ b/QuickMD/QuickMDTests/RendererTests.swift
@@ -910,6 +910,38 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(String(renderer.renderQuotedBody(quote).characters), "a * b and c $5")
}
+ // MARK: - Code spans outrank brackets (review R16)
+
+ /// A `]` or `[` inside a code span pairs with nothing (CommonMark); cases
+ /// and expected output from `gh api markdown`. (A ```` ``` ```` run stays
+ /// literal in QuickMD's inline code, so that GitHub case is not here.)
+ func testCodeSpansOutrankBrackets() {
+ func check(_ markdown: String, text: String, links: [String], code: [String],
+ file: StaticString = #filePath, line: UInt = #line) {
+ let attributed = renderer.renderInline(markdown)
+ XCTAssertEqual(String(attributed.characters), text, markdown, file: file, line: line)
+ XCTAssertEqual(Set(attributed.runs.compactMap { $0.link?.absoluteString }), Set(links), markdown,
+ file: file, line: line)
+ let monospaced = attributed.runs.filter {
+ $0[AttributeScopes.AppKitAttributes.FontAttribute.self]?.fontDescriptor.symbolicTraits
+ .contains(.monoSpace) ?? false
+ }.map { String(attributed[$0.range].characters) }
+ XCTAssertEqual(monospaced, code, markdown, file: file, line: line)
+ }
+ check("[a `](` b](u)", text: "a ]( b", links: ["u"], code: ["]("])
+ check("[x `]` y]", text: "[x ] y]", links: [], code: ["]"])
+ check("[a ``]`` b](u)", text: "a ] b", links: ["u"], code: ["]"])
+ check("[a ` b](u)", text: "a ` b", links: ["u"], code: [])
+ check("[a \\` b](u) c`", text: "a ` b c`", links: ["u"], code: [])
+ check("`[a](u)`", text: "[a](u)", links: [], code: ["[a](u)"])
+
+ // A standalone image whose alt holds a code span with `]`.
+ let blocks = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse("![alt `]` x](i.png)")
+ guard case .image(let url, let alt, _)? = blocks.first?.content else { return XCTFail("image") }
+ XCTAssertEqual(url, "i.png")
+ XCTAssertEqual(alt, "alt `]` x")
+ }
+
// MARK: - Plain link text fast path (review R15)
/// Link text with no inline syntax skips the inline pass; the run it gets
From 44eaf2333bbf48827005407189c94e9661a592db Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:32:11 +0100
Subject: [PATCH 080/105] perf(quote): a long quote is rendered once per parse,
not per re-measure or row update
A quote or alert body is rendered from its source (renderQuotedBody); a
paragraph's string is stored in its block. Two places rendered it again:
- The measurer, on every width change and reading-mode toggle. It now
takes the string the previous measurement of the same parse built
(measure(reusing:), the heights task's measured.converted, the same
contract as measureMathRows) and only lays it out again.
- BlockquoteView and AlertBlockView, on the main thread on every update of
the row (it comes into view, a search keystroke, Cmd-G), although
TextBlockView reads that string only without preconverted. They render
it now only when there is no measured string.
5,000-line quote, -O: re-measure 532 -> 156 ms (the same text as a
paragraph: 163 ms); view update 368 ms on main -> none. Opening is
unchanged and equal to the paragraph's (parse + measure 576 vs 581 ms).
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/BlockHeightMeasurer.swift | 17 ++++++--
QuickMD/QuickMD/MarkdownView.swift | 16 +++----
QuickMD/QuickMD/Views/AlertBlockView.swift | 6 ++-
QuickMD/QuickMD/Views/BlockquoteView.swift | 8 +++-
.../QuickMDTests/VerticalRhythmTests.swift | 42 +++++++++++++++++++
5 files changed, 75 insertions(+), 14 deletions(-)
diff --git a/QuickMD/QuickMD/BlockHeightMeasurer.swift b/QuickMD/QuickMD/BlockHeightMeasurer.swift
index eb686f9..de4d5ee 100644
--- a/QuickMD/QuickMD/BlockHeightMeasurer.swift
+++ b/QuickMD/QuickMD/BlockHeightMeasurer.swift
@@ -1201,6 +1201,15 @@ enum BlockHeightMeasurer {
/// computed (`BlockLayout.ImageBlock`) instead of starting at the
/// loading view. SVG sizes come from `SVGImageDecoder`, diagram
/// geometry from `MermaidGeometryStore`.
+ /// - reusing: strings a PREVIOUS measurement of the same parse built,
+ /// keyed by block id — the caller's last `MeasuredBlocks.converted`.
+ /// Quote and alert bodies found here are laid out again as they are:
+ /// a paragraph's string is stored in its block, a quote's is rendered
+ /// from its source (`renderQuotedBody`), and that render is the whole
+ /// cost of a long quote (a 5,000-line one: ~370 ms of ~530). A width
+ /// change does not change a character, so the render is done once per
+ /// parse. Pass it ONLY for the same content, theme and zoom (the
+ /// `measureMathRows` contract); a new parse passes nothing.
/// - math: the math engine, or `nil` when the call is NOT on the main
/// thread — see the thread rule in the file header. With `nil`, rows
/// that need the engine come back at a placeholder height, listed in
@@ -1221,6 +1230,7 @@ enum BlockHeightMeasurer {
contentWidth: CGFloat,
heightSeeds: [String: CGFloat] = [:],
imageSizes: [NSString: CGSize] = [:],
+ reusing: [String: NSAttributedString] = [:],
math: MathRendering?) -> MeasuredBlocks {
#if DEBUG
let signpostID = heightSignpost.makeSignpostID()
@@ -1296,8 +1306,9 @@ enum BlockHeightMeasurer {
// The quote body is inline-rendered exactly as BlockquoteView
// does it, and passed to TextBlockView WITHOUT inline math (the
// view does not opt in), so we must not opt in either — which is
- // also why a quote never needs the math engine.
- let ns = BlockTextConverter.makeNSAttributedString(
+ // also why a quote never needs the math engine. A string the
+ // previous measurement built is only laid out again (`reusing`).
+ let ns = reusing[block.id] ?? BlockTextConverter.makeNSAttributedString(
from: renderer.renderQuotedBody(content), hasInlineMath: false,
theme: theme, fontScale: fontScale, math: engine)
converted[block.id] = ns
@@ -1312,7 +1323,7 @@ enum BlockHeightMeasurer {
+ 2 * BlockLayout.Alert.verticalPadding
+ 2 * BlockLayout.Document.alertOuterVerticalPadding
if !content.isEmpty {
- let ns = BlockTextConverter.makeNSAttributedString(
+ let ns = reusing[block.id] ?? BlockTextConverter.makeNSAttributedString(
from: renderer.renderQuotedBody(content), hasInlineMath: false,
theme: theme, fontScale: fontScale, math: engine)
converted[block.id] = ns
diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift
index eaea016..d0e7958 100644
--- a/QuickMD/QuickMD/MarkdownView.swift
+++ b/QuickMD/QuickMD/MarkdownView.swift
@@ -772,17 +772,19 @@ struct MarkdownView: View {
let seeds = heightCache.snapshot
// Images shown before: their rows are computed at the new width.
let imageSizes = heightCache.imageSizesSnapshot
- // Inline-math paragraphs: hand the previous pass's strings back so the
- // main-actor step re-wraps them instead of re-rendering every `$…$`
- // segment through SwiftMath. Safe only because this task never runs
- // across a content change — the guard below drops the result if a
- // parse landed meanwhile, and everything that alters the characters
- // (text, theme, fonts, zoom) goes through a re-parse.
+ // The previous pass's strings, handed back so they are only re-wrapped:
+ // quote and alert bodies (off-main, `measure` — not rendered from
+ // their source again) and inline-math paragraphs (the main-actor
+ // step — not every `$…$` segment through SwiftMath again). Safe
+ // only because this task never runs across a content change — the
+ // guard below drops the result if a parse landed meanwhile, and
+ // everything that alters the characters (text, theme, fonts, zoom)
+ // goes through a re-parse.
let reusableStrings = measured.converted
let raw: MeasuredBlocks = await Task.detached(priority: .userInitiated) {
BlockHeightMeasurer.measure(blocks: blocks, theme: currentTheme, fontScale: scale,
contentWidth: width, heightSeeds: seeds,
- imageSizes: imageSizes, math: nil)
+ imageSizes: imageSizes, reusing: reusableStrings, math: nil)
}.value
// A parse that landed while we were measuring owns the state now —
// its own transaction published a matching table.
diff --git a/QuickMD/QuickMD/Views/AlertBlockView.swift b/QuickMD/QuickMD/Views/AlertBlockView.swift
index 723fd78..4e25fc7 100644
--- a/QuickMD/QuickMD/Views/AlertBlockView.swift
+++ b/QuickMD/QuickMD/Views/AlertBlockView.swift
@@ -93,8 +93,10 @@ struct AlertBlockView: View {
/// One attributed string for the whole alert body — see
/// `MarkdownRenderer.renderQuotedBody` (shared with the blockquote view and
- /// the height measurer).
+ /// the height measurer) — rendered only when the measurer has not built
+ /// the string (`BlockquoteView.renderedContent`: `TextBlockView` reads it
+ /// only without `preconverted`; this body runs on every row update).
private func renderedContent() -> AttributedString {
- renderer.renderQuotedBody(content)
+ preconverted == nil ? renderer.renderQuotedBody(content) : AttributedString()
}
}
diff --git a/QuickMD/QuickMD/Views/BlockquoteView.swift b/QuickMD/QuickMD/Views/BlockquoteView.swift
index 68a8a31..198eb4b 100644
--- a/QuickMD/QuickMD/Views/BlockquoteView.swift
+++ b/QuickMD/QuickMD/Views/BlockquoteView.swift
@@ -82,8 +82,12 @@ struct BlockquoteView: View {
/// One attributed string for the whole quote body — see
/// `MarkdownRenderer.renderQuotedBody` (shared with the alert view and the
- /// height measurer).
+ /// height measurer) — rendered only when the measurer has not built the
+ /// string: `TextBlockView` reads `attributed` only without `preconverted`,
+ /// and this body runs on the main thread on every update of the row (it
+ /// comes into view, a search keystroke, ⌘G). A long quote's render is
+ /// hundreds of milliseconds; a paragraph's string is stored in its block.
private func renderedContent() -> AttributedString {
- renderer.renderQuotedBody(content)
+ preconverted == nil ? renderer.renderQuotedBody(content) : AttributedString()
}
}
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index abdb95a..0718e86 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -859,6 +859,48 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(HeadingAnchors.slug(entries[0].title), "para-one-line-two")
}
+ // MARK: - Long quotes
+
+ /// A width change lays a quote or alert body out again WITHOUT rendering
+ /// it: the measurer takes the string the previous measurement of the same
+ /// parse built (`reusing`, the caller's `measured.converted`), as a
+ /// paragraph's string is stored in its block. It rendered the body again
+ /// on every width change and reading-mode toggle — a 5,000-line quote
+ /// cost ~530 ms per re-measure (-O) against ~160 ms for the same text as
+ /// a paragraph. The heights and the rhythm are a fresh measurement's.
+ func testAWidthChangeLaysAQuoteOutAgainWithoutRenderingIt() throws {
+ let long = "words that wrap at a narrow column but not at a wide one, and a few more of them"
+ let markdown = "Para\n\n> \(long)\nand a lazy line\n\n> > nested \(long)\n\n"
+ + "> [!NOTE]\n> \(long)\n\n> [!TIP]\n\nEnd\n"
+ let blocks = try XCTUnwrap(parseReturning(markdown))
+ let theme = MarkdownTheme.cached(for: .light)
+ let first = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1,
+ contentWidth: 700, math: nil)
+ let again = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1,
+ contentWidth: 260, reusing: first.converted, math: nil)
+ let fresh = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1,
+ contentWidth: 260, math: nil)
+ XCTAssertNotEqual(first.table.heights, fresh.table.heights) // the wrap moved
+ XCTAssertEqual(again.table.heights, fresh.table.heights)
+ XCTAssertEqual(again.rhythm, fresh.rhythm)
+ let bodies = blocks.filter { block in
+ switch block.content {
+ case .blockquote: return true
+ case .alert(_, let content): return !content.isEmpty
+ default: return false
+ }
+ }
+ XCTAssertEqual(bodies.count, 3)
+ for block in bodies {
+ let reused = try XCTUnwrap(again.converted[block.id], block.id)
+ XCTAssertTrue(reused === first.converted[block.id], block.id)
+ XCTAssertEqual(reused, fresh.converted[block.id], block.id)
+ }
+ // Only those: a paragraph's string is built from its block again.
+ let paragraph = try XCTUnwrap(blocks.first)
+ XCTAssertFalse(again.converted[paragraph.id] === first.converted[paragraph.id])
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From cc49795d5361bcd318c7822da0b5b23264a88f63 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:33:10 +0100
Subject: [PATCH 081/105] fix(links): a backtick in a link destination opens no
code span for bracket matching
Review R16 follow-up: 4c0afbf's bracket scans looked for code spans
from the start of the line, but the renderer never starts one inside a
destination it consumed, so [a](u`x) and [b `c](v) and
[a]() [b](v) ` lost their second link. PairTable.build now pairs
parens in a first pass and, in the bracket pass, jumps over the
destination after a "](" (to its paired paren); the lookup-less
matchBracket skips an inner link's destination the same way. Both
lines and the reviewer's corpus are in the test, with GitHub's result
noted where QuickMD's own inline rules differ (emphasis before code,
literal 3+ backtick runs). Linear-time tests unchanged.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/InlineSyntaxScanner.swift | 48 +++++++++++++++--------
QuickMD/QuickMDTests/RendererTests.swift | 38 ++++++++++++++++++
2 files changed, 70 insertions(+), 16 deletions(-)
diff --git a/QuickMD/QuickMD/InlineSyntaxScanner.swift b/QuickMD/QuickMD/InlineSyntaxScanner.swift
index 3638063..d04f03e 100644
--- a/QuickMD/QuickMD/InlineSyntaxScanner.swift
+++ b/QuickMD/QuickMD/InlineSyntaxScanner.swift
@@ -54,35 +54,44 @@ enum InlineSyntaxScanner {
fileprivate func closingBracket(atAbsolute offset: Int) -> Int? { pairs.brackets[offset] }
fileprivate func closingParen(atAbsolute offset: Int) -> Int? { pairs.parens[offset] }
+ /// Two linear passes. Parens first, everywhere (a destination is not
+ /// code). Then brackets, skipping code spans (code spans outrank
+ /// brackets) and the destination after a `](` — the renderer consumes
+ /// it as the link's, so a backtick in a URL (`[a](u`x) [b](v)`) must
+ /// not open a "span" that swallows a later link's `[`.
private static func build(_ text: String) -> (brackets: [Int: Int], parens: [Int: Int]) {
InlineSyntaxScanner.withBytes(text[...]) { bytes in
var brackets: [Int: Int] = [:], parens: [Int: Int] = [:]
var openBrackets: [Int] = [], openParens: [Int] = []
- var codeSpans = CodeSpanScanner()
var i = 0
while i < bytes.count {
if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
- // Code spans outrank brackets: a `]` or `[` inside one pairs
- // with nothing. Parens are not skipped — a destination is
- // not code — so only brackets look at the span.
+ switch bytes[i] {
+ case UInt8(ascii: "("): openParens.append(i)
+ case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = i }
+ default: break
+ }
+ i += 1
+ }
+
+ var codeSpans = CodeSpanScanner()
+ i = 0
+ while i < bytes.count {
+ if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) {
- var j = i
- while j < end {
- switch bytes[j] {
- case UInt8(ascii: "("): openParens.append(j)
- case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = j }
- default: break
- }
- j += 1
- }
i = end
continue
}
switch bytes[i] {
case UInt8(ascii: "["): openBrackets.append(i)
- case UInt8(ascii: "]"): if let open = openBrackets.popLast() { brackets[open] = i }
- case UInt8(ascii: "("): openParens.append(i)
- case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = i }
+ case UInt8(ascii: "]"):
+ if let open = openBrackets.popLast() {
+ brackets[open] = i
+ if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "("), let close = parens[i + 1] {
+ i = close + 1
+ continue
+ }
+ }
default: break
}
i += 1
@@ -332,6 +341,13 @@ enum InlineSyntaxScanner {
case UInt8(ascii: "]"):
depth -= 1
if depth == 0 { return i }
+ // An inner link's destination is not scanned for code spans
+ // (see `PairTable.build`).
+ if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "("),
+ let close = scanParenDestination(bytes, open: i + 1, nil)?.end {
+ i = close
+ continue
+ }
default: break
}
i += 1
diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift
index f7cf077..7f77870 100644
--- a/QuickMD/QuickMDTests/RendererTests.swift
+++ b/QuickMD/QuickMDTests/RendererTests.swift
@@ -942,6 +942,44 @@ final class RendererTests: XCTestCase {
XCTAssertEqual(alt, "alt `]` x")
}
+ /// The renderer's runs as "text" / "`code`" pieces joined by "|".
+ private func runs(_ markdown: String) -> String {
+ let attributed = renderer.renderInline(markdown)
+ return attributed.runs.map { run in
+ let text = String(attributed[run.range].characters)
+ let mono = run[AttributeScopes.AppKitAttributes.FontAttribute.self]?.fontDescriptor.symbolicTraits
+ .contains(.monoSpace) ?? false
+ return (run.link.map { "<\($0.absoluteString)>" } ?? "") + (mono ? "`\(text)`" : text)
+ }.joined(separator: "|")
+ }
+
+ /// The renderer never starts a code span inside a link destination it
+ /// consumed, so the bracket scans must not either: a backtick in a URL
+ /// must not swallow a later link's `[` (4c0afbf did). The reviewer's
+ /// corpus; GitHub's result (`gh api markdown`) noted where QuickMD's own
+ /// inline rules differ (emphasis/strikethrough before code — 1.15, R17;
+ /// 3+ backtick code spans are literal in QuickMD).
+ func testLinkDestinationsHideBackticksFromTheBracketScan() {
+ // GitHub: two links "a" and "b `c".
+ XCTAssertEqual(runs("[a](u`x) and [b `c](v)"), "a| and |b `c")
+ // GitHub: both links, a trailing backtick.
+ XCTAssertEqual(runs("[a]() [b](v) `"), "a| |b| `")
+ // GitHub-equal.
+ XCTAssertEqual(runs("[a `](` b](u)"), "a |`](`| b")
+ XCTAssertEqual(runs(" [c](u) x`y"), " [c](u) x`|y")
+ XCTAssertEqual(runs("[x](u) `` [y]` `` [z](v)"), "x| |`[y]``| |z")
+ XCTAssertEqual(runs("[a ``b]`` c](u)"), "a |`b]`| c")
+ XCTAssertEqual(runs("[a \\`b](u) c`"), "a |`|b| c`")
+ XCTAssertEqual(runs("[^n`] [d](u) `"), "[^n|`] [d](u) `")
+ XCTAssertEqual(runs("`[` [a](u) `]`"), "`[`| |a| |`]`")
+ // QuickMD's own rules (GitHub differs, not this fix): no link in
+ // either, as on GitHub, but emphasis / strikethrough win over code.
+ XCTAssertEqual(runs("***a `b*** [c` d](u)"), "a `b| [c` d](u)")
+ XCTAssertEqual(runs("~~x`~~ [y](u) z`"), "x`| [y](u) z`")
+ // GitHub: "[a " + code "b](u) c" (QuickMD keeps ``` runs literal).
+ XCTAssertEqual(runs("[a ```b](u) c```"), "a ```b| c```")
+ }
+
// MARK: - Plain link text fast path (review R15)
/// Link text with no inline syntax skips the inline pass; the run it gets
From 9b6891409cab8fb97767cba92ff1f844520424d9 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:43:07 +0100
Subject: [PATCH 082/105] perf(links): the image-line bracket scan pairs parens
once, not per inner link
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review round 7: matchBracket (no pair table: every . It now builds the paren map once, on the
first inner "](" (the same one-pass pairing PairTable uses, shared as
pairParens). 5k → 40k: 0.63 s → 34.5 s before, 0.06 s → 0.47 s after
(Debug); the new test asserts the scaling, not an absolute time.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/InlineSyntaxScanner.swift | 50 +++++++++++++++--------
QuickMD/QuickMDTests/ParserTests.swift | 22 ++++++++++
2 files changed, 54 insertions(+), 18 deletions(-)
diff --git a/QuickMD/QuickMD/InlineSyntaxScanner.swift b/QuickMD/QuickMD/InlineSyntaxScanner.swift
index d04f03e..d22c3ee 100644
--- a/QuickMD/QuickMD/InlineSyntaxScanner.swift
+++ b/QuickMD/QuickMD/InlineSyntaxScanner.swift
@@ -61,21 +61,11 @@ enum InlineSyntaxScanner {
/// not open a "span" that swallows a later link's `[`.
private static func build(_ text: String) -> (brackets: [Int: Int], parens: [Int: Int]) {
InlineSyntaxScanner.withBytes(text[...]) { bytes in
- var brackets: [Int: Int] = [:], parens: [Int: Int] = [:]
- var openBrackets: [Int] = [], openParens: [Int] = []
- var i = 0
- while i < bytes.count {
- if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
- switch bytes[i] {
- case UInt8(ascii: "("): openParens.append(i)
- case UInt8(ascii: ")"): if let open = openParens.popLast() { parens[open] = i }
- default: break
- }
- i += 1
- }
-
+ var brackets: [Int: Int] = [:]
+ let parens = pairParens(bytes)
+ var openBrackets: [Int] = []
var codeSpans = CodeSpanScanner()
- i = 0
+ var i = 0
while i < bytes.count {
if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) {
@@ -101,6 +91,24 @@ enum InlineSyntaxScanner {
}
}
+ /// Every `(` → its `)`, one stack pass, escapes respected — exactly the
+ /// closer a depth scan from that `(` finds (`scanParenDestination`).
+ fileprivate static func pairParens(_ bytes: Bytes) -> [Int: Int] {
+ var parens: [Int: Int] = [:]
+ var open: [Int] = []
+ var i = 0
+ while i < bytes.count {
+ if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
+ switch bytes[i] {
+ case UInt8(ascii: "("): open.append(i)
+ case UInt8(ascii: ")"): if let start = open.popLast() { parens[start] = i }
+ default: break
+ }
+ i += 1
+ }
+ return parens
+ }
+
/// A pair table seen from a slice: offsets in the slice's byte buffer are
/// `base` bytes into the table's text.
fileprivate struct Lookup {
@@ -333,6 +341,10 @@ enum InlineSyntaxScanner {
var depth = 0
var i = open
var codeSpans = CodeSpanScanner()
+ /// Built on the first inner `](` only: a depth scan per `](` that
+ /// never closes went to the end of the line each time (quadratic on
+ /// `.
+ var parens: [Int: Int]?
while i < bytes.count {
if isEscape(bytes, at: i, before: bytes.count) { i += 2; continue }
if bytes[i] == backtick, let end = codeSpans.end(bytes, at: i) { i = end; continue }
@@ -343,10 +355,12 @@ enum InlineSyntaxScanner {
if depth == 0 { return i }
// An inner link's destination is not scanned for code spans
// (see `PairTable.build`).
- if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "("),
- let close = scanParenDestination(bytes, open: i + 1, nil)?.end {
- i = close
- continue
+ if i + 1 < bytes.count, bytes[i + 1] == UInt8(ascii: "(") {
+ if parens == nil { parens = pairParens(bytes) }
+ if let close = parens?[i + 1] {
+ i = close + 1
+ continue
+ }
}
default: break
}
diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift
index 86949d3..ea96d07 100644
--- a/QuickMD/QuickMDTests/ParserTests.swift
+++ b/QuickMD/QuickMDTests/ParserTests.swift
@@ -494,6 +494,28 @@ final class ParserTests: XCTestCase {
.nonZero, "no list hang")
}
+ /// An image line whose alt holds many inner `[a](` that never close: the
+ /// bracket scan must stay linear (a depth scan per `](` to the end of the
+ /// line made it quadratic, review round 7). Relative, not absolute: 8×
+ /// the input may take at most ~3× the linear share.
+ func testImageLineWithUnclosedInnerLinksParsesInLinearTime() {
+ let parser = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light))
+ func time(_ count: Int) -> TimeInterval {
+ let line = " + "]"
+ _ = parser.parse(line) // warm
+ var best = TimeInterval.infinity
+ for _ in 0..<3 {
+ let start = Date()
+ _ = parser.parse(line)
+ best = min(best, Date().timeIntervalSince(start))
+ }
+ return best
+ }
+ let small = time(5_000)
+ let large = time(40_000)
+ XCTAssertLessThan(large, max(small, 0.005) * 24, "5k: \(small) s, 40k: \(large) s (quadratic would be ~64×)")
+ }
+
// MARK: - Reference links in quotes, alerts and table cells
/// Quotes, alerts and table cells keep their markdown and are rendered
From 0e8ced066beb8710f639853ca4a9b39dce0e443f Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:52:20 +0100
Subject: [PATCH 083/105] fix(anchors): a multi-line Setext title gets GitHub's
id
GitHub keeps a Setext title's line breaks in the heading's text and the
id drops them: `Alpha` / `Beta` / `===` is `#alphabeta` (a hard break
too). The title was joined with spaces, which the slug turned into `-`
(`alpha-beta`), so `[go](#alphabeta)` went to another heading or
nowhere.
The title now keeps its line breaks (MarkdownBlockParser.setextTitle,
which also drops a hard break's backslash). Anchors read them
(MarkdownRenderer.headingAnchorText); the heading, search and the
Contents sidebar show each as a space (shownHeadingTitle,
ToCEntry.entries). Copy Section is unchanged (source lines).
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 21 +++++++--
QuickMD/QuickMD/MarkdownRenderer.swift | 17 +++++++-
QuickMD/QuickMD/MarkdownView.swift | 7 +--
QuickMD/QuickMD/SectionExtractor.swift | 18 ++++++--
.../QuickMDTests/SectionExtractorTests.swift | 43 ++++++++++++++++---
.../QuickMDTests/VerticalRhythmTests.swift | 11 +++--
6 files changed, 91 insertions(+), 26 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 4136156..1d0b635 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -296,7 +296,8 @@ struct MarkdownBlockParser: Sendable {
// Setext headers (underlined headers): the WHOLE paragraph above
// the underline is the heading (CommonMark, GitHub), on the
- // paragraph's first line, its lines joined like soft breaks. Not a
+ // paragraph's first line. Its title keeps the lines' breaks
+ // (`setextTitle`): shown as spaces, dropped by the anchor. Not a
// list item's paragraph (`- a` over `===` is the item's text, over
// `---` the list and a rule) nor a quote line's (the quote branch
// takes those, lazy lines included).
@@ -318,8 +319,7 @@ struct MarkdownBlockParser: Sendable {
level = nil
}
if let level, let start = Self.setextParagraphStart(in: textBuffer, endingWith: line) {
- let title = (textBuffer[start...].map { Self.markerLine($0.line) } + [trimmed])
- .joined(separator: " ")
+ let title = Self.setextTitle(textBuffer[start...].map(\.line) + [line])
let headingLine = start < textBuffer.count ? textBuffer[start].originalIndex : sourceLine
var preceding = Array(textBuffer[.. String {
+ let trimmed = lines.map(markerLine)
+ return trimmed.enumerated().map { index, text in
+ guard index < trimmed.count - 1 else { return text }
+ let backslashes = text.reversed().prefix(while: { $0 == "\\" }).count
+ return backslashes % 2 == 1 ? String(text.dropLast()) : text
+ }.joined(separator: "\n")
+ }
+
/// Where the paragraph that `line` (the line over a Setext underline)
/// ends starts in the text buffer: after its last blank line or rule.
/// Nil when that paragraph is a list item's — an item line starts it or
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index d6dc714..d7b19b8 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -458,8 +458,23 @@ struct MarkdownRenderer: Sendable {
// MARK: - Block Renderers
+ /// A heading's title as shown: a Setext title keeps its paragraph's line
+ /// breaks (`MarkdownBlockParser.setextTitle`) and each reads as a space,
+ /// like a soft break in a paragraph — in the heading, the Contents
+ /// sidebar and search alike.
+ static func shownHeadingTitle(_ title: String) -> String {
+ title.replacingOccurrences(of: "\n", with: " ")
+ }
+
+ /// The text GitHub takes a heading's id from: the rendered title with a
+ /// Setext title's line breaks KEPT — the slug drops them (`Alpha⏎Beta`
+ /// is `alphabeta`), where the space shown would be a `-`.
+ func headingAnchorText(_ title: String) -> String {
+ String(renderInlineFormatting(title).characters)
+ }
+
func renderHeader(_ text: String, level: Int) -> AttributedString {
- var attr = renderInlineFormatting(text)
+ var attr = renderInlineFormatting(Self.shownHeadingTitle(text))
attr.setDualFont(size: headerFontSize(level: level), bold: true, fonts: theme.fonts)
attr.setDualForeground(theme.textColor)
return attr
diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift
index d0e7958..4d44476 100644
--- a/QuickMD/QuickMD/MarkdownView.swift
+++ b/QuickMD/QuickMD/MarkdownView.swift
@@ -732,12 +732,7 @@ struct MarkdownView: View {
theme: currentTheme, fontScale: scale,
contentWidth: width, math: .swiftMath)
: .empty
- headings = blocks.compactMap { block in
- if case .heading(let level, let title, let sourceLine) = block.content {
- return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine)
- }
- return nil
- }
+ headings = ToCEntry.entries(of: blocks)
// A landing that waited for THIS parse (Source Edit saved, then
// left before the re-parse arrived): requested in the transaction
// that installs the blocks, so it wins over the list's own anchor
diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift
index f038db9..d008461 100644
--- a/QuickMD/QuickMD/SectionExtractor.swift
+++ b/QuickMD/QuickMD/SectionExtractor.swift
@@ -9,8 +9,18 @@ import Foundation
struct ToCEntry: Identifiable, Sendable {
let id: String
let level: Int
+ /// As shown (`MarkdownRenderer.shownHeadingTitle`): one line.
let title: String
let sourceLine: Int
+
+ /// The headings of `blocks`, in document order.
+ static func entries(of blocks: [MarkdownBlock]) -> [ToCEntry] {
+ blocks.compactMap { block in
+ guard case .heading(let level, let title, let sourceLine) = block.content else { return nil }
+ return ToCEntry(id: block.id, level: level, title: MarkdownRenderer.shownHeadingTitle(title),
+ sourceLine: sourceLine)
+ }
+ }
}
// MARK: - Contents Depth
@@ -86,12 +96,14 @@ enum HeadingAnchors {
}
/// The headings of `blocks` as links see them: block id and the text the
- /// heading shows (`renderHeader`'s characters, as `HeadingBlockView`).
+ /// heading shows (`renderHeader`'s characters, as `HeadingBlockView`),
+ /// with a Setext title's line breaks kept for the slug to drop
+ /// (`MarkdownRenderer.headingAnchorText`).
static func headings(of blocks: [MarkdownBlock],
renderer: MarkdownRenderer) -> [(id: String, text: String)] {
blocks.compactMap { block in
- guard case .heading(let level, let title, _) = block.content else { return nil }
- return (block.id, String(renderer.renderHeader(title, level: level).characters))
+ guard case .heading(_, let title, _) = block.content else { return nil }
+ return (block.id, renderer.headingAnchorText(title))
}
}
diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift
index beb40c2..2f3d52a 100644
--- a/QuickMD/QuickMDTests/SectionExtractorTests.swift
+++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift
@@ -7,13 +7,7 @@ import XCTest
final class SectionExtractorTests: XCTestCase {
private func headings(of markdown: String) -> [ToCEntry] {
- let blocks = MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(markdown)
- return blocks.compactMap { block in
- if case .heading(let level, let title, let sourceLine) = block.content {
- return ToCEntry(id: block.id, level: level, title: title, sourceLine: sourceLine)
- }
- return nil
- }
+ ToCEntry.entries(of: MarkdownBlockParser(theme: MarkdownTheme.cached(for: .light)).parse(markdown))
}
func testSectionEndsAtNextSameLevelHeading() {
@@ -166,6 +160,41 @@ final class SectionExtractorTests: XCTestCase {
])
}
+ /// A Setext title over several lines: GitHub keeps its line breaks in
+ /// the heading's text, and the id DROPS them — `Alpha⏎Beta` is
+ /// `alphabeta`, after a hard break too (gfm/h1_*.html). The title was
+ /// joined with spaces, which the slug made `-` (`alpha-beta`), so
+ /// `[go](#alphabeta)` went nowhere. Shown — the heading, the Contents
+ /// sidebar, search — each break is a space; Copy Section is the source.
+ func testAMultiLineSetextTitleHasGitHubsId() {
+ let md = "Alpha\nBeta\n===\n\nAlphaBeta\n===\n\nAlpha\nBeta\n---\n\nPara one\nline two\n---\n\n"
+ + "One \nTwo\\\nThree\n---\n\n**Bold\nend**\n===\n\n[go](#alphabeta)\n"
+ let anchors = anchorHeadings(of: md)
+ XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), [
+ "alphabeta", "alphabeta-1", "alphabeta-2", "para-oneline-two", "onetwothree", "boldend",
+ ])
+ XCTAssertEqual(HeadingAnchors.target(forFragment: "alphabeta", in: anchors), .heading(anchors[0].id))
+ XCTAssertEqual(HeadingAnchors.target(forFragment: "para-oneline-two", in: anchors),
+ .heading(anchors[3].id))
+
+ let theme = MarkdownTheme.cached(for: .light)
+ let blocks = MarkdownBlockParser(theme: theme).parse(md)
+ let renderer = MarkdownRenderer(theme: theme)
+ let shown = blocks.compactMap { block -> String? in
+ guard case .heading(let level, let title, _) = block.content else { return nil }
+ return String(renderer.renderHeader(title, level: level).characters)
+ }
+ XCTAssertEqual(shown, ["Alpha Beta", "AlphaBeta", "Alpha Beta", "Para one line two", "One Two Three",
+ "Bold end"])
+ let entries = headings(of: md)
+ XCTAssertEqual(entries.map(\.title), ["Alpha Beta", "AlphaBeta", "Alpha Beta", "Para one line two",
+ "One Two Three", "**Bold end**"])
+ XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: entries[0], headings: entries),
+ "Alpha\nBeta\n===")
+ XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: entries[4], headings: entries),
+ "One \nTwo\\\nThree\n---")
+ }
+
func testFragmentFindsItsHeading() {
let headings = anchorHeadings(of: """
# Guide
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 0718e86..44f8511 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -653,7 +653,7 @@ final class VerticalRhythmTests: XCTestCase {
case .alert(let kind, let content): return "alert:\(kind.rawValue):\(content)"
case .text(let attributed): return "t:" + String(attributed.characters)
case .codeBlock(let code, _): return "c:\(code)"
- case .heading(_, let title, _): return "h:\(title)"
+ case .heading(_, let title, _): return "h:" + MarkdownRenderer.shownHeadingTitle(title)
case .image: return "img"
case .table: return "table"
default: return "other"
@@ -850,13 +850,12 @@ final class VerticalRhythmTests: XCTestCase {
let spans = SourceTint.spans(for: blocks, lineStarts: SourceEditSupport.lineStarts(in: ns), in: ns)
XCTAssertEqual(spans.filter { $0.kind == .heading }.map { ns.substring(with: $0.range) },
["Para one\nline two\n---"])
- let entries = blocks.compactMap { block -> ToCEntry? in
- guard case .heading(let level, let title, let line) = block.content else { return nil }
- return ToCEntry(id: block.id, level: level, title: title, sourceLine: line)
- }
+ let entries = ToCEntry.entries(of: blocks)
XCTAssertEqual(SectionExtractor.extractSection(from: markdown, entry: entries[0], headings: entries),
"Para one\nline two\n---\n\nAfter")
- XCTAssertEqual(HeadingAnchors.slug(entries[0].title), "para-one-line-two")
+ // GitHub's id drops the line break (gfm/h1_words.html).
+ let anchors = HeadingAnchors.headings(of: blocks, renderer: MarkdownRenderer(theme: .cached(for: .light)))
+ XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), ["para-oneline-two"])
}
// MARK: - Long quotes
From cbe5c1c8a0de3a129678b7f9a50d036a428698f4 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:55:18 +0100
Subject: [PATCH 084/105] fix(parser): a block start ends a table's body
A table's body took every line with a `|`. GitHub closes a table for any
block start: a fence, a quote, an ATX heading, a list item or an HTML
block. Here `$$` opens a formula too. A fence or `$$` line with a pipe
became a row while the definition pre-pass read it as a block, and the
block's closing line then opened one that ran to the end of the
document.
MarkdownBlockParser.interruptsTable ends the body, and a quote's table
the same way (QuoteTip): an item after the table goes on lazily, as on
GitHub.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 15 ++++++--
.../QuickMDTests/VerticalRhythmTests.swift | 34 +++++++++++++++++++
2 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 1d0b635..80b2c1c 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -345,7 +345,8 @@ struct MarkdownBlockParser: Sendable {
var rows: [[String]] = []
i += 2 // Skip header and separator
- while i < lines.count && lines[i].contains("|") && !isTableSeparator(lines[i]) {
+ while i < lines.count && lines[i].contains("|") && !isTableSeparator(lines[i])
+ && !Self.interruptsTable(lines[i]) {
rows.append(parseTableRow(lines[i]))
i += 1
}
@@ -1183,7 +1184,8 @@ struct MarkdownBlockParser: Sendable {
inTable = false
return
}
- if inTable, content.contains("|") {
+ // A row; a block start ends the table (`interruptsTable`).
+ if inTable, content.contains("|"), !MarkdownBlockParser.interruptsTable(content) {
endsInParagraph = false
return
}
@@ -1225,6 +1227,15 @@ struct MarkdownBlockParser: Sendable {
return true
}
+ /// Whether a block starts on `line`, which ends a table's body: GitHub
+ /// closes a table for any block start — a fence, a quote, an ATX
+ /// heading, a list item, an HTML block (gfm/h2_*.html) — and `$$` opens
+ /// a formula here. A fence or `$$` line taken as a row disagreed with the
+ /// definition pre-pass (`VerbatimRegions`), which reads it as a block.
+ static func interruptsTable(_ line: String) -> Bool {
+ !startsNoBlock(line) || markerLine(line).hasPrefix(">")
+ }
+
/// CommonMark's part of `continuesQuoteLazily`: not blank, and no block
/// starts on the line (fence, `$$`, ATX heading, rule, list item, HTML
/// block) — checked against the document, as cmark does.
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 44f8511..e1b0e47 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -900,6 +900,40 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertFalse(again.converted[paragraph.id] === first.converted[paragraph.id])
}
+ // MARK: - Tables
+
+ /// A block start ends a table's body (GFM: GitHub closes the table for
+ /// any of them — a fence, a quote, an ATX heading, a list item, an HTML
+ /// block; gfm/h2_*.html — and `$$` opens a formula here). The body took
+ /// every line with a `|`: a fence line with one became a row while the
+ /// definition pre-pass read it as a fence, and the fence's closing line
+ /// then opened a fence that ran to the end of the document.
+ func testABlockStartEndsATablesBody() throws {
+ let table = "| a |\n|---|\n| 1 |\n"
+ func bodyRows(_ markdown: String) throws -> [Int] {
+ try XCTUnwrap(parseReturning(markdown)).compactMap { block in
+ guard case .table(_, let rows, _, _) = block.content else { return nil }
+ return rows.count
+ }
+ }
+ XCTAssertEqual(try quoteShape(table + "```x|\ncode\n```\nafter\n"), ["table", "c:code", "t:after"])
+ XCTAssertEqual(try quoteShape(table + "~~~ |\ncode\n~~~\n"), ["table", "c:code"])
+ XCTAssertEqual(try quoteShape(table + "> q | r\n"), ["table", "q1:q | r"])
+ XCTAssertEqual(try quoteShape(table + "# H | x\n"), ["table", "h:H | x"])
+ XCTAssertEqual(try quoteShape(table + "- b | c\n"), ["table", "t:• b | c"])
+ XCTAssertEqual(try quoteShape(table + "2. b | c\n"), ["table", "t:2. b | c"])
+ XCTAssertEqual(try bodyRows(table + "|\n"), [1])
+ XCTAssertEqual(try bodyRows(table + "$$ |\nx\n$$\n\n[x]: http://e.com\n\n[x]\n"), [1])
+ XCTAssertEqual(try quoteShape(table + "$$ |\nx\n$$\n\n[x]: http://e.com\n\n[x]\n").last, "t:x")
+ // A row stays a row.
+ XCTAssertEqual(try bodyRows(table + "b | c\n| 3 |\n"), [3])
+ // In a quote too: the item's paragraph goes on lazily, a heading's
+ // does not (gfm/h2_quote_table_*_lazy.html).
+ let quoted = "> | a |\n> |---|\n> | 1 |\n"
+ XCTAssertEqual(try quoteShape(quoted + "> - b | c\nlazy\n"), ["q1:| a |\n|---|\n| 1 |\n- b | c\nlazy"])
+ XCTAssertEqual(try quoteShape(quoted + "> # H | x\nlazy\n"), ["q1:| a |\n|---|\n| 1 |\n# H | x", "t:lazy"])
+ }
+
/// The lines a quote absorbed are the quote's: its source line is its
/// first, the next block starts after them, the tint spans them, search
/// finds and copy reads them in the quote — and a long hard-wrapped quote
From f5425f94fd95a9edac10b8fe451b03ddd6be121d Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:56:29 +0100
Subject: [PATCH 085/105] fix(parser): a tab past an indented fence's
indentation leaves spaces
A fence indented N columns drops up to N columns from each of its lines.
A tab reaching past them was kept whole, so the line was drawn too deep:
`\tTab` under a fence indented 2 showed a tab where CommonMark and GitHub
show " Tab". The columns the tab has left are now spaces; a later tab
stays as it is.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 13 ++++++++-----
QuickMD/QuickMDTests/VerticalRhythmTests.swift | 15 +++++++++++++++
2 files changed, 23 insertions(+), 5 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 80b2c1c..e9f0a4b 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -1058,13 +1058,14 @@ struct MarkdownBlockParser: Sendable {
return columns
}
- /// `line` without up to `columns` columns of its leading spaces and tabs
- /// (a tab that would reach past them stays).
+ /// `line` without up to `columns` columns of its leading spaces and tabs.
+ /// A tab that reaches past them leaves the columns it has left as spaces
+ /// (CommonMark: `\tTab` under a fence indented 2 is ` Tab`).
static func removingIndentation(_ line: String, upTo columns: Int) -> String {
guard columns > 0 else { return line }
var used = 0
var index = line.startIndex
- while index < line.endIndex {
+ while index < line.endIndex, used < columns {
let character = line[index]
let width: Int
if character == " " {
@@ -1074,9 +1075,11 @@ struct MarkdownBlockParser: Sendable {
} else {
break
}
- guard used + width <= columns else { break }
- used += width
index = line.index(after: index)
+ if used + width > columns {
+ return String(repeating: " ", count: used + width - columns) + line[index...]
+ }
+ used += width
}
return String(line[index...])
}
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index e1b0e47..2ece021 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -797,6 +797,21 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(DocumentSearch.computeMatches(in: blocks, term: "let needle").matchBlockIds, [blocks[1].id])
}
+ /// A tab that reaches past the fence's indentation keeps the columns it
+ /// has left, as spaces (CommonMark; GitHub, gfm/h3_*.html): the tab was
+ /// kept whole, a code line drawn two columns too deep. A later tab stays.
+ func testAnIndentedFenceKeepsWhatATabHasLeft() throws {
+ XCTAssertEqual(try quoteShape("- a\n\n ```\n\tTab\n ```\n"), ["t:• a", "c: Tab"])
+ XCTAssertEqual(try quoteShape(" ```\n\tTab\n ```\n"), ["c: Tab"])
+ XCTAssertEqual(try quoteShape(" ```\n\tTab\n ```\n"), ["c: Tab"])
+ XCTAssertEqual(try quoteShape(" ```\n\t\tTab\n ```\n"), ["c: \tTab"])
+ XCTAssertEqual(try quoteShape(" ```\n \tTab\n ```\n"), ["c: Tab"])
+ // A tab inside the indentation goes, one after it stays whole.
+ XCTAssertEqual(MarkdownBlockParser.removingIndentation("\tx", upTo: 4), "x")
+ XCTAssertEqual(MarkdownBlockParser.removingIndentation(" \tx", upTo: 2), "\tx")
+ XCTAssertEqual(MarkdownBlockParser.removingIndentation("x", upTo: 2), "x")
+ }
+
/// Source Edit finds a selection made in such a block in the source,
/// whose lines still carry the indentation: a multi-line selection is
/// matched with the spaces and tabs at the start of each source line
From 61b9a6d741dbe83b642432ff7cf9bcef6ba0dfe9 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 22:59:33 +0100
Subject: [PATCH 086/105] fix(parser): a link definition may start a quote's
paragraph
`> [x]: url` is a definition on GitHub: it defines `x` and leaves the
quote empty. It was shown as the quote's text. The definition pre-pass
now takes a link definition on a quote line that starts a paragraph of
its quote (DefinitionPlacement.linkDefinitionText,
QuoteTip.admitsDefinition). It stays text after the quote's text (a
definition cannot interrupt a paragraph), in the quote's fence or table,
and after an alert's marker, as on GitHub.
One GitHub definition stays text: a deeper quote opened right under the
quote's text (`> a`, then `> > [x]: url`).
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 47 ++++++++++++++-----
.../QuickMDTests/VerticalRhythmTests.swift | 27 +++++++++++
2 files changed, 63 insertions(+), 11 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index e9f0a4b..9ac3c1b 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -109,11 +109,11 @@ struct MarkdownBlockParser: Sendable {
footnoteDefinitions.append((id: fnId, content: fnContent))
if !footnoteIds.contains(fnId) { footnoteIds.append(fnId) }
placement.definition()
- } else if !placement.paragraphOpen,
- let match = Self.refLinkDefRegex.firstMatch(in: trimmed, range: nsRange),
- let idRange = Range(match.range(at: 1), in: trimmed),
- let urlRange = Range(match.range(at: 2), in: trimmed) {
- referenceDefinitions[String(trimmed[idRange]).lowercased()] = String(trimmed[urlRange])
+ } else if let text = placement.linkDefinitionText(trimmed),
+ let match = Self.refLinkDefRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ let idRange = Range(match.range(at: 1), in: text),
+ let urlRange = Range(match.range(at: 2), in: text) {
+ referenceDefinitions[String(text[idRange]).lowercased()] = String(text[urlRange])
placement.definition()
} else {
lines.append(line)
@@ -1157,6 +1157,11 @@ struct MarkdownBlockParser: Sendable {
private var inTable = false
private var previousHadPipe = false
+ /// Whether the group's next line would start a paragraph: none is
+ /// open, and it is not in the quote's fence or table — where a link
+ /// definition may stand (`DefinitionPlacement`).
+ var admitsDefinition: Bool { fence == nil && !inTable && !endsInParagraph }
+
/// A lazily continued line (no marker, or fewer markers than the
/// group): paragraph text whatever it looks like — CommonMark never
/// reads a lazy line as a table separator, a Setext underline or any
@@ -1278,6 +1283,31 @@ struct MarkdownBlockParser: Sendable {
quote = nil
}
+ /// What a LINK definition on the next line (its `markerLine`) would
+ /// be read from, or nil where none may stand: the line itself where
+ /// no paragraph is open; what follows a quote line's markers where
+ /// the line starts a paragraph of its quote (GitHub: `> [x]: url`
+ /// defines `x` and leaves the quote empty) — not after the quote's
+ /// text, nor in its fence or table. One case GitHub reads as a
+ /// definition stays text: a deeper quote opened right under the
+ /// quote's text (`> a`, then `> > [x]: url`).
+ func linkDefinitionText(_ marker: String) -> String? {
+ guard marker.hasPrefix(">") else { return paragraphOpen ? nil : marker }
+ guard quote?.admitsDefinition ?? true else { return nil }
+ return MarkdownBlockParser.markerLine(String(Self.quoteContent(of: marker)))
+ }
+
+ /// What follows a quote line's markers (each `>` with one optional
+ /// space after it).
+ static func quoteContent(of marker: String) -> Substring {
+ var content = marker[...]
+ while content.hasPrefix(">") {
+ content = content.dropFirst()
+ if content.hasPrefix(" ") { content = content.dropFirst() }
+ }
+ return content
+ }
+
/// Any other line, kept for the block loop.
mutating func kept(_ line: String) {
if MarkdownRenderer.isBlankLine(line) {
@@ -1287,13 +1317,8 @@ struct MarkdownBlockParser: Sendable {
}
let marker = MarkdownBlockParser.markerLine(line)
if marker.hasPrefix(">") {
- var content = marker[...]
- while content.hasPrefix(">") {
- content = content.dropFirst()
- if content.hasPrefix(" ") { content = content.dropFirst() }
- }
var tip = quote ?? QuoteTip()
- tip.take(String(content))
+ tip.take(String(Self.quoteContent(of: marker)))
quote = tip
paragraphOpen = tip.endsInParagraph
return
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 2ece021..06391df 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -760,6 +760,33 @@ final class VerticalRhythmTests: XCTestCase {
/// lazy line, a table's — it is that text, and `[x]` stays literal.
/// The pre-pass took it out anywhere. A footnote definition MAY
/// interrupt a paragraph (GitHub). Every shape: gfm/def_*.html, fn_*.html.
+ /// A link definition that starts a paragraph of a quote is a definition
+ /// (GitHub, gfm/h4_*.html): `> [x]: url` defines `x` and leaves the
+ /// quote empty — it was the quote's text. After the quote's text it is
+ /// that text (G7), in the quote's fence code, in its table a row, after
+ /// an alert's marker the alert's text.
+ func testALinkDefinitionMayStartAQuoteParagraph() throws {
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n\n[x]\n"), ["t:x"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> text\n\n[x]\n"), ["q1:text", "t:x"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [y]: http://f.com\n\n[x] [y]\n"), ["t:x y"])
+ XCTAssertEqual(try quoteShape("> > [x]: http://e.com\n\n[x]\n"), ["t:x"])
+ XCTAssertEqual(try quoteShape("Para\n> [x]: http://e.com\n\n[x]\n"), ["t:Para", "t:x"])
+ XCTAssertEqual(try quoteShape("> # H\n> [x]: http://e.com\n\n[x]\n"), ["q1:# H", "t:x"])
+ XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n\n[x]\n"), ["q1:text", "t:x"])
+ XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n>\n> more\n\n[x]\n"),
+ ["q1:text\n\n\nmore", "t:x"])
+ // Text where GitHub has text.
+ XCTAssertEqual(try quoteShape("> text\n> [x]: http://e.com\n\n[x]\n"),
+ ["q1:text\n[x]: http://e.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("> ```\n> [x]: http://e.com\n> ```\n\n[x]\n").last, "t:[x]")
+ XCTAssertEqual(try quoteShape("> | a |\n> |---|\n> | 1 |\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]")
+ XCTAssertEqual(try quoteShape("> [!NOTE]\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]")
+ XCTAssertEqual(try quoteShape("> > a\n> [x]: http://e.com\n\n[x]\n").last, "t:[x]")
+ // What follows keeps its source line.
+ let blocks = try XCTUnwrap(parseReturning("> [x]: http://e.com\n> text\n\n[x]\n"))
+ XCTAssertEqual(blocks.map(\.sourceLine), [1, 3])
+ }
+
func testALinkDefinitionCannotInterruptAParagraph() throws {
XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"])
XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"])
From 11f924d3c3999dd284ed011becfa06439c5199f7 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:07:07 +0100
Subject: [PATCH 087/105] fix(quote): a run of blank lines in a quote is one
paragraph break
Two blank `>` lines drew two gaps in a quote or an alert, at any
nesting level. GitHub gives one paragraph break, as our top-level rhythm
does, where spacing comes from structure, not from blank lines. A
definition the pre-pass takes out between two blank lines left the same
double gap.
The parser now makes each run of blank lines in a quote group one
(MarkdownBlockParser.collapsingBlankRuns); blank lines inside the
quote's fence are code and stay. The view, measurer, search and print
all read that content. Blank lines at a quote's start or end still add
nothing.
Copy pasted the quote's blank line as a line holding one space (the body
draws a blank line as a space to keep its height). A line of only spaces
and tabs is now copied as an empty line, so paragraphs copy with one
blank line between them.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/DocumentSelection.swift | 17 ++++++++++
QuickMD/QuickMD/MarkdownBlockParser.swift | 25 ++++++++++++++
.../QuickMDTests/VerticalRhythmTests.swift | 34 +++++++++++++++++--
3 files changed, 74 insertions(+), 2 deletions(-)
diff --git a/QuickMD/QuickMD/DocumentSelection.swift b/QuickMD/QuickMD/DocumentSelection.swift
index e80da23..7921990 100644
--- a/QuickMD/QuickMD/DocumentSelection.swift
+++ b/QuickMD/QuickMD/DocumentSelection.swift
@@ -371,6 +371,7 @@ enum DocumentCopyBuilder {
case .text(let text):
let cleaned = replacingAttachments(in: text)
trimBlankLines(cleaned)
+ emptyWhitespaceLines(cleaned)
collapseBlankLineRuns(cleaned)
return cleaned
case .code(let code):
@@ -487,6 +488,22 @@ enum DocumentCopyBuilder {
}
}
+ private static let whitespaceLine = try? NSRegularExpression(pattern: "^[ \t]+$",
+ options: .anchorsMatchLines)
+
+ /// A line of nothing but spaces and tabs becomes an empty line: a quote
+ /// or alert body draws a blank line as one space so it keeps its height
+ /// (`renderQuotedBody`), and a paragraph break must paste as an empty
+ /// line, not as a space. Only lines between others are left by
+ /// `trimBlankLines`; a line of no-break spaces is text and stays.
+ private static func emptyWhitespaceLines(_ text: NSMutableAttributedString) {
+ guard let whitespaceLine else { return }
+ let matches = whitespaceLine.matches(in: text.string, range: NSRange(location: 0, length: text.length))
+ for match in matches.reversed() {
+ text.replaceCharacters(in: match.range, with: "")
+ }
+ }
+
private static let blankLineRun = try? NSRegularExpression(pattern: "\n{3,}")
/// Three or more consecutive line breaks become two: a renderer chunk
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 9ac3c1b..d26c5cf 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -445,6 +445,8 @@ struct MarkdownBlockParser: Sendable {
i += 1
}
+ // One paragraph break per run of blank lines (`collapsingBlankRuns`).
+ groupLines = Self.collapsingBlankRuns(groupLines)
let content = groupLines.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !content.isEmpty {
@@ -1042,6 +1044,29 @@ struct MarkdownBlockParser: Sendable {
// MARK: - Fences and `$$` blocks
+ /// A quote's or alert's lines with each run of blank lines made ONE: a
+ /// blank line only separates paragraphs, and any run of them is one
+ /// paragraph break (GitHub, every nesting level; a definition the
+ /// pre-pass took out between two blank lines leaves none of its own).
+ /// Inside the quote's fence a blank line is code and stays.
+ static func collapsingBlankRuns(_ lines: [String]) -> [String] {
+ var result: [String] = []
+ result.reserveCapacity(lines.count)
+ var fence: Fence?
+ for line in lines {
+ let marker = markerLine(line)
+ if let open = fence {
+ if closesFence(marker, open) { fence = nil }
+ } else if let opening = fenceOpening(marker) {
+ fence = opening
+ } else if isBlank(line), let last = result.last, isBlank(last) {
+ continue
+ }
+ result.append(line)
+ }
+ return result
+ }
+
/// Columns of the spaces and tabs a line starts with (a tab runs to the
/// next multiple of 4, CommonMark).
static func indentColumns(_ line: String) -> Int {
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 06391df..a1d3e11 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -765,6 +765,38 @@ final class VerticalRhythmTests: XCTestCase {
/// quote empty — it was the quote's text. After the quote's text it is
/// that text (G7), in the quote's fence code, in its table a row, after
/// an alert's marker the alert's text.
+ /// A run of blank lines in a quote or an alert is ONE paragraph break,
+ /// at every nesting level (GitHub, gfm/h5_*.html; at the top level our
+ /// spacing comes from structure, not from blank lines): two blank `>`
+ /// lines drew two gaps. Blank lines at a quote's start or end add
+ /// nothing; inside the quote's fence they are code. The body every
+ /// consumer reads (view, measurer, search, copy, print) has one.
+ func testARunOfBlankQuoteLinesIsOneParagraphBreak() throws {
+ XCTAssertEqual(try quoteShape("> a\n>\n>\n> b\n"), ["q1:a\n\nb"])
+ XCTAssertEqual(try quoteShape("> a\n>\n>\n>\n> b\n>\n>\n> c\n"), ["q1:a\n\nb\n\nc"])
+ XCTAssertEqual(try quoteShape("> > a\n> >\n> >\n> > b\n"), ["q2:a\n\nb"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\n> a\n>\n>\n> b\n"), ["alert:note:a\n\nb"])
+ XCTAssertEqual(try quoteShape("> [!NOTE]\n>\n>\n> a\n>\n>\n"), ["alert:note:a"])
+ XCTAssertEqual(try quoteShape(">\n>\n> a\n>\n>\n\nafter\n"), ["q1:a", "t:after"])
+ XCTAssertEqual(try quoteShape("> a\n>\n>\n> > b\n> >\n> >\n> c\n"), ["q1:a", "q2:b", "q1:c"])
+ // A definition taken out (H4) leaves no gap of its own.
+ XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n>\n> more\n\n[x]\n"),
+ ["q1:text\n\nmore", "t:x"])
+ // Code keeps its blank lines.
+ XCTAssertEqual(try quoteShape("> ```\n> a\n>\n>\n> b\n> ```\n"), ["q1:```\na\n\n\nb\n```"])
+
+ let theme = MarkdownTheme.cached(for: .light)
+ let blocks = try XCTUnwrap(parseReturning("> a\n>\n>\n> b\n\n> [!TIP]\n> c\n>\n>\n> d\n"))
+ let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1,
+ contentWidth: 600, math: nil)
+ XCTAssertEqual(blocks.map { measured.converted[$0.id]?.string }, ["a\n \nb", "c\n \nd"])
+ let all = try XCTUnwrap(DocumentSelection.selectAll(rowCount: blocks.count,
+ lengths: { measured.converted[blocks[$0].id]?.length ?? 0 }))
+ let parts = SelectionRows.parts(of: all, blocks: blocks, text: { measured.converted[$0.id] },
+ table: { _ in nil })
+ XCTAssertEqual(DocumentCopyBuilder.build(parts: parts).plain, "a\n\nb\n\nc\n\nd")
+ }
+
func testALinkDefinitionMayStartAQuoteParagraph() throws {
XCTAssertEqual(try quoteShape("> [x]: http://e.com\n\n[x]\n"), ["t:x"])
XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> text\n\n[x]\n"), ["q1:text", "t:x"])
@@ -773,8 +805,6 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(try quoteShape("Para\n> [x]: http://e.com\n\n[x]\n"), ["t:Para", "t:x"])
XCTAssertEqual(try quoteShape("> # H\n> [x]: http://e.com\n\n[x]\n"), ["q1:# H", "t:x"])
XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n\n[x]\n"), ["q1:text", "t:x"])
- XCTAssertEqual(try quoteShape("> text\n>\n> [x]: http://e.com\n>\n> more\n\n[x]\n"),
- ["q1:text\n\n\nmore", "t:x"])
// Text where GitHub has text.
XCTAssertEqual(try quoteShape("> text\n> [x]: http://e.com\n\n[x]\n"),
["q1:text\n[x]: http://e.com", "t:[x]"])
From 324453f028d6a3a71420ed1560341d806669ad48 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:13:07 +0100
Subject: [PATCH 088/105] fix(anchors): a code span across a Setext title's
break keeps a space in the id
A line break inside a code span is a space (CommonMark), so on GitHub
`code` / `span` inside one code span, under a Setext underline, is
`#code-span`. Outside a code span the break is dropped (`#codespan` for
two spans). Since the anchor text keeps a multi-line title's breaks
(H1), the span kept its break too and the slug dropped it. The inline
code renderer now turns a line break inside a span into a space. Only
the anchor text still has one there.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownRenderer.swift | 5 ++++-
QuickMD/QuickMDTests/SectionExtractorTests.swift | 14 ++++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift
index d7b19b8..818d5b4 100644
--- a/QuickMD/QuickMD/MarkdownRenderer.swift
+++ b/QuickMD/QuickMD/MarkdownRenderer.swift
@@ -840,7 +840,10 @@ struct MarkdownRenderer: Sendable {
}
}
- var code = String(afterOpening[.. 1 {
diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift
index 2f3d52a..2e142cf 100644
--- a/QuickMD/QuickMDTests/SectionExtractorTests.swift
+++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift
@@ -195,6 +195,20 @@ final class SectionExtractorTests: XCTestCase {
"One \nTwo\\\nThree\n---")
}
+ /// A line break inside a code span is a space (CommonMark), so a code
+ /// span across a Setext title's break keeps one in the id: `code-span`.
+ /// Outside a code span the break is dropped, also between two spans
+ /// (GitHub, gfm/h6_*.html). The span kept the break, which the slug
+ /// dropped: `codespan`.
+ func testACodeSpanAcrossASetextBreakKeepsASpaceInTheId() {
+ let md = "`code\nspan`\n===\n\n`code`\n`span`\n===\n\n``a\nb``\n===\n\nUse `a\nb` and\nmore\n---\n"
+ let anchors = anchorHeadings(of: md)
+ XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)),
+ ["code-span", "codespan", "a-b", "use-a-b-andmore"])
+ XCTAssertEqual(HeadingAnchors.target(forFragment: "code-span", in: anchors), .heading(anchors[0].id))
+ XCTAssertEqual(headings(of: md).map(\.title), ["`code span`", "`code` `span`", "``a b``", "Use `a b` and more"])
+ }
+
func testFragmentFindsItsHeading() {
let headings = anchorHeadings(of: """
# Guide
From 8c0fd97bef4ac50274d8a1bf83c3409e232d869a Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:15:12 +0100
Subject: [PATCH 089/105] fix(parser): a definition opening a deeper quote is a
definition
A deeper `>` opens a nested quote whose first line starts a new
paragraph, so on GitHub `> a` then `>> [x]: url` is a definition: an
empty nested quote, with `[x]` a link. The definition pre-pass read
every quote level as one quote, so the line continued "a" and stayed
the nested quote's text.
DefinitionPlacement now groups quote lines as the block loop does: a
deeper line opens a new group with a fresh QuoteTip, and a shallower
line continues the group lazily or starts a new one. Lazy text stays
text (`>> a` then `> [x]: url`), and so does a nested quote that a lazy
line kept open. A deeper `>` inside the quote's fence is code.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 66 +++++++++++++++----
.../QuickMDTests/VerticalRhythmTests.swift | 19 ++++++
2 files changed, 73 insertions(+), 12 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index d26c5cf..3537341 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -1187,6 +1187,9 @@ struct MarkdownBlockParser: Sendable {
/// definition may stand (`DefinitionPlacement`).
var admitsDefinition: Bool { fence == nil && !inTable && !endsInParagraph }
+ /// Inside the quote's fence.
+ var inFence: Bool { fence != nil }
+
/// A lazily continued line (no marker, or fewer markers than the
/// group): paragraph text whatever it looks like — CommonMark never
/// reads a lazy line as a table separator, a Setext underline or any
@@ -1293,8 +1296,12 @@ struct MarkdownBlockParser: Sendable {
/// outside fenced code.)
struct DefinitionPlacement {
private(set) var paragraphOpen = false
- /// The quote being read: what its lines end in (`QuoteTip`).
+ /// The quote group being read, grouped as the block loop groups it:
+ /// what its lines end in (`QuoteTip`) and its `>` count. A line with
+ /// more markers opens a nested quote, a new group; one with fewer
+ /// goes on lazily or starts a new group.
private var quote: QuoteTip?
+ private var quoteLevel = 0
/// A line of a fenced code or `$$` block (`VerbatimRegions`).
mutating func verbatim() {
@@ -1312,25 +1319,50 @@ struct MarkdownBlockParser: Sendable {
/// be read from, or nil where none may stand: the line itself where
/// no paragraph is open; what follows a quote line's markers where
/// the line starts a paragraph of its quote (GitHub: `> [x]: url`
- /// defines `x` and leaves the quote empty) — not after the quote's
- /// text, nor in its fence or table. One case GitHub reads as a
- /// definition stays text: a deeper quote opened right under the
- /// quote's text (`> a`, then `> > [x]: url`).
+ /// defines `x` and leaves the quote empty) — a new quote, a deeper
+ /// one opened under the quote's text (`> a`, then `>> [x]: url`), or
+ /// a line of the group after no open paragraph. Not lazy text, nor
+ /// in the quote's fence or table.
func linkDefinitionText(_ marker: String) -> String? {
guard marker.hasPrefix(">") else { return paragraphOpen ? nil : marker }
- guard quote?.admitsDefinition ?? true else { return nil }
- return MarkdownBlockParser.markerLine(String(Self.quoteContent(of: marker)))
+ let (level, content) = Self.quoteMarkers(of: marker)
+ let text = MarkdownBlockParser.markerLine(String(content))
+ switch step(level: level, content: String(content)) {
+ case .same:
+ return quote?.admitsDefinition == true ? text : nil
+ case .lazy:
+ return nil
+ case .newGroup:
+ // A deeper `>` inside the quote's fence is that fence's code.
+ if level > quoteLevel, quote?.inFence == true { return nil }
+ return text
+ }
}
- /// What follows a quote line's markers (each `>` with one optional
- /// space after it).
- static func quoteContent(of marker: String) -> Substring {
+ /// How a quote line goes on from the group being read — the block
+ /// loop's grouping (lazy: fewer markers, continuing its paragraph).
+ private enum QuoteStep { case same, lazy, newGroup }
+
+ private func step(level: Int, content: String) -> QuoteStep {
+ guard let tip = quote else { return .newGroup }
+ if level == quoteLevel { return .same }
+ if level < quoteLevel, tip.endsInParagraph, MarkdownBlockParser.startsNoBlock(content) {
+ return .lazy
+ }
+ return .newGroup
+ }
+
+ /// A quote line's `>` count and what follows its markers (each `>`
+ /// with one optional space after it).
+ static func quoteMarkers(of marker: String) -> (level: Int, content: Substring) {
var content = marker[...]
+ var level = 0
while content.hasPrefix(">") {
content = content.dropFirst()
+ level += 1
if content.hasPrefix(" ") { content = content.dropFirst() }
}
- return content
+ return (level, content)
}
/// Any other line, kept for the block loop.
@@ -1342,8 +1374,18 @@ struct MarkdownBlockParser: Sendable {
}
let marker = MarkdownBlockParser.markerLine(line)
if marker.hasPrefix(">") {
+ let (level, content) = Self.quoteMarkers(of: marker)
var tip = quote ?? QuoteTip()
- tip.take(String(Self.quoteContent(of: marker)))
+ switch step(level: level, content: String(content)) {
+ case .same:
+ tip.take(String(content))
+ case .lazy:
+ tip.takeLazy()
+ case .newGroup:
+ tip = QuoteTip()
+ tip.take(String(content))
+ quoteLevel = level
+ }
quote = tip
paragraphOpen = tip.endsInParagraph
return
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index a1d3e11..41517d5 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -817,6 +817,25 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(blocks.map(\.sourceLine), [1, 3])
}
+ /// A deeper `>` opens a nested quote, whose first line starts a new
+ /// paragraph: a link definition there is a definition, also right under
+ /// the quote's text (GitHub, gfm/h7_*.html). The pre-pass read every
+ /// level as one quote, so `> a` then `>> [x]: url` was the nested
+ /// quote's text and `[x]` stayed literal. Fewer markers still continue
+ /// the paragraph lazily, and so does a nested quote a lazy line kept
+ /// open; inside the quote's fence a deeper `>` is code.
+ func testADefinitionOpeningADeeperQuoteIsADefinition() throws {
+ XCTAssertEqual(try quoteShape("> a\n>> [x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"])
+ XCTAssertEqual(try quoteShape("> a\n> > [x]: http://e.com\n\n[x]\n"), ["q1:a", "t:x"])
+ XCTAssertEqual(try quoteShape("> a\n> > b\n> > > [x]: http://e.com\n\n[x]\n"), ["q1:a", "q2:b", "t:x"])
+ XCTAssertEqual(try quoteShape("> | a |\n> |---|\n> | 1 |\n>> [x]: http://e.com\n\n[x]\n").last, "t:x")
+ XCTAssertEqual(try quoteShape("> > ```\n> > c\n> > ```\n> [x]: http://e.com\n\n[x]\n").last, "t:x")
+ // Text where GitHub has text.
+ XCTAssertEqual(try quoteShape(">> a\n> [x]: http://e.com\n\n[x]\n"), ["q2:a\n[x]: http://e.com", "t:[x]"])
+ XCTAssertEqual(try quoteShape("> > a\n> b\n> > [x]: http://e.com\n\n[x]\n").last, "t:[x]")
+ XCTAssertEqual(try quoteShape("> ```\n>> [x]: http://e.com\n> ```\n\n[x]\n").last, "t:[x]")
+ }
+
func testALinkDefinitionCannotInterruptAParagraph() throws {
XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"])
XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"])
From 7aea0cd89779ba58294037fa516c8d22aae08525 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:30:14 +0100
Subject: [PATCH 090/105] fix(parser): a lazy line after a quote's definition
stays in the quote
A link definition the pre-pass takes out of a quote opens a paragraph of
that quote (CommonMark), so on GitHub a lazy line right after it stays
in the quote: `> [y]: url` then `lazy` is a quote "lazy", and under
`> a` / `>> [z]: url` the line `code` is the inner quote's. The pre-pass
dropped the line, so the block loop never saw that paragraph and the
lazy line landed outside the quote or in the outer one.
The line now stays in `lines` as a marker without text
(definitionLines). The block loop's quote group takes it as an open
paragraph of definitions only (QuoteTip.takeDefinition) and draws
nothing for it: a group with only markers is no block, and a group
starts at its first line of text. The pre-pass follows the same state
(DefinitionPlacement.linkDefinition), so more definitions may follow:
marked, lazy or with fewer markers. After them `===` is text, as on
GitHub.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 109 +++++++++++++++---
.../QuickMDTests/VerticalRhythmTests.swift | 37 ++++++
2 files changed, 129 insertions(+), 17 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 3537341..7b8a681 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -84,6 +84,10 @@ struct MarkdownBlockParser: Sendable {
// Headings carry the original line number so section copy can slice the
// raw text without re-detecting headings itself.
var lineMap: [Int] = []
+ // Indices in `lines` of quote lines a link definition was taken out
+ // of: kept as markers without text, so the block loop knows the
+ // quote's paragraph they open (`DefinitionPlacement.linkDefinition`).
+ var definitionLines = Set()
// Fenced code and `$$` blocks, read as the block loop reads them: a
// definition inside one is that block's text (GitHub).
var verbatim = VerbatimRegions()
@@ -114,7 +118,11 @@ struct MarkdownBlockParser: Sendable {
let idRange = Range(match.range(at: 1), in: text),
let urlRange = Range(match.range(at: 2), in: text) {
referenceDefinitions[String(text[idRange]).lowercased()] = String(text[urlRange])
- placement.definition()
+ if placement.linkDefinition(trimmed) {
+ lines.append(line)
+ lineMap.append(originalIndex)
+ definitionLines.insert(lines.count - 1)
+ }
} else {
lines.append(line)
lineMap.append(originalIndex)
@@ -393,11 +401,20 @@ struct MarkdownBlockParser: Sendable {
// A single `>` run can yield SEVERAL blocks (one per nesting
// level), so each group needs its OWN first line — the outer
// `sourceLine` only describes the first group.
- let groupSourceLine = lineMap[i]
+ // A definition marker (`definitionLines`) opens the
+ // quote's paragraph for a lazy line and draws nothing:
+ // the group starts at its first line of text.
+ var groupSourceLine: Int?
let groupLevel = level
- var groupLines = [String(scanner)]
+ var groupLines: [String] = []
var tip = QuoteTip()
- tip.take(groupLines[0])
+ if definitionLines.contains(i) {
+ tip.takeDefinition()
+ } else {
+ groupLines.append(String(scanner))
+ groupSourceLine = lineMap[i]
+ tip.take(groupLines[0])
+ }
i += 1
while i < lines.count {
@@ -410,6 +427,7 @@ struct MarkdownBlockParser: Sendable {
references: referenceDefinitions)
else { break }
groupLines.append(lines[i])
+ groupSourceLine = groupSourceLine ?? lineMap[i]
tip.takeLazy()
i += 1
continue
@@ -435,13 +453,23 @@ struct MarkdownBlockParser: Sendable {
htmlImageLine: HTMLImageSyntax.scan(content[...]) != nil,
references: referenceDefinitions)
else { break }
- groupLines.append(content)
- tip.takeLazy()
+ if definitionLines.contains(i) {
+ tip.takeDefinition()
+ } else {
+ groupLines.append(content)
+ groupSourceLine = groupSourceLine ?? lineMap[i]
+ tip.takeLazy()
+ }
i += 1
continue
}
- groupLines.append(String(nextScanner))
- tip.take(groupLines[groupLines.count - 1])
+ if definitionLines.contains(i) {
+ tip.takeDefinition()
+ } else {
+ groupLines.append(String(nextScanner))
+ groupSourceLine = groupSourceLine ?? lineMap[i]
+ tip.take(groupLines[groupLines.count - 1])
+ }
i += 1
}
@@ -449,7 +477,7 @@ struct MarkdownBlockParser: Sendable {
groupLines = Self.collapsingBlankRuns(groupLines)
let content = groupLines.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
- if !content.isEmpty {
+ if !content.isEmpty, let groupSourceLine {
// GFM alert: level-1 quote whose first line is exactly
// `[!NOTE]` / `[!TIP]` / `[!IMPORTANT]` / `[!WARNING]` /
// `[!CAUTION]` (marker alone on the line, per GitHub spec).
@@ -1181,11 +1209,24 @@ struct MarkdownBlockParser: Sendable {
private var fence: Fence?
private var inTable = false
private var previousHadPipe = false
-
- /// Whether the group's next line would start a paragraph: none is
- /// open, and it is not in the quote's fence or table — where a link
- /// definition may stand (`DefinitionPlacement`).
- var admitsDefinition: Bool { fence == nil && !inTable && !endsInParagraph }
+ /// The open paragraph holds link definitions only, so far: another
+ /// may follow, and it draws nothing (`takeDefinition`).
+ private(set) var onlyDefinitions = false
+
+ /// Whether a link definition may stand on the group's next line: it
+ /// would start a paragraph, or go on with one of definitions only —
+ /// not in the quote's fence or table (`DefinitionPlacement`).
+ var admitsDefinition: Bool { fence == nil && !inTable && (!endsInParagraph || onlyDefinitions) }
+
+ /// A link definition the pre-pass took out: it opens (or goes on
+ /// with) a paragraph of definitions only, which a lazy line continues
+ /// in this quote (CommonMark, GitHub).
+ mutating func takeDefinition() {
+ endsInParagraph = true
+ onlyDefinitions = true
+ previousHadPipe = false
+ inTable = false
+ }
/// Inside the quote's fence.
var inFence: Bool { fence != nil }
@@ -1196,12 +1237,16 @@ struct MarkdownBlockParser: Sendable {
/// other structure, so the paragraph stays open for the next one.
mutating func takeLazy() {
endsInParagraph = true
+ onlyDefinitions = false
previousHadPipe = false
}
/// A line of the group itself (marked at the group's level).
mutating func take(_ content: String) {
let marker = MarkdownBlockParser.markerLine(content)
+ // Under definitions only, `===` is text (CommonMark).
+ let afterDefinitions = onlyDefinitions
+ onlyDefinitions = false
if let open = fence {
// Inside the quote's fence: only its closing line matters.
if MarkdownBlockParser.closesFence(marker, open) { fence = nil }
@@ -1238,7 +1283,7 @@ struct MarkdownBlockParser: Sendable {
endsInParagraph = false
return
}
- if endsInParagraph,
+ if endsInParagraph, !afterDefinitions,
marker.range(of: MarkdownTheme.setextH1Pattern, options: .regularExpression) != nil
|| marker.range(of: MarkdownTheme.setextH2Pattern, options: .regularExpression) != nil {
endsInParagraph = false
@@ -1324,14 +1369,20 @@ struct MarkdownBlockParser: Sendable {
/// a line of the group after no open paragraph. Not lazy text, nor
/// in the quote's fence or table.
func linkDefinitionText(_ marker: String) -> String? {
- guard marker.hasPrefix(">") else { return paragraphOpen ? nil : marker }
+ guard marker.hasPrefix(">") else {
+ // A lazy line of a quote paragraph of definitions only: another.
+ if let tip = quote, tip.endsInParagraph {
+ return tip.onlyDefinitions && MarkdownBlockParser.startsNoBlock(marker) ? marker : nil
+ }
+ return paragraphOpen ? nil : marker
+ }
let (level, content) = Self.quoteMarkers(of: marker)
let text = MarkdownBlockParser.markerLine(String(content))
switch step(level: level, content: String(content)) {
case .same:
return quote?.admitsDefinition == true ? text : nil
case .lazy:
- return nil
+ return quote?.onlyDefinitions == true ? text : nil
case .newGroup:
// A deeper `>` inside the quote's fence is that fence's code.
if level > quoteLevel, quote?.inFence == true { return nil }
@@ -1339,6 +1390,30 @@ struct MarkdownBlockParser: Sendable {
}
}
+ /// A LINK definition taken out of `marker` (a `markerLine`). Out of a
+ /// quote line, it opens a paragraph of that quote — of definitions
+ /// only, which draws nothing and which a lazy line continues in the
+ /// quote (GitHub) — and the line stays for the block loop as a
+ /// marker: true. A lazy definition after such a paragraph goes on
+ /// with it; any other definition ends what came before.
+ mutating func linkDefinition(_ marker: String) -> Bool {
+ if marker.hasPrefix(">") {
+ let (level, content) = Self.quoteMarkers(of: marker)
+ var tip = quote ?? QuoteTip()
+ if step(level: level, content: String(content)) == .newGroup {
+ tip = QuoteTip()
+ quoteLevel = level
+ }
+ tip.takeDefinition()
+ quote = tip
+ paragraphOpen = true
+ return true
+ }
+ if let tip = quote, tip.endsInParagraph, tip.onlyDefinitions { return false }
+ definition()
+ return false
+ }
+
/// How a quote line goes on from the group being read — the block
/// loop's grouping (lazy: fewer markers, continuing its paragraph).
private enum QuoteStep { case same, lazy, newGroup }
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index 41517d5..fa764e8 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -836,6 +836,43 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(try quoteShape("> ```\n>> [x]: http://e.com\n> ```\n\n[x]\n").last, "t:[x]")
}
+ /// A link definition taken out of a quote opens a paragraph of that
+ /// quote — of definitions only, which draws nothing — and a lazy line
+ /// right after it goes on in that quote (GitHub, gfm/h8_*.html). The
+ /// pre-pass dropped the line, so the lazy line landed outside the quote
+ /// or in the outer one. More definitions may follow (marked, lazy or at
+ /// fewer markers); after them `===` is text. Nothing follows: nothing is
+ /// drawn, as before.
+ func testALazyLineAfterAQuoteDefinitionStaysInTheQuote() throws {
+ XCTAssertEqual(try quoteShape("> [y]: http://e.com\nlazy\n\n[y]\n"), ["q1:lazy", "t:y"])
+ XCTAssertEqual(try quoteShape("> a\n>\n> [y]: http://e.com\nlazy\n\n[y]\n"), ["q1:a\n\nlazy", "t:y"])
+ XCTAssertEqual(try quoteShape("> a\n>> [z]: http://e.com\ncode\n\n[z]\n"), ["q1:a", "q2:code", "t:z"])
+ XCTAssertEqual(try quoteShape("> a\n>> [z]: http://e.com\n> b\n\n[z]\n"), ["q1:a", "q2:b", "t:z"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> text\nlazy\n\n[x]\n"), ["q1:text\nlazy", "t:x"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n[y]: http://f.com\nlazy\n\n[x] [y]\n"),
+ ["q1:lazy", "t:x y"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [y]: http://f.com\nlazy\n\n[x] [y]\n"),
+ ["q1:lazy", "t:x y"])
+ XCTAssertEqual(try quoteShape("> > [x]: http://e.com\n> [y]: http://f.com\n\n[x] [y]\n"), ["t:x y"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> ===\nlazy\n\n[x]\n"), ["q1:===\nlazy", "t:x"])
+ XCTAssertEqual(try quoteShape("> [x]: http://e.com\n> [!NOTE]\n> body\n\n[x]\n"), ["alert:note:body", "t:x"])
+ // Nothing follows, or a block does: no quote drawn.
+ XCTAssertEqual(try quoteShape("> [y]: http://e.com\n\n[y]\n"), ["t:y"])
+ XCTAssertEqual(try quoteShape("> [y]: http://e.com\n# H\n\n[y]\n"), ["h:H", "t:y"])
+
+ // The quote starts at its first drawn line; every later block keeps
+ // its source line; no definition is drawn.
+ let markdown = "> a\n>> [z]: http://e.com\ncode\n\n> [y]: http://f.com\nlazy\n\n[z] [y]\n"
+ let blocks = try XCTUnwrap(parseReturning(markdown))
+ XCTAssertEqual(blocks.map(\.sourceLine), [0, 2, 5, 7])
+ let theme = MarkdownTheme.cached(for: .light)
+ let measured = BlockHeightMeasurer.measure(blocks: blocks, theme: theme, fontScale: 1,
+ contentWidth: 600, math: nil)
+ let shown = blocks.compactMap { measured.converted[$0.id]?.string }
+ XCTAssertEqual(shown, ["a", "code", "lazy", "z y"])
+ XCTAssertEqual(blocks.map(\.joinsPrevious), [false, true, false, false])
+ }
+
func testALinkDefinitionCannotInterruptAParagraph() throws {
XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"])
XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"])
From 24863d94f4390007392a830edfbaa6672a061629 Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:33:43 +0100
Subject: [PATCH 091/105] fix(parser): reference links in headings resolve
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`# [Link text][r]` showed its source and its id was `link-textr`
(GitHub: a link, `link-text`); collapsed and shortcut references and
footnote references in headings drew their source too. Headings are
stored raw and drawn by definition-less renderers (view, measurer,
anchors, search, Contents, print), so their references are now inlined
at parse time, as quotes, alerts and cells are
(ReferenceLinks.inlinedHeading). The title is scanned as one run with
its line breaks kept, so a Setext `[Link⏎text][r]` is one link with
GitHub's id `linktext`.
A footnote reference counts as its ASCII number in the heading's id
(FootnoteNumberAttribute): GitHub's slug reads `Note1 here`
as `note1-here`, and the superscript digit drawn here is no word
character for the slug.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 11 +++-
QuickMD/QuickMD/MarkdownRenderer.swift | 24 +++++++-
QuickMD/QuickMD/ReferenceLinks.swift | 17 +++++-
.../QuickMDTests/SectionExtractorTests.swift | 55 +++++++++++++++++++
4 files changed, 103 insertions(+), 4 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index c89e30b..20db539 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -245,7 +245,11 @@ struct MarkdownBlockParser: Sendable {
let level = min(max(line[hashRange].count, 1), 6)
let title = Self.withoutClosingSequence(
String(line[contentRange]).trimmingCharacters(in: .whitespaces))
- blocks.append(.heading(index: blockIndex, level: level, title: title, sourceLine: sourceLine))
+ // Headings are rendered later without the definitions (`ReferenceLinks`).
+ blocks.append(.heading(index: blockIndex, level: level,
+ title: ReferenceLinks.inlinedHeading(title, definitions: referenceDefinitions,
+ footnoteIds: footnoteDefinitions.map(\.id)),
+ sourceLine: sourceLine))
blockIndex += 1
i += 1
continue
@@ -342,7 +346,10 @@ struct MarkdownBlockParser: Sendable {
var preceding = Array(textBuffer[..1` is `note1`, and the
+ /// superscript digit drawn here is not a word character for the slug.
func headingAnchorText(_ title: String) -> String {
- String(Self.formulaSourceWithEscapes(renderInlineFormatting(title)).characters)
+ let attributed = Self.formulaSourceWithEscapes(renderInlineFormatting(title))
+ var text = ""
+ for run in attributed.runs {
+ if let number = run[FootnoteNumberAttribute.self] {
+ text += String(number)
+ } else {
+ text += String(attributed[run.range].characters)
+ }
+ }
+ return text
}
func renderHeader(_ text: String, level: Int) -> AttributedString {
@@ -1261,9 +1273,19 @@ struct MarkdownRenderer: Sendable {
let superscript = String(String(number).map { superscriptDigits[$0] ?? $0 })
var attr = AttributedString(superscript)
+ attr[FootnoteNumberAttribute.self] = number
attr.setDualFont(size: scaled(11), bold: true, fonts: theme.fonts)
attr.setDualForeground(theme.linkColor)
return (attr, afterBracket[afterBracket.index(after: closeIndex)...])
}
}
+
+/// The number a footnote reference draws as superscript digits, for
+/// `headingAnchorText` (GitHub's heading id reads it as plain digits). Not
+/// part of the AppKit scope, so an installed NSAttributedString never
+/// carries it.
+enum FootnoteNumberAttribute: AttributedStringKey {
+ typealias Value = Int
+ static let name = "qmdFootnoteNumber"
+}
diff --git a/QuickMD/QuickMD/ReferenceLinks.swift b/QuickMD/QuickMD/ReferenceLinks.swift
index 2a603e8..4439c6a 100644
--- a/QuickMD/QuickMD/ReferenceLinks.swift
+++ b/QuickMD/QuickMD/ReferenceLinks.swift
@@ -5,7 +5,8 @@ import Foundation
/// Rewrites reference-style links and images (`[text][id]`, `[text][]`,
/// `[text]`, `![alt][id]`, `![alt][]`, `![alt]`) whose definition exists into
/// their inline form (`[text]()`), for the block kinds whose RAW markdown
-/// is stored and rendered later: blockquotes, alerts and table cells.
+/// is stored and rendered later: blockquotes, alerts, table cells and
+/// headings.
///
/// Why rewrite instead of handing those renderers the definitions: paragraphs
/// are rendered inside the parser with the document's definitions, but a
@@ -73,6 +74,20 @@ enum ReferenceLinks {
return out.joined(separator: "\n")
}
+ /// A heading's title with every resolvable reference inlined. Headings
+ /// are stored as raw Markdown and rendered by definition-less renderers
+ /// (the view, measurer, anchors, search, Contents, print), like a quote.
+ /// The title is scanned as ONE run, line breaks included, because that
+ /// is how `renderHeader` / `headingAnchorText` read a Setext title — a
+ /// link across its break (`[Link⏎text][r]`) is one link — and the breaks
+ /// stay, since the anchor drops them where the shown title has a space.
+ static func inlinedHeading(_ title: String, definitions: [String: String],
+ footnoteIds: [String] = []) -> String {
+ guard !definitions.isEmpty || !footnoteIds.isEmpty, title.contains("[") else { return title }
+ return inlinedLine(title, sources: Sources(definitions: definitions, footnoteIds: footnoteIds),
+ linksDisabled: false)
+ }
+
private static func inlinedLine(_ line: String, sources: Sources, linksDisabled: Bool) -> String {
let definitions = sources.definitions
guard line.contains("[") else { return line }
diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift
index bf0a661..fd18069 100644
--- a/QuickMD/QuickMDTests/SectionExtractorTests.swift
+++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift
@@ -268,6 +268,61 @@ final class SectionExtractorTests: XCTestCase {
XCTAssertEqual(headings(of: md).map { $0.shownTitle(renderer: renderer) }, shown)
}
+ /// R18: a FULL reference link in a heading (`# [Link text][r]`) showed
+ /// its source and its id kept the label (`link-textr`). Headings are
+ /// stored raw and drawn by definition-less renderers, so references are
+ /// inlined at parse time (`ReferenceLinks.inlinedHeading`) — the view,
+ /// a click, the id, search, Contents and print all read the same title.
+ /// GitHub (`gh api markdown`, 2026-11-04) links every form below, keeps
+ /// the code span, and its id reads the footnote's `1` as `1`.
+ func testReferenceLinksInHeadingsResolveLikeGitHub() {
+ let md = """
+ # [Link text][r]
+
+ # [Coll][]
+
+ # [Short]
+
+ [Link
+ text][r]
+ ===
+
+ # Note[^1] here
+
+ # **[Bold][r]** and `[c][r]`
+
+ # [A & B][r]
+
+ [r]: https://e.x/r
+ [coll]: https://e.x/c
+ [short]: https://e.x/s
+ [^1]: Foot.
+ """
+ let theme = MarkdownTheme.cached(for: .light)
+ let renderer = MarkdownRenderer(theme: theme)
+ let blocks = MarkdownBlockParser(theme: theme).parse(md)
+ let rendered = blocks.compactMap { block -> AttributedString? in
+ guard case .heading(let level, let title, _) = block.content else { return nil }
+ return renderer.renderHeader(title, level: level)
+ }
+ let shown = rendered.map { String($0.characters) }
+ XCTAssertEqual(shown, ["Link text", "Coll", "Short", "Link text", "Note\u{00B9} here",
+ "Bold and [c][r]", "A & B"])
+ XCTAssertEqual(rendered.map { heading in heading.runs.compactMap { $0.link?.absoluteString } }, [
+ ["https://e.x/r"], ["https://e.x/c"], ["https://e.x/s"], ["https://e.x/r"], [],
+ ["https://e.x/r"], ["https://e.x/r"],
+ ])
+
+ let anchors = anchorHeadings(of: md)
+ XCTAssertEqual(HeadingAnchors.ids(for: anchors.map(\.text)), [
+ "link-text", "coll", "short", "linktext", "note1-here", "bold-and-cr", "a--b",
+ ])
+ XCTAssertEqual(headings(of: md).map { $0.shownTitle(renderer: renderer) }, shown)
+ XCTAssertEqual(SectionExtractor.extractSection(from: md, entry: headings(of: md)[0],
+ headings: headings(of: md)), "# [Link text][r]",
+ "copy is the source")
+ }
+
func testFragmentFindsItsHeading() {
let headings = anchorHeadings(of: """
# Guide
From 3e7ed3cd0cc1ae25a6a4095d87fdeefb5101efdf Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:41:51 +0100
Subject: [PATCH 092/105] fix(parser): a footnote definition in a quote is a
footnote
The definition pre-pass tried the footnote pattern only on the whole
line, and on a quote line the `>` comes first. What follows the
markers was tried only against the link pattern, so `> [^m]: two`
became a reference `^m` to `two` and `[^m]` a link. On GitHub it is
footnote 1, "two", in an empty quote.
The footnote pattern now runs first on a quote line's text too
(DefinitionPlacement.footnoteDefinitionText). A footnote definition
there may interrupt a paragraph, as at the top level, but not inside
the quote's fence. Its line stays as a marker without text, as a link
definition's does (definitionTaken), so a lazy line after it stays in
the quote. A footnote definition starts a block (startsNoBlock), so at
fewer markers it ends a nested quote rather than continuing it lazily,
as on GitHub. `[^]` is no footnote label and remains a link, as on
GitHub.
Co-Authored-By: Claude Opus 5.5
---
QuickMD/QuickMD/MarkdownBlockParser.swift | 52 +++++++++++++++----
.../QuickMDTests/VerticalRhythmTests.swift | 30 +++++++++++
2 files changed, 71 insertions(+), 11 deletions(-)
diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift
index 7b8a681..96933d6 100644
--- a/QuickMD/QuickMD/MarkdownBlockParser.swift
+++ b/QuickMD/QuickMD/MarkdownBlockParser.swift
@@ -84,9 +84,9 @@ struct MarkdownBlockParser: Sendable {
// Headings carry the original line number so section copy can slice the
// raw text without re-detecting headings itself.
var lineMap: [Int] = []
- // Indices in `lines` of quote lines a link definition was taken out
- // of: kept as markers without text, so the block loop knows the
- // quote's paragraph they open (`DefinitionPlacement.linkDefinition`).
+ // Indices in `lines` of quote lines a link or footnote definition was
+ // taken out of: kept as markers without text, so the block loop knows
+ // the quote's paragraph they open (`DefinitionPlacement.definitionTaken`).
var definitionLines = Set()
// Fenced code and `$$` blocks, read as the block loop reads them: a
// definition inside one is that block's text (GitHub).
@@ -113,12 +113,24 @@ struct MarkdownBlockParser: Sendable {
footnoteDefinitions.append((id: fnId, content: fnContent))
if !footnoteIds.contains(fnId) { footnoteIds.append(fnId) }
placement.definition()
+ } else if let text = placement.footnoteDefinitionText(trimmed),
+ let match = Self.footnoteDefRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
+ let idRange = Range(match.range(at: 1), in: text),
+ let contentRange = Range(match.range(at: 2), in: text) {
+ // A quote's footnote: before the link pattern here too.
+ let fnId = String(text[idRange])
+ footnoteDefinitions.append((id: fnId, content: String(text[contentRange])))
+ if !footnoteIds.contains(fnId) { footnoteIds.append(fnId) }
+ _ = placement.definitionTaken(trimmed)
+ lines.append(line)
+ lineMap.append(originalIndex)
+ definitionLines.insert(lines.count - 1)
} else if let text = placement.linkDefinitionText(trimmed),
let match = Self.refLinkDefRegex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)),
let idRange = Range(match.range(at: 1), in: text),
let urlRange = Range(match.range(at: 2), in: text) {
referenceDefinitions[String(text[idRange]).lowercased()] = String(text[urlRange])
- if placement.linkDefinition(trimmed) {
+ if placement.definitionTaken(trimmed) {
lines.append(line)
lineMap.append(originalIndex)
definitionLines.insert(lines.count - 1)
@@ -1329,6 +1341,11 @@ struct MarkdownBlockParser: Sendable {
if MarkdownRenderer.isThematicBreak(line) || MarkdownRenderer.isListItem(line) { return false }
if ["-", "*", "+"].contains(marker) { return false } // an empty item
if startsHTMLBlock(marker) { return false }
+ // A footnote definition starts a block (GFM): `> > a` then
+ // `> [^m]: note` is the outer quote's footnote, not lazy text.
+ if footnoteDefRegex.firstMatch(in: marker, range: NSRange(marker.startIndex..., in: marker)) != nil {
+ return false
+ }
return true
}
@@ -1390,13 +1407,26 @@ struct MarkdownBlockParser: Sendable {
}
}
- /// A LINK definition taken out of `marker` (a `markerLine`). Out of a
- /// quote line, it opens a paragraph of that quote — of definitions
- /// only, which draws nothing and which a lazy line continues in the
- /// quote (GitHub) — and the line stays for the block loop as a
- /// marker: true. A lazy definition after such a paragraph goes on
- /// with it; any other definition ends what came before.
- mutating func linkDefinition(_ marker: String) -> Bool {
+ /// What a FOOTNOTE definition on a quote line (its `markerLine`)
+ /// would be read from: what follows its markers. A footnote
+ /// definition may interrupt a paragraph (GitHub), so anywhere in the
+ /// quote but in its fence. Nil for any other line: the pre-pass
+ /// reads a line without markers whole.
+ func footnoteDefinitionText(_ marker: String) -> String? {
+ guard marker.hasPrefix(">") else { return nil }
+ let (level, content) = Self.quoteMarkers(of: marker)
+ if let tip = quote, tip.inFence, level >= quoteLevel { return nil }
+ return MarkdownBlockParser.markerLine(String(content))
+ }
+
+ /// A link or footnote definition taken out of `marker` (a
+ /// `markerLine`). Out of a quote line, it opens a paragraph of that
+ /// quote — of definitions only, which draws nothing and which a lazy
+ /// line continues in the quote (GitHub) — and the line stays for the
+ /// block loop as a marker: true. A lazy link definition after such a
+ /// paragraph goes on with it; any other definition ends what came
+ /// before.
+ mutating func definitionTaken(_ marker: String) -> Bool {
if marker.hasPrefix(">") {
let (level, content) = Self.quoteMarkers(of: marker)
var tip = quote ?? QuoteTip()
diff --git a/QuickMD/QuickMDTests/VerticalRhythmTests.swift b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
index fa764e8..89cec7f 100644
--- a/QuickMD/QuickMDTests/VerticalRhythmTests.swift
+++ b/QuickMD/QuickMDTests/VerticalRhythmTests.swift
@@ -873,6 +873,36 @@ final class VerticalRhythmTests: XCTestCase {
XCTAssertEqual(blocks.map(\.joinsPrevious), [false, true, false, false])
}
+ /// A FOOTNOTE definition on a quote line is a footnote (GitHub,
+ /// gfm/h9_*.html): in a quote, a nested or deeper one, after an alert's
+ /// body, after the quote's text (it may interrupt a paragraph, as at the
+ /// top level). The pre-pass tried only the LINK pattern on what follows
+ /// the markers, so `> [^m]: two` became a reference `^m` to `two` and
+ /// `[^m]` a link. Like a link definition (H8) it leaves a marker that
+ /// draws nothing and keeps a lazy line in the quote; at fewer markers it
+ /// is never lazy (it starts a block); in the quote's fence it is code.
+ func testAFootnoteDefinitionInAQuoteIsAFootnote() throws {
+ let note = "t:────────────────────────────────\n1. two in quote"
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\n"), ["t:x ¹", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> > [^m]: two in quote\n"), ["t:x ¹", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> a\n>> [^m]: two in quote\n"), ["t:x ¹", "q1:a", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> [!NOTE]\n> body\n>\n> [^m]: two in quote\n"),
+ ["t:x ¹", "alert:note:body", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> text\n> [^m]: two in quote\n"), ["t:x ¹", "q1:text", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\nlazy\n"), ["t:x ¹", "q1:lazy", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> [^m]: two in quote\n>\n> after\n"), ["t:x ¹", "q1:after", note])
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> > a\n> [^m]: two in quote\nlazy\n"),
+ ["t:x ¹", "q2:a", "q1:lazy", note])
+ // In the quote's fence it is code.
+ XCTAssertEqual(try quoteShape("x [^m]\n\n> ```\n> [^m]: two in quote\n> ```\n"),
+ ["t:x [^m]", "q1:```\n[^m]: two in quote\n```"])
+ // `[^]` is no footnote label: a link, as on GitHub (h9_caret_empty).
+ XCTAssertEqual(try quoteShape("[^]: http://e.com\n\n[^]\n"), ["t:^"])
+ // The quote's first drawn line is its source line; the marker is the footnote's.
+ let blocks = try XCTUnwrap(parseReturning("x [^m]\n\n> [^m]: two in quote\nlazy\n"))
+ XCTAssertEqual(blocks.map(\.sourceLine).prefix(2), [0, 3])
+ }
+
func testALinkDefinitionCannotInterruptAParagraph() throws {
XCTAssertEqual(try quoteShape("> a\n[x]: http://e.com\n\n[x]\n"), ["q1:a\n[x]: http://e.com", "t:[x]"])
XCTAssertEqual(try quoteShape("Para\n[x]: http://e.com\n\n[x]\n"), ["t:Para [x]: http://e.com", "t:[x]"])
From 12f9b33c7f2196f3f327e73581cd2c6d69cf247e Mon Sep 17 00:00:00 2001
From: Bartosz Sroczynski
Date: Wed, 4 Nov 2026 23:41:55 +0100
Subject: [PATCH 093/105] fix(parser): reference labels match as CommonMark's
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A label across a Setext break (`[link⏎text]`), with extra spaces or a
tab (`[a b]`, `[A⇥B]`) or in another case form (`[ẞ]` for `[SS]:`,
`[straße]` for `[STRASSE]:`) stayed literal; GitHub links them all.
ReferenceLabel.normalized is now the one key every lookup uses (the
definition pre-pass, the renderer, InlineSyntaxScanner, ReferenceLinks):
whitespace runs (cmark's set: a no-break space is not one) become one
space, the ends are trimmed, and the label is Unicode case-folded.
The first definition of a label now wins, as on GitHub; the last one
did.
Co-Authored-By: Claude Opus 5.5