Skip to content

Commit ba5e33c

Browse files
add support for row- and column-span in tables
rdar://98017880
1 parent 7e2c7e0 commit ba5e33c

File tree

5 files changed

+202
-16
lines changed

5 files changed

+202
-16
lines changed

Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
121121
package.dependencies += [
122122
.package(url: "https://github.com/apple/swift-nio.git", .upToNextMinor(from: "2.31.2")),
123123
.package(url: "https://github.com/apple/swift-nio-ssl.git", .upToNextMinor(from: "2.15.0")),
124-
.package(name: "swift-markdown", url: "https://github.com/apple/swift-markdown.git", .branch("main")),
124+
.package(name: "swift-markdown", url: "https://github.com/QuietMisdreavus/swift-markdown.git", .branch("table-spans")),
125125
.package(name: "CLMDB", url: "https://github.com/apple/swift-lmdb.git", .branch("main")),
126126
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.0.1")),
127127
.package(name: "SymbolKit", url: "https://github.com/apple/swift-docc-symbolkit", .branch("main")),

Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,16 @@ public enum RenderBlockContent: Equatable {
231231
public var header: HeaderType
232232
/// The rows in this table.
233233
public var rows: [TableRow]
234+
/// Any extended information that describes cells in this table.
235+
public var extendedData: Set<TableCellExtendedData>
234236
/// Additional metadata for this table, if present.
235237
public var metadata: RenderContentMetadata?
236238

237239
/// Creates a new table with the given data.
238-
public init(header: HeaderType, rows: [TableRow], metadata: RenderContentMetadata? = nil) {
240+
public init(header: HeaderType, rows: [TableRow], extendedData: Set<TableCellExtendedData>, metadata: RenderContentMetadata? = nil) {
239241
self.header = header
240242
self.rows = rows
243+
self.extendedData = extendedData
241244
self.metadata = metadata
242245
}
243246
}
@@ -378,6 +381,36 @@ public enum RenderBlockContent: Equatable {
378381
cells = try container.decode([Cell].self)
379382
}
380383
}
384+
385+
/// Extended data that may be applied to a table cell.
386+
public struct TableCellExtendedData: Equatable, Hashable {
387+
/// The row coordinate for the cell described by this data.
388+
public let rowIndex: Int
389+
/// The column coordinate for the cell described by this data.
390+
public let columnIndex: Int
391+
392+
/// The number of columns this cell spans over.
393+
///
394+
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
395+
/// over" by a previous cell in this row. A value of greater than 1 means that this cell
396+
/// "spans over" later cells in this row.
397+
public let colspan: UInt
398+
399+
/// The number of rows this cell spans over.
400+
///
401+
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
402+
/// over" by another cell in a previous row. A value of greater than one means that this
403+
/// cell "spans over" other cells in later rows.
404+
public let rowspan: UInt
405+
406+
public init(rowIndex: Int, columnIndex: Int,
407+
colspan: UInt, rowspan: UInt) {
408+
self.rowIndex = rowIndex
409+
self.columnIndex = columnIndex
410+
self.colspan = colspan
411+
self.rowspan = rowspan
412+
}
413+
}
381414

382415
/// A term definition.
383416
///
@@ -429,6 +462,102 @@ public enum RenderBlockContent: Equatable {
429462
}
430463
}
431464

465+
// Writing a manual Codable implementation for tables because the encoding of `extendedData` does
466+
// not follow from the struct layout.
467+
extension RenderBlockContent.Table: Codable {
468+
// `extendedData` is encoded as a keyed container where the "keys" are the cell index, and
469+
// the "values" are the remaining fields in the struct. The key is formatted as a string with
470+
// the format "{row}_{column}", which is represented here as the `.index(row:column:)` enum
471+
// case. This CodingKey implementation performs that parsing and formatting so that the
472+
// Encodable/Decodable implementation can use the plain numbered indices.
473+
enum CodingKeys: CodingKey, Equatable {
474+
case header, rows, extendedData, metadata
475+
case index(row: Int, column: Int)
476+
case colspan, rowspan
477+
478+
var stringValue: String {
479+
switch self {
480+
case .header: return "header"
481+
case .rows: return "rows"
482+
case .extendedData: return "extendedData"
483+
case .metadata: return "metadata"
484+
case .colspan: return "colspan"
485+
case .rowspan: return "rowspan"
486+
case let .index(row, column): return "\(row)_\(column)"
487+
}
488+
}
489+
490+
init?(stringValue: String) {
491+
switch stringValue {
492+
case "header": self = .header
493+
case "rows": self = .rows
494+
case "extendedData": self = .extendedData
495+
case "metadata": self = .metadata
496+
case "colspan": self = .colspan
497+
case "rowspan": self = .rowspan
498+
default:
499+
let coordinates = stringValue.split(separator: "_")
500+
guard coordinates.count == 2,
501+
let rowIndex = Int(coordinates.first!),
502+
let columnIndex = Int(coordinates.last!) else {
503+
return nil
504+
}
505+
self = .index(row: rowIndex, column: columnIndex)
506+
}
507+
}
508+
509+
var intValue: Int? { nil }
510+
511+
init?(intValue: Int) {
512+
return nil
513+
}
514+
}
515+
516+
public init(from decoder: Decoder) throws {
517+
let container = try decoder.container(keyedBy: CodingKeys.self)
518+
519+
var extendedData = Set<RenderBlockContent.TableCellExtendedData>()
520+
if container.allKeys.contains(.extendedData) {
521+
let dataContainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .extendedData)
522+
523+
for index in dataContainer.allKeys {
524+
guard case let .index(row, column) = index else { continue }
525+
526+
let cellContainer = try dataContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: index)
527+
extendedData.insert(.init(rowIndex: row,
528+
columnIndex: column,
529+
colspan: try cellContainer.decode(UInt.self, forKey: .colspan),
530+
rowspan: try cellContainer.decode(UInt.self, forKey: .rowspan)))
531+
}
532+
}
533+
534+
self = .init(header: try container.decode(RenderBlockContent.HeaderType.self, forKey: .header),
535+
rows: try container.decode([RenderBlockContent.TableRow].self, forKey: .rows),
536+
extendedData: extendedData,
537+
metadata: try container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata))
538+
}
539+
540+
public func encode(to encoder: Encoder) throws {
541+
var container = encoder.container(keyedBy: CodingKeys.self)
542+
543+
try container.encode(header, forKey: .header)
544+
try container.encode(rows, forKey: .rows)
545+
546+
if !extendedData.isEmpty {
547+
var dataContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .extendedData)
548+
for data in extendedData {
549+
var cellContainer = dataContainer.nestedContainer(keyedBy: CodingKeys.self,
550+
forKey: .index(row: data.rowIndex,
551+
column: data.columnIndex))
552+
try cellContainer.encode(data.colspan, forKey: .colspan)
553+
try cellContainer.encode(data.rowspan, forKey: .rowspan)
554+
}
555+
}
556+
557+
try container.encodeIfPresent(metadata, forKey: .metadata)
558+
}
559+
}
560+
432561
// Codable conformance
433562
extension RenderBlockContent: Codable {
434563
private enum CodingKeys: CodingKey {
@@ -475,11 +604,8 @@ extension RenderBlockContent: Codable {
475604
case .dictionaryExample:
476605
self = try .dictionaryExample(.init(summary: container.decodeIfPresent([RenderBlockContent].self, forKey: .summary), example: container.decode(CodeExample.self, forKey: .example)))
477606
case .table:
478-
self = try .table(.init(
479-
header: container.decode(HeaderType.self, forKey: .header),
480-
rows: container.decode([TableRow].self, forKey: .rows),
481-
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
482-
))
607+
// Defer to Table's own Codable implemenatation to parse `extendedData` properly.
608+
self = try .table(.init(from: decoder))
483609
case .termList:
484610
self = try .termList(.init(items: container.decode([TermListItem].self, forKey: .items)))
485611
case .row:
@@ -551,9 +677,8 @@ extension RenderBlockContent: Codable {
551677
try container.encodeIfPresent(e.summary, forKey: .summary)
552678
try container.encode(e.example, forKey: .example)
553679
case .table(let t):
554-
try container.encode(t.header, forKey: .header)
555-
try container.encode(t.rows, forKey: .rows)
556-
try container.encodeIfPresent(t.metadata, forKey: .metadata)
680+
// Defer to Table's own Codable implemenatation to format `extendedData` properly.
681+
try t.encode(to: encoder)
557682
case .termList(items: let l):
558683
try container.encode(l.items, forKey: .items)
559684
case .row(let row):

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,23 +185,32 @@ struct RenderContentCompiler: MarkupVisitor {
185185
}
186186

187187
mutating func visitTable(_ table: Table) -> [RenderContent] {
188+
var extendedData = Set<RenderBlockContent.TableCellExtendedData>()
189+
188190
var headerCells = [RenderBlockContent.TableRow.Cell]()
189191
for cell in table.head.cells {
190192
let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))})
191193
headerCells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))])
194+
if cell.colspan != 1 || cell.rowspan != 1 {
195+
extendedData.insert(.init(rowIndex: 0, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan))
196+
}
192197
}
193198

194199
var rows = [RenderBlockContent.TableRow]()
195200
for row in table.body.rows {
201+
let rowIndex = row.indexInParent + 1
196202
var cells = [RenderBlockContent.TableRow.Cell]()
197203
for cell in row.cells {
198204
let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))})
199205
cells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))])
206+
if cell.colspan != 1 || cell.rowspan != 1 {
207+
extendedData.insert(.init(rowIndex: rowIndex, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan))
208+
}
200209
}
201210
rows.append(RenderBlockContent.TableRow(cells: cells))
202211
}
203212

204-
return [RenderBlockContent.table(.init(header: .row, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, metadata: nil))]
213+
return [RenderBlockContent.table(.init(header: .row, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, extendedData: extendedData, metadata: nil))]
205214
}
206215

207216
mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> [RenderContent] {

Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class RenderContentMetadataTests: XCTestCase {
3737
RenderInlineContent.text("Content"),
3838
])
3939

40-
let table = RenderBlockContent.table(.init(header: .both, rows: [], metadata: metadata))
40+
let table = RenderBlockContent.table(.init(header: .both, rows: [], extendedData: [], metadata: metadata))
4141
let data = try JSONEncoder().encode(table)
4242
let roundtrip = try JSONDecoder().decode(RenderBlockContent.self, from: data)
4343

@@ -105,6 +105,58 @@ class RenderContentMetadataTests: XCTestCase {
105105
default: XCTFail("Unexpected element")
106106
}
107107
}
108+
109+
func testRenderingTableSpans() throws {
110+
let (bundle, context) = try testBundleAndContext(named: "TestBundle")
111+
var renderContentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/path", fragment: nil, sourceLanguage: .swift))
112+
113+
let source = """
114+
| one | two | three |
115+
| --- | --- | ----- |
116+
| big || small |
117+
| ^ || small |
118+
"""
119+
let document = Document(parsing: source)
120+
121+
// Verifies that a markdown table renders correctly.
122+
123+
let result = try XCTUnwrap(renderContentCompiler.visit(document.child(at: 0)!))
124+
let renderedTable = try XCTUnwrap(result.first as? RenderBlockContent)
125+
126+
let renderCell: ([RenderBlockContent]) -> String = { cell in
127+
return cell.reduce(into: "") { (result, element) in
128+
switch element {
129+
case .paragraph(let p):
130+
guard let para = p.inlineContent.first else { return }
131+
result.append(para.plainText)
132+
default: XCTFail("Unexpected element"); return
133+
}
134+
}
135+
}
136+
137+
let expectedExtendedData: [RenderBlockContent.TableCellExtendedData] = [
138+
.init(rowIndex: 1, columnIndex: 0, colspan: 2, rowspan: 2),
139+
.init(rowIndex: 1, columnIndex: 1, colspan: 0, rowspan: 1),
140+
.init(rowIndex: 2, columnIndex: 0, colspan: 2, rowspan: 0),
141+
.init(rowIndex: 2, columnIndex: 1, colspan: 0, rowspan: 1)
142+
]
143+
144+
switch renderedTable {
145+
case .table(let t):
146+
XCTAssertEqual(t.header, .row)
147+
XCTAssertEqual(t.rows.count, 3)
148+
guard t.rows.count == 3 else { return }
149+
XCTAssertEqual(t.rows[0].cells.map(renderCell), ["one", "two", "three"])
150+
XCTAssertEqual(t.rows[1].cells.map(renderCell), ["big", "", "small"])
151+
XCTAssertEqual(t.rows[2].cells.map(renderCell), ["", "", "small"])
152+
for expectedData in expectedExtendedData {
153+
XCTAssert(t.extendedData.contains(expectedData))
154+
}
155+
default: XCTFail("Unexpected element")
156+
}
157+
158+
try assertRoundTripCoding(renderedTable)
159+
}
108160

109161
func testStrikethrough() throws {
110162
let (bundle, context) = try testBundleAndContext(named: "TestBundle")

0 commit comments

Comments
 (0)