diff --git a/Sources/Markdown/Base/RawMarkup.swift b/Sources/Markdown/Base/RawMarkup.swift index 1f80439a..3cd1f853 100644 --- a/Sources/Markdown/Base/RawMarkup.swift +++ b/Sources/Markdown/Base/RawMarkup.swift @@ -49,7 +49,18 @@ enum RawMarkupData: Equatable { case tableHead case tableBody case tableRow - case tableCell + case tableCell(colspan: UInt, rowspan: UInt) +} + +extension RawMarkupData { + func isTableCell() -> Bool { + switch self { + case .tableCell: + return true + default: + return false + } + } } /// The header for the `RawMarkup` managed buffer. @@ -297,12 +308,12 @@ final class RawMarkup: ManagedBuffer { } static func tableRow(parsedRange: SourceRange?, _ columns: [RawMarkup]) -> RawMarkup { - precondition(columns.allSatisfy { $0.header.data == .tableCell }) + precondition(columns.allSatisfy { $0.header.data.isTableCell() }) return .create(data: .tableRow, parsedRange: parsedRange, children: columns) } static func tableHead(parsedRange: SourceRange?, columns: [RawMarkup]) -> RawMarkup { - precondition(columns.allSatisfy { $0.header.data == .tableCell }) + precondition(columns.allSatisfy { $0.header.data.isTableCell() }) return .create(data: .tableHead, parsedRange: parsedRange, children: columns) } @@ -311,8 +322,8 @@ final class RawMarkup: ManagedBuffer { return .create(data: .tableBody, parsedRange: parsedRange, children: rows) } - static func tableCell(parsedRange: SourceRange?, _ children: [RawMarkup]) -> RawMarkup { - return .create(data: .tableCell, parsedRange: parsedRange, children: children) + static func tableCell(parsedRange: SourceRange?, colspan: UInt, rowspan: UInt, _ children: [RawMarkup]) -> RawMarkup { + return .create(data: .tableCell(colspan: colspan, rowspan: rowspan), parsedRange: parsedRange, children: children) } } diff --git a/Sources/Markdown/Block Nodes/Tables/TableCell.swift b/Sources/Markdown/Block Nodes/Tables/TableCell.swift index dbb30cc4..0d74745d 100644 --- a/Sources/Markdown/Block Nodes/Tables/TableCell.swift +++ b/Sources/Markdown/Block Nodes/Tables/TableCell.swift @@ -30,10 +30,48 @@ extension Table { public extension Table.Cell { + /// The number of columns this cell spans over. + /// + /// A normal, non-spanning table cell has a `colspan` of 1. A value greater than one indicates + /// that this cell has expanded to cover up that number of columns. A value of zero means that + /// this cell is being covered up by a previous cell in the same row. + var colspan: UInt { + get { + guard case let .tableCell(colspan, _) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return colspan + } + set { + _data = _data.replacingSelf(.tableCell(parsedRange: nil, colspan: newValue, rowspan: rowspan, _data.raw.markup.copyChildren())) + } + } + + /// The number of rows this cell spans over. + /// + /// A normal, non-spanning table cell has a `rowspan` of 1. A value greater than one indicates + /// that this cell has expanded to cover up that number of rows. A value of zero means that + /// this cell is being covered up by another cell in a row above it. + var rowspan: UInt { + get { + guard case let .tableCell(_, rowspan) = _data.raw.markup.data else { + fatalError("\(self) markup wrapped unexpected \(_data.raw)") + } + return rowspan + } + set { + _data = _data.replacingSelf(.tableCell(parsedRange: nil, colspan: colspan, rowspan: newValue, _data.raw.markup.copyChildren())) + } + } + // MARK: BasicInlineContainer init(_ children: Children) where Children : Sequence, Children.Element == InlineMarkup { - try! self.init(RawMarkup.tableCell(parsedRange: nil, children.map { $0.raw.markup })) + self.init(colspan: 1, rowspan: 1, children) + } + + init(colspan: UInt, rowspan: UInt, _ children: Children) where Children : Sequence, Children.Element == InlineMarkup { + try! self.init(RawMarkup.tableCell(parsedRange: nil, colspan: colspan, rowspan: rowspan, children.map { $0.raw.markup })) } // MARK: Visitation diff --git a/Sources/Markdown/Parser/CommonMarkConverter.swift b/Sources/Markdown/Parser/CommonMarkConverter.swift index 1307977a..2819f7c7 100644 --- a/Sources/Markdown/Parser/CommonMarkConverter.swift +++ b/Sources/Markdown/Parser/CommonMarkConverter.swift @@ -570,17 +570,22 @@ struct MarkupParser { precondition(state.nodeType == .tableCell) let parsedRange = state.range(state.node) let childConversion = convertChildren(state) + let colspan = UInt(cmark_gfm_extensions_get_table_cell_colspan(state.node)) + let rowspan = UInt(cmark_gfm_extensions_get_table_cell_rowspan(state.node)) precondition(childConversion.state.node == state.node) precondition(childConversion.state.event == CMARK_EVENT_EXIT) - return MarkupConversion(state: childConversion.state.next(), result: .tableCell(parsedRange: parsedRange, childConversion.result)) + return MarkupConversion(state: childConversion.state.next(), result: .tableCell(parsedRange: parsedRange, colspan: colspan, rowspan: rowspan, childConversion.result)) } static func parseString(_ string: String, source: URL?, options: ParseOptions) -> Document { cmark_gfm_core_extensions_ensure_registered() + + var cmarkOptions = CMARK_OPT_TABLE_SPANS + if !options.contains(.disableSmartOpts) { + cmarkOptions |= CMARK_OPT_SMART + } - let parser = cmark_parser_new(options.contains(.disableSmartOpts) - ? CMARK_OPT_DEFAULT - : CMARK_OPT_SMART) + let parser = cmark_parser_new(cmarkOptions) cmark_parser_attach_syntax_extension(parser, cmark_find_syntax_extension("table")) cmark_parser_attach_syntax_extension(parser, cmark_find_syntax_extension("strikethrough")) diff --git a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift index d3f6995d..f961de98 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift @@ -899,15 +899,37 @@ public struct MarkupFormatter: MarkupWalker { $0.formatIndependently(options: cellFormattingOptions) }).ensuringCount(atLeast: uniformColumnCount, filler: "") + /// All of the column-span values from the head cells, adding cells as + /// needed to meet the uniform `uniformColumnCount`. + let headCellSpans = Array(table.head.cells.map { + $0.colspan + }).ensuringCount(atLeast: uniformColumnCount, filler: 1) + /// All of the independently formatted body cells' text by row, adding /// cells to each row to meet the `uniformColumnCount`. let bodyRowTexts = Array(table.body.rows.map { row -> [String] in return Array(row.cells.map { - $0.formatIndependently(options: cellFormattingOptions) + if $0.rowspan == 0 { + // If this cell is being spanned over, replace its text + // (which should be the empty string anyway) with the + // rowspan marker. + return "^" + } else { + return $0.formatIndependently(options: cellFormattingOptions) + } }).ensuringCount(atLeast: uniformColumnCount, filler: "") }) + /// All of the column- and row-span information for the body cells, + /// cells to each row to meet the `uniformColumnCount`. + let bodyRowSpans = Array(table.body.rows.map { row in + return Array(row.cells.map { + (colspan: $0.colspan, rowspan: $0.rowspan) + }).ensuringCount(atLeast: uniformColumnCount, + filler: (colspan: 1, rowspan: 1)) + }) + // Next, calculate the maximum width of each column. /// The column alignments of the table, filled out to `uniformColumnCount`. @@ -952,15 +974,32 @@ public struct MarkupFormatter: MarkupWalker { } } + /// Calculate the width of the given column and colspan. + /// + /// This adds up the appropriate column widths based on the given column span, including + /// the default span of 1, where it will only return the `finalColumnWidths` value for the + /// given `column`. + func columnWidth(column: Int, colspan: Int) -> Int { + let lastColumn = column + colspan + return (column.. String in - let minLineLength = finalColumnWidths[column] - return headCellTexts[column] - .ensuringCount(atLeast: minLineLength, filler: " ") + let colspan = headCellSpans[column] + if colspan == 0 { + // If this cell is being spanned over, collapse it so it + // can be filled with the spanning cell. + return "" + } else { + let minLineLength = columnWidth(column: column, colspan: Int(colspan)) + return headCellTexts[column] + .ensuringCount(atLeast: minLineLength, filler: " ") + } } /// Rendered delimter row cells with the correct width. @@ -988,10 +1027,18 @@ public struct MarkupFormatter: MarkupWalker { /// appropriately for their row and column. let expandedBodyRowTexts = bodyRowTexts.enumerated() .map { (row, rowCellTexts) -> [String] in + let rowSpans = bodyRowSpans[row] return (0.. String in - let minLineLength = finalColumnWidths[column] - return rowCellTexts[column] - .ensuringCount(atLeast: minLineLength, filler: " ") + let colspan = rowSpans[column].colspan + if colspan == 0 { + // If this cell is being spanned over, collapse it so it + // can be filled with the spanning cell. + return "" + } else { + let minLineLength = columnWidth(column: column, colspan: Int(colspan)) + return rowCellTexts[column] + .ensuringCount(atLeast: minLineLength, filler: " ") + } } } diff --git a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift index 47358740..3b265f17 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupTreeDumper.swift @@ -258,4 +258,20 @@ struct MarkupTreeDumper: MarkupWalker { mutating func visitSymbolLink(_ symbolLink: SymbolLink) { dump(symbolLink, customDescription: symbolLink.destination.map { "destination: \($0)" }) } + + mutating func visitTableCell(_ tableCell: Table.Cell) { + var desc = "" + if tableCell.colspan != 1 { + desc += " colspan: \(tableCell.colspan)" + } + if tableCell.rowspan != 1 { + desc += " rowspan: \(tableCell.rowspan)" + } + desc = desc.trimmingCharacters(in: .whitespaces) + if !desc.isEmpty { + dump(tableCell, customDescription: desc) + } else { + dump(tableCell) + } + } } diff --git a/Tests/MarkdownTests/Block Nodes/TableTests.swift b/Tests/MarkdownTests/Block Nodes/TableTests.swift index b840e6b0..709e4232 100644 --- a/Tests/MarkdownTests/Block Nodes/TableTests.swift +++ b/Tests/MarkdownTests/Block Nodes/TableTests.swift @@ -205,4 +205,40 @@ class TableTests: XCTestCase { """ XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) } + + func testParseCellSpans() { + let source = """ + | one | two | three | + | --- | --- | ----- | + | big || small | + | ^ || small | + """ + + let document = Document(parsing: source) + + let expectedDump = """ + Document @1:1-4:22 + └─ Table @1:1-4:22 alignments: |-|-|-| + ├─ Head @1:1-1:22 + │ ├─ Cell @1:2-1:7 + │ │ └─ Text @1:3-1:6 "one" + │ ├─ Cell @1:8-1:13 + │ │ └─ Text @1:9-1:12 "two" + │ └─ Cell @1:14-1:21 + │ └─ Text @1:15-1:20 "three" + └─ Body @3:1-4:22 + ├─ Row @3:1-3:22 + │ ├─ Cell @3:2-3:12 colspan: 2 rowspan: 2 + │ │ └─ Text @3:3-3:6 "big" + │ ├─ Cell @3:13-3:14 colspan: 0 + │ └─ Cell @3:14-3:21 + │ └─ Text @3:15-3:20 "small" + └─ Row @4:1-4:22 + ├─ Cell @4:2-4:12 colspan: 2 rowspan: 0 + ├─ Cell @4:13-4:14 colspan: 0 + └─ Cell @4:14-4:21 + └─ Text @4:15-4:20 "small" + """ + XCTAssertEqual(expectedDump, document.debugDescription(options: .printSourceLocations)) + } } diff --git a/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift b/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift index c8a54fa2..f599f1e2 100644 --- a/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift +++ b/Tests/MarkdownTests/Visitors/MarkupFormatterTests.swift @@ -1265,9 +1265,9 @@ class MarkupFormatterTableTests: XCTestCase { │ └─ Link destination: "https://swift.org" │ └─ Text "https://swift.org" └─ Row - ├─ Cell + ├─ Cell colspan: 2 │ └─ InlineHTML
- ├─ Cell + ├─ Cell colspan: 0 └─ Cell """ XCTAssertEqual(expectedDump, document.debugDescription()) @@ -1277,7 +1277,58 @@ class MarkupFormatterTableTests: XCTestCase { |*A* |**B** |~C~ | |:-------------------------|:--------------------:|------------------:| |[Apple](https://apple.com)|![image](image.png "")|| - |
| | | + |
|| | + """ + + XCTAssertEqual(expected, formatted) + print(formatted) + + let reparsed = Document(parsing: formatted) + print(reparsed.debugDescription()) + XCTAssertTrue(document.hasSameStructure(as: reparsed)) + } + + func testRoundTripRowspan() { + let source = """ + | one | two | three | + | --- | --- | ----- | + | big || small | + | ^ || small | + """ + + let document = Document(parsing: source) + + let expectedDump = """ + Document + └─ Table alignments: |-|-|-| + ├─ Head + │ ├─ Cell + │ │ └─ Text "one" + │ ├─ Cell + │ │ └─ Text "two" + │ └─ Cell + │ └─ Text "three" + └─ Body + ├─ Row + │ ├─ Cell colspan: 2 rowspan: 2 + │ │ └─ Text "big" + │ ├─ Cell colspan: 0 + │ └─ Cell + │ └─ Text "small" + └─ Row + ├─ Cell colspan: 2 rowspan: 0 + ├─ Cell colspan: 0 + └─ Cell + └─ Text "small" + """ + XCTAssertEqual(expectedDump, document.debugDescription()) + + let formatted = document.format() + let expected = """ + |one|two|three| + |---|---|-----| + |big ||small| + |^ ||small| """ XCTAssertEqual(expected, formatted)