From 1b87f6571739c1f9144ed05bbdd084a28cdc65d8 Mon Sep 17 00:00:00 2001 From: rosekanari Date: Fri, 4 Sep 2026 17:56:15 +0200 Subject: [PATCH] Renderer: align ordered list items on the widest marker of their list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hanging indent of an ordered item was the width of that item's own prefix. The body font is not monospaced, so "1. " and "10. " are 18.47 pt and 31.00 pt wide at a 21 pt body, and each item's text started at a different x — a 12.5 pt step, visible at a glance on any list that crosses ten items. `render` now pre-scans the joined lines and, for each contiguous run of ordered items at one indent level, computes the widest prefix; every item of the run hangs under it. The run is known before any rendering, so the widest prefix is too. `testWiderOrderedMarkerHangsFurther` keeps passing, and for the right reason: it renders "1. alpha" and "10. alpha" as two separate one-item documents, so each still hangs under its own — and only — marker. Its stated intent, "a wider marker has to hang further, or 10. would overlap its own text", is preserved: within a list, every item now hangs under the widest one. Adds `testOrderedItemsOfOneListShareTheirHangingIndent`, which fails on unpatched main and passes here. Co-Authored-By: Claude Opus 5 (1M context) --- QuickMD/QuickMD/MarkdownRenderer.swift | 53 +++++++++++++++++++++--- QuickMD/QuickMDTests/RendererTests.swift | 22 ++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/QuickMD/QuickMD/MarkdownRenderer.swift b/QuickMD/QuickMD/MarkdownRenderer.swift index 2d3645e..689c6cd 100644 --- a/QuickMD/QuickMD/MarkdownRenderer.swift +++ b/QuickMD/QuickMD/MarkdownRenderer.swift @@ -111,14 +111,52 @@ struct MarkdownRenderer: Sendable { func render(_ markdown: String) -> AttributedString { var result = AttributedString() - for line in Self.joinSoftBreaks(markdown.components(separatedBy: "\n")) { - result.append(renderLine(line)) + let lines = Self.joinSoftBreaks(markdown.components(separatedBy: "\n")) + // The hanging indent of an ordered item must be the width of the WIDEST + // marker in its list, not of its own: the body font is not monospaced, + // so "1. " and "10. " have different widths and each item's text would + // start at a different x. The run is known before any rendering, so the + // widest prefix is too. + let hangs = Self.widestOrderedPrefixes(lines) + for (index, line) in lines.enumerated() { + result.append(renderLine(line, hangingPrefix: hangs[index])) result.append(AttributedString("\n")) } return result } + /// For every line, the widest ordered-list prefix of the contiguous run it + /// belongs to — `nil` for lines that are not ordered items. + /// + /// A run ends at the first line that is not an ordered item at the same + /// indent level, which is what a reader sees as one list. + static func widestOrderedPrefixes(_ lines: [String]) -> [String?] { + var out = [String?](repeating: nil, count: lines.count) + var start = 0 + while start < lines.count { + guard let first = orderedItem(lines[start]) else { start += 1; continue } + var end = start, widest = first.prefix + while end + 1 < lines.count, + let next = orderedItem(lines[end + 1]), next.level == first.level { + end += 1 + if next.prefix.count > widest.count { widest = next.prefix } + } + for i in start...end { out[i] = widest } + start = end + 1 + } + return out + } + + /// `(prefix, level)` of an ordered list item, prefix including its indent. + private static func orderedItem(_ line: String) -> (prefix: String, level: Int)? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil else { return nil } + let indent = line.prefix(while: { $0 == " " || $0 == "\t" }) + let number = trimmed.prefix(while: { $0.isNumber }) + return (indentSpaces(listLevel(for: indent)) + "\(number). ", listLevel(for: indent)) + } + // MARK: - Soft Breaks /// CommonMark renders a single newline inside a paragraph as a space (a @@ -192,7 +230,7 @@ struct MarkdownRenderer: Sendable { // MARK: - Line Rendering - private func renderLine(_ line: String) -> AttributedString { + private func renderLine(_ line: String, hangingPrefix: String? = nil) -> AttributedString { let trimmed = line.trimmingCharacters(in: .whitespaces) // Header - extract hash count from regex group, not space position @@ -227,7 +265,8 @@ struct MarkdownRenderer: Sendable { let level = Self.listLevel(for: line.prefix(while: { $0 == " " || $0 == "\t" })) let number = Int(trimmed.prefix(while: { $0.isNumber })) ?? 1 let content = String(trimmed[match.upperBound...]) - return renderListItem(content, level: level, ordered: true, number: number) + return renderListItem(content, level: level, ordered: true, number: number, + hangingPrefix: hangingPrefix) } // Empty line @@ -312,13 +351,15 @@ struct MarkdownRenderer: Sendable { return min(columns / 2, 8) } - private func renderListItem(_ text: String, level: Int, ordered: Bool, number: Int) -> AttributedString { + private func renderListItem(_ text: String, level: Int, ordered: Bool, number: Int, + hangingPrefix: String? = nil) -> AttributedString { let prefix = Self.indentSpaces(level) + (ordered ? "\(number). " : "• ") var attr = AttributedString(prefix) attr.setDualFont(size: scaled(14), fonts: theme.fonts) attr.setDualForeground(theme.textColor) attr.append(renderInlineFormatting(text)) - applyListParagraphStyle(&attr, hangingUnder: prefix) + // Hang under the widest prefix of the list, not under this item's own. + applyListParagraphStyle(&attr, hangingUnder: hangingPrefix ?? prefix) return attr } diff --git a/QuickMD/QuickMDTests/RendererTests.swift b/QuickMD/QuickMDTests/RendererTests.swift index 9ca4c62..ed61f73 100644 --- a/QuickMD/QuickMDTests/RendererTests.swift +++ b/QuickMD/QuickMDTests/RendererTests.swift @@ -115,6 +115,28 @@ final class RendererTests: XCTestCase { } } + /// Every paragraph style in `markdown`, in document order. + private func listStyles(_ markdown: String) throws -> [NSParagraphStyle] { + let r = MarkdownRenderer(theme: MarkdownTheme.cached(for: .light), fontScale: 1) + let ns = try NSAttributedString(r.render(markdown), including: \.appKit) + var out: [NSParagraphStyle] = [] + ns.enumerateAttribute(.paragraphStyle, in: NSRange(location: 0, length: ns.length)) { v, _, _ in + if let style = v as? NSParagraphStyle { out.append(style) } + } + return out + } + + /// The items of ONE list must share a hanging indent, or their text starts + /// at a different x. The body font is not monospaced, so "1. " and "10. " + /// are 18.47 pt and 31.00 pt wide at a 21 pt body — a 12.5 pt step, visible + /// at a glance on any list that crosses ten items. + func testOrderedItemsOfOneListShareTheirHangingIndent() throws { + let markdown = (1...12).map { "\($0). alpha" }.joined(separator: "\n") + let indents = Set(try listStyles(markdown).map { $0.headIndent }) + XCTAssertEqual(indents.count, 1, + "items of one list hang at \(indents.sorted()) instead of sharing one indent") + } + /// A wider marker has to hang further, or "10." would overlap its own text. func testWiderOrderedMarkerHangsFurther() throws { let single = try listStyle("1. alpha")