From cc6df3a60d01565636ec0e1f0ac968f36b85d8b3 Mon Sep 17 00:00:00 2001 From: Luke Bradford Date: Tue, 11 Aug 2026 17:19:19 -0700 Subject: [PATCH] Honor the GFM delimiter row's column alignment in tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README lists `:---`, `:---:` and `---:` column alignment as supported, but every cell rendered `.leading` regardless. `Markdown.Table` exposes `columnAlignments`, and `Table+.convert` had it in hand — there was simply nowhere to put it, since `MarkdownRenderable.table` carried only headers, rows and the raw markdown. The alignment was dropped at conversion time and `TableView` never had a chance to apply it. - `MarkdownRenderable.table` gains `alignments: [MarkdownColumnAlignment]`, a small internal enum so the model keeps its current imports. - `Table+.convert` maps swift-markdown's `columnAlignments` onto it, padding short/absent declarations with `.leading`. - `TableView` applies both `multilineTextAlignment` and the cell's frame alignment per column, for the header row and both body-cell variants. A table that declares no alignment still renders `.leading` — the GFM default — so existing output is unchanged. `TableView`'s new `alignments` parameter is defaulted, so direct constructions (including the ones in `TableViewTests`) still compile untouched. This matters most for numeric columns: a currency or percentage column declared `---:` currently reads as a ragged left edge, which is what sent me looking. (Heads up for CI: `TableViewSnapshotTests.testTableCellWithOnlyCitation` and the `UnorderedListViewTests` snapshots already fail on my machine at unmodified `main` — reference mismatches under `macOS-standard-*`, seemingly recorded on different hardware — so I could only verify "no new failures" rather than green.) --- Sources/MarkdownText/Block/Table+.swift | 11 +++++- .../Models/MarkdownRenderable.swift | 13 ++++++- .../Models/RenderableDocument.swift | 4 +- Sources/MarkdownText/UI/BlockView.swift | 3 +- Sources/MarkdownText/UI/TableView.swift | 37 ++++++++++++++++--- 5 files changed, 56 insertions(+), 12 deletions(-) diff --git a/Sources/MarkdownText/Block/Table+.swift b/Sources/MarkdownText/Block/Table+.swift index 94d4ee5..6472f45 100644 --- a/Sources/MarkdownText/Block/Table+.swift +++ b/Sources/MarkdownText/Block/Table+.swift @@ -38,6 +38,15 @@ extension Markdown.Table: BlockConvertible { let headerColumnCount = self.head.childCount let isWellFormed = self.body.children.allSatisfy { $0.childCount == headerColumnCount } let rawMarkdown = isWellFormed ? self.format() : "" - return .table(id: self.id, headers: headerCells, rows: rows, rawMarkdown: rawMarkdown) + let alignments = (0.. MarkdownColumnAlignment in + guard index < self.columnAlignments.count else { return .leading } + switch self.columnAlignments[index] { + case .left: return .leading + case .center: return .center + case .right: return .trailing + case .none: return .leading + } + } + return .table(id: self.id, headers: headerCells, rows: rows, alignments: alignments, rawMarkdown: rawMarkdown) } } diff --git a/Sources/MarkdownText/Models/MarkdownRenderable.swift b/Sources/MarkdownText/Models/MarkdownRenderable.swift index 397f3c9..ddac464 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderable.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderable.swift @@ -13,6 +13,15 @@ import AppKit /// Markdown element representation that is ready to be rendered by a SwiftUI View /// The representation already have all the parsing and processing completed to minimize rendering overhead on UI thread. /// This data structure is not thread safe due to the usage of `NSMutableAttributedString`, this needs to be addressed as a future improvement +/// Per-column text alignment declared by a GFM table's delimiter row +/// (`:---`, `:---:`, `---:`). `.leading` is the GFM default for a column that +/// declares nothing. +enum MarkdownColumnAlignment: Equatable, Sendable { + case leading + case center + case trailing +} + indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { /// To be rendered as a paragraph @@ -34,7 +43,7 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { case codeBlock(id: String, language: String?, code: String) /// To be rendered as a table - case table(id: String, headers: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], rawMarkdown: String) + case table(id: String, headers: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], alignments: [MarkdownColumnAlignment], rawMarkdown: String) /// To be rendered as thematic break case thematicBreak(id: String) @@ -54,7 +63,7 @@ indirect enum MarkdownRenderable: Identifiable, Equatable, @unchecked Sendable { case .orderedList(let id, _): return id case .unorderedList(let id, _, _): return id case .codeBlock(let id, _, _): return id - case .table(let id, _, _, _): return id + case .table(let id, _, _, _, _): return id case .thematicBreak(let id): return id case .blockQuote(let id, _): return id case .image(let id, _): return id diff --git a/Sources/MarkdownText/Models/RenderableDocument.swift b/Sources/MarkdownText/Models/RenderableDocument.swift index 2ef23d7..f65bde8 100644 --- a/Sources/MarkdownText/Models/RenderableDocument.swift +++ b/Sources/MarkdownText/Models/RenderableDocument.swift @@ -92,7 +92,7 @@ extension MarkdownRenderable { return items.plainText(separator: "\n") case .codeBlock(_, _, let code): return code - case .table(_, let headers, let rows, _): + case .table(_, let headers, let rows, _, _): let headerLine = headers.map { $0.string }.joined(separator: "\t") let rowLines = rows.map { row in row.map { $0.string }.joined(separator: "\t") } return ([headerLine] + rowLines).joined(separator: "\n") @@ -135,7 +135,7 @@ extension MarkdownRenderable { return items.flatMap { $0.attributedStrings() } case .unorderedList(_, let items, _): return items.flatMap { $0.attributedStrings() } - case .table(_, let headers, let rows, _): + case .table(_, let headers, let rows, _, _): return headers + rows.flatMap { $0 } default: return [] diff --git a/Sources/MarkdownText/UI/BlockView.swift b/Sources/MarkdownText/UI/BlockView.swift index b7fa300..ab66adf 100644 --- a/Sources/MarkdownText/UI/BlockView.swift +++ b/Sources/MarkdownText/UI/BlockView.swift @@ -62,9 +62,10 @@ struct SingleBlockView: View { code: code) case .thematicBreak: ThematicBreakView() - case .table(_, let headers, let rows, let rawMarkdown): + case .table(_, let headers, let rows, let alignments, let rawMarkdown): TableView(headings: headers, rows: rows, + alignments: alignments, rawMarkdown: rawMarkdown) case .blockQuote(_, let item): BlockQuoteView(item: item) diff --git a/Sources/MarkdownText/UI/TableView.swift b/Sources/MarkdownText/UI/TableView.swift index 84579b0..199fcbe 100644 --- a/Sources/MarkdownText/UI/TableView.swift +++ b/Sources/MarkdownText/UI/TableView.swift @@ -22,6 +22,7 @@ struct TableView: View { let headings: [AttributedString] let rows: [[RowContent]] + let alignments: [MarkdownColumnAlignment] let columnMaxWidths: [Int: CGFloat] private let defaultMaxColumnWidth: CGFloat = 200 @@ -32,7 +33,8 @@ struct TableView: View { private let rawMarkdown: String - init(headings: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], columnMaxWidths: [Int: CGFloat] = [:], rawMarkdown: String = "") { + init(headings: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], alignments: [MarkdownColumnAlignment] = [], columnMaxWidths: [Int: CGFloat] = [:], rawMarkdown: String = "") { + self.alignments = alignments self.headings = headings.map { AttributedString($0) } self.rows = rows.map { row in row.map { content in @@ -52,13 +54,36 @@ struct TableView: View { return rows.count } + /// The GFM delimiter row's alignment for a column, defaulting to `.leading` + /// for a table that declares none. + private func alignment(forColumn index: Int) -> MarkdownColumnAlignment { + guard index < alignments.count else { return .leading } + return alignments[index] + } + + private func textAlignment(forColumn index: Int) -> TextAlignment { + switch alignment(forColumn: index) { + case .leading: return .leading + case .center: return .center + case .trailing: return .trailing + } + } + + private func frameAlignment(forColumn index: Int) -> Alignment { + switch alignment(forColumn: index) { + case .leading: return .topLeading + case .center: return .top + case .trailing: return .topTrailing + } + } + private func headerView(colIdx: Int) -> some View { HStack(spacing: 0) { Text(headings[colIdx]) .foregroundStyle(config.tableStyle.headerTextColor) .lineLimit(nil) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .multilineTextAlignment(textAlignment(forColumn: colIdx)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlignment(forColumn: colIdx)) .if(config.shouldAnimateText) { view in view.fadeInTextTransition(attributedString: headings[colIdx]) } @@ -106,7 +131,7 @@ struct TableView: View { case .containsAttachment(let nsAttributedString): HStack(spacing: 0) { ParagraphView(contents: applyTypographyThemingAndGetContent(nsAttributedString)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlignment(forColumn: colIdx)) .accessibilityValue(String.itemPositionInTable(rowIndex: rowIdx + 2, totalRow: numOfRows + 1, columnIndex: colIdx + 1, totalColumn: headings.count)) Spacer() } @@ -119,8 +144,8 @@ struct TableView: View { Text(attributedString) .foregroundStyle(config.tableStyle.regularTextColor) .lineLimit(nil) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .multilineTextAlignment(textAlignment(forColumn: colIdx)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: frameAlignment(forColumn: colIdx)) .if(config.shouldAnimateText) { view in view.fadeInTextTransition(attributedString: attributedString) }