From 9c1ef0b451c3738b5eec311aa9a7b8afbb336f25 Mon Sep 17 00:00:00 2001 From: Victoria Mitchell Date: Wed, 14 Sep 2022 10:49:57 -0600 Subject: [PATCH 1/2] parse table cell spans in cmark and use the information rdar://98017807 --- Sources/Markdown/Base/RawMarkup.swift | 21 +++++-- .../Block Nodes/Tables/TableCell.swift | 40 +++++++++++- .../Markdown/Parser/CommonMarkConverter.swift | 13 ++-- .../Walker/Walkers/MarkupFormatter.swift | 61 ++++++++++++++++--- .../Walker/Walkers/MarkupTreeDumper.swift | 16 +++++ .../Block Nodes/TableTests.swift | 36 +++++++++++ .../Visitors/MarkupFormatterTests.swift | 57 ++++++++++++++++- 7 files changed, 224 insertions(+), 20 deletions(-) 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..e2c447ae 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`. @@ -958,9 +980,21 @@ public struct MarkupFormatter: MarkupWalker { /// extending each line with spaces to fit the uniform column width. let expandedHeaderCellTexts = (0.. 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 { + // With a colspan, we want to expand the cell width to + // cover multiple columns. This helpfully generalizes + // to `colspan == 1`, where it will only query the + // current column! + let lastColumn = column + Int(colspan) + let minLineLength = (column.. [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 { + // With a colspan, we want to expand the cell width to + // cover multiple columns. This helpfully generalizes + // to `colspan == 1`, where it will only query the + // current column! + let lastColumn = column + Int(colspan) + let minLineLength = (column.. - ├─ 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) From 8b578b85b5598ddc7a2a975f814ef473205ab5e4 Mon Sep 17 00:00:00 2001 From: Victoria Mitchell Date: Tue, 20 Sep 2022 14:02:04 -0600 Subject: [PATCH 2/2] review: factor column-width calculation together --- .../Walker/Walkers/MarkupFormatter.swift | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift index e2c447ae..f961de98 100644 --- a/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift +++ b/Sources/Markdown/Walker/Walkers/MarkupFormatter.swift @@ -974,6 +974,16 @@ 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..