Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions QuickMD/QuickMD/MarkdownBlockParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,12 @@ struct MarkdownBlockParser: Sendable {
if !isTableSeparator(trimmed) && i + 1 < lines.count && isTableSeparator(lines[i + 1]) {
flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer)

let headers = parseTableRow(line)
let columnCount = headers.count
var alignments = parseTableAlignments(lines[i + 1])
// The separator row is authoritative for column count: a header row
// of entirely empty cells (`| | |`, a headerless table) parses to zero
// cells, which would otherwise collapse the whole table to nothing.
var headers = parseTableRow(line)
let columnCount = max(headers.count, alignments.count)
var rows: [[String]] = []
i += 2 // Skip header and separator

Expand All @@ -250,7 +253,8 @@ struct MarkdownBlockParser: Sendable {
i += 1
}

// Normalize: ensure all rows and alignments match column count
// Normalize: ensure headers, rows, and alignments match column count
headers = normalizeArray(headers, to: columnCount, default: "")
alignments = normalizeArray(alignments, to: columnCount, default: .leading)
rows = rows.map { normalizeArray($0, to: columnCount, default: "") }

Expand Down
40 changes: 23 additions & 17 deletions QuickMD/QuickMD/MarkdownExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,36 +147,42 @@ struct PrintableTableView: View, TableAlignmentProvider {
/// Stored column count - computed once on init for efficiency
private let columnCount: Int

/// Headerless tables (`| | |`) skip the header band entirely
private let showsHeader: Bool

init(headers: [String], rows: [[String]], alignments: [TextAlignment]) {
self.headers = headers
self.rows = rows
self.alignments = alignments
self.renderer = MarkdownRenderer(colorScheme: .light)
self.columnCount = headers.count
self.columnCount = max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)
self.showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}

var body: some View {
VStack(spacing: 0) {
// Header row
HStack(spacing: 0) {
ForEach(0..<columnCount, id: \.self) { index in
Text(renderer.renderInline(headers[index]))
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.black)
.multilineTextAlignment(textAlignmentFor(index))
.frame(maxWidth: .infinity, alignment: alignmentFor(index))
.padding(.horizontal, 8)
.padding(.vertical, 6)
if showsHeader {
// Header row
HStack(spacing: 0) {
ForEach(0..<columnCount, id: \.self) { index in
Text(renderer.renderInline(index < headers.count ? headers[index] : ""))
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.black)
.multilineTextAlignment(textAlignmentFor(index))
.frame(maxWidth: .infinity, alignment: alignmentFor(index))
.padding(.horizontal, 8)
.padding(.vertical, 6)

if index < columnCount - 1 {
Rectangle().fill(theme.borderColor).frame(width: 1)
if index < columnCount - 1 {
Rectangle().fill(theme.borderColor).frame(width: 1)
}
}
}
}
.background(theme.headerBackgroundColor)
.background(theme.headerBackgroundColor)

// Header separator
Rectangle().fill(theme.borderColor).frame(height: 1)
// Header separator
Rectangle().fill(theme.borderColor).frame(height: 1)
}

// Data rows
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
Expand Down
62 changes: 37 additions & 25 deletions QuickMD/QuickMD/Views/TableBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ struct TableBlockView: View, TableAlignmentProvider {
/// Stored column count - computed once on init for efficiency
private let columnCount: Int

/// Headerless tables (`| | |`) skip the header band entirely
private let showsHeader: Bool

init(headers: [String], rows: [[String]], alignments: [TextAlignment], theme: MarkdownTheme,
fontScale: CGFloat = 1.0, searchText: String = "", focusedOccurrence: Int? = nil) {
self.headers = headers
Expand All @@ -56,7 +59,8 @@ struct TableBlockView: View, TableAlignmentProvider {
self.searchText = searchText
self.focusedOccurrence = focusedOccurrence
self.renderer = MarkdownRenderer(theme: theme, fontScale: fontScale)
self.columnCount = headers.count
self.columnCount = max(headers.count, alignments.count, rows.map(\.count).max() ?? 0)
self.showsHeader = headers.contains { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
}

// MARK: - Body
Expand All @@ -66,32 +70,35 @@ struct TableBlockView: View, TableAlignmentProvider {
let cellOffsets = computeCellOffsets()

VStack(spacing: 0) {
// Header row with inline dividers (no GeometryReader)
HStack(spacing: 0) {
ForEach(0..<columnCount, id: \.self) { index in
let rendered = renderer.renderInline(headers[index])
let localFocused = localFocusedOccurrence(cellOffset: cellOffsets.headerOffsets[index],
cellText: headers[index])
Text(searchText.isEmpty ? rendered : searchHighlight(rendered, term: searchText, focusedOccurrence: localFocused))
.font(.system(size: 13 * fontScale, weight: .semibold))
.foregroundColor(theme.textColor)
.multilineTextAlignment(textAlignmentFor(index))
.frame(maxWidth: .infinity, alignment: alignmentFor(index))
.padding(.horizontal, 12)
.padding(.vertical, 8)

// Inline vertical divider (except after last column)
if index < columnCount - 1 {
Rectangle()
.fill(theme.borderColor)
.frame(width: 1)
if showsHeader {
// Header row with inline dividers (no GeometryReader)
HStack(spacing: 0) {
ForEach(0..<columnCount, id: \.self) { index in
let header = headerCell(index)
let rendered = renderer.renderInline(header)
let localFocused = localFocusedOccurrence(cellOffset: cellOffsets.headerOffsets[index],
cellText: header)
Text(searchText.isEmpty ? rendered : searchHighlight(rendered, term: searchText, focusedOccurrence: localFocused))
.font(.system(size: 13 * fontScale, weight: .semibold))
.foregroundColor(theme.textColor)
.multilineTextAlignment(textAlignmentFor(index))
.frame(maxWidth: .infinity, alignment: alignmentFor(index))
.padding(.horizontal, 12)
.padding(.vertical, 8)

// Inline vertical divider (except after last column)
if index < columnCount - 1 {
Rectangle()
.fill(theme.borderColor)
.frame(width: 1)
}
}
}
}
.background(theme.headerBackgroundColor)
.background(theme.headerBackgroundColor)

// Header separator
Rectangle().fill(theme.borderColor).frame(height: 1)
// Header separator
Rectangle().fill(theme.borderColor).frame(height: 1)
}

// Data rows with inline dividers
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
Expand Down Expand Up @@ -129,6 +136,11 @@ struct TableBlockView: View, TableAlignmentProvider {
.overlay(RoundedRectangle(cornerRadius: 4).stroke(theme.borderColor, lineWidth: 1))
}

/// Header cell at index, or "" when the header row is shorter than the column count
private func headerCell(_ index: Int) -> String {
index < headers.count ? headers[index] : ""
}

// MARK: - Per-Cell Occurrence Tracking

private struct CellOffsets {
Expand All @@ -146,7 +158,7 @@ struct TableBlockView: View, TableAlignmentProvider {
var headerOffsets: [Int] = []
for i in 0..<columnCount {
headerOffsets.append(offset)
offset += countOccurrences(in: headers[i], of: searchText)
offset += countOccurrences(in: headerCell(i), of: searchText)
}
var rowOffsets: [[Int]] = []
for row in rows {
Expand Down
19 changes: 19 additions & 0 deletions QuickMD/QuickMDTests/ParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,25 @@ final class ParserTests: XCTestCase {
XCTAssertTrue(rows.allSatisfy { $0.count == 3 })
}

func testHeaderlessTableKeepsColumnsFromSeparator() {
let md = """
| | |
|---|---|
| **Kettle** | Boils water in about three minutes |
| **Toaster** | Two slots, one crumb tray |
"""
let blocks = parse(md)
guard case .table(let headers, let rows, let alignments) = blocks[0].content else {
return XCTFail("expected table")
}
// Empty header row must not collapse the table — separator defines 2 columns
XCTAssertEqual(headers, ["", ""])
XCTAssertEqual(alignments.count, 2)
XCTAssertEqual(rows.count, 2)
XCTAssertEqual(rows[0], ["**Kettle**", "Boils water in about three minutes"])
XCTAssertEqual(rows[1], ["**Toaster**", "Two slots, one crumb tray"])
}

// MARK: - Blockquotes

func testBlockquoteNestingLevels() {
Expand Down
Loading