Skip to content

Commit 3e38626

Browse files
add support for row- and column-span in tables
rdar://98017880
1 parent 0253ded commit 3e38626

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
@@ -123,7 +123,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
123123
package.dependencies += [
124124
.package(url: "https://github.com/apple/swift-nio.git", .upToNextMinor(from: "2.31.2")),
125125
.package(url: "https://github.com/apple/swift-nio-ssl.git", .upToNextMinor(from: "2.15.0")),
126-
.package(name: "swift-markdown", url: "https://github.com/apple/swift-markdown.git", .branch("main")),
126+
.package(name: "swift-markdown", url: "https://github.com/QuietMisdreavus/swift-markdown.git", .branch("table-spans")),
127127
.package(name: "CLMDB", url: "https://github.com/apple/swift-lmdb.git", .branch("main")),
128128
.package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "1.0.1")),
129129
.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
@@ -235,13 +235,16 @@ public enum RenderBlockContent: Equatable {
235235
public var header: HeaderType
236236
/// The rows in this table.
237237
public var rows: [TableRow]
238+
/// Any extended information that describes cells in this table.
239+
public var extendedData: Set<TableCellExtendedData>
238240
/// Additional metadata for this table, if present.
239241
public var metadata: RenderContentMetadata?
240242

241243
/// Creates a new table with the given data.
242-
public init(header: HeaderType, rows: [TableRow], metadata: RenderContentMetadata? = nil) {
244+
public init(header: HeaderType, rows: [TableRow], extendedData: Set<TableCellExtendedData>, metadata: RenderContentMetadata? = nil) {
243245
self.header = header
244246
self.rows = rows
247+
self.extendedData = extendedData
245248
self.metadata = metadata
246249
}
247250
}
@@ -382,6 +385,36 @@ public enum RenderBlockContent: Equatable {
382385
cells = try container.decode([Cell].self)
383386
}
384387
}
388+
389+
/// Extended data that may be applied to a table cell.
390+
public struct TableCellExtendedData: Equatable, Hashable {
391+
/// The row coordinate for the cell described by this data.
392+
public let rowIndex: Int
393+
/// The column coordinate for the cell described by this data.
394+
public let columnIndex: Int
395+
396+
/// The number of columns this cell spans over.
397+
///
398+
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
399+
/// over" by a previous cell in this row. A value of greater than 1 means that this cell
400+
/// "spans over" later cells in this row.
401+
public let colspan: UInt
402+
403+
/// The number of rows this cell spans over.
404+
///
405+
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
406+
/// over" by another cell in a previous row. A value of greater than one means that this
407+
/// cell "spans over" other cells in later rows.
408+
public let rowspan: UInt
409+
410+
public init(rowIndex: Int, columnIndex: Int,
411+
colspan: UInt, rowspan: UInt) {
412+
self.rowIndex = rowIndex
413+
self.columnIndex = columnIndex
414+
self.colspan = colspan
415+
self.rowspan = rowspan
416+
}
417+
}
385418

386419
/// A term definition.
387420
///
@@ -442,6 +475,102 @@ public enum RenderBlockContent: Equatable {
442475
}
443476
}
444477

478+
// Writing a manual Codable implementation for tables because the encoding of `extendedData` does
479+
// not follow from the struct layout.
480+
extension RenderBlockContent.Table: Codable {
481+
// `extendedData` is encoded as a keyed container where the "keys" are the cell index, and
482+
// the "values" are the remaining fields in the struct. The key is formatted as a string with
483+
// the format "{row}_{column}", which is represented here as the `.index(row:column:)` enum
484+
// case. This CodingKey implementation performs that parsing and formatting so that the
485+
// Encodable/Decodable implementation can use the plain numbered indices.
486+
enum CodingKeys: CodingKey, Equatable {
487+
case header, rows, extendedData, metadata
488+
case index(row: Int, column: Int)
489+
case colspan, rowspan
490+
491+
var stringValue: String {
492+
switch self {
493+
case .header: return "header"
494+
case .rows: return "rows"
495+
case .extendedData: return "extendedData"
496+
case .metadata: return "metadata"
497+
case .colspan: return "colspan"
498+
case .rowspan: return "rowspan"
499+
case let .index(row, column): return "\(row)_\(column)"
500+
}
501+
}
502+
503+
init?(stringValue: String) {
504+
switch stringValue {
505+
case "header": self = .header
506+
case "rows": self = .rows
507+
case "extendedData": self = .extendedData
508+
case "metadata": self = .metadata
509+
case "colspan": self = .colspan
510+
case "rowspan": self = .rowspan
511+
default:
512+
let coordinates = stringValue.split(separator: "_")
513+
guard coordinates.count == 2,
514+
let rowIndex = Int(coordinates.first!),
515+
let columnIndex = Int(coordinates.last!) else {
516+
return nil
517+
}
518+
self = .index(row: rowIndex, column: columnIndex)
519+
}
520+
}
521+
522+
var intValue: Int? { nil }
523+
524+
init?(intValue: Int) {
525+
return nil
526+
}
527+
}
528+
529+
public init(from decoder: Decoder) throws {
530+
let container = try decoder.container(keyedBy: CodingKeys.self)
531+
532+
var extendedData = Set<RenderBlockContent.TableCellExtendedData>()
533+
if container.allKeys.contains(.extendedData) {
534+
let dataContainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .extendedData)
535+
536+
for index in dataContainer.allKeys {
537+
guard case let .index(row, column) = index else { continue }
538+
539+
let cellContainer = try dataContainer.nestedContainer(keyedBy: CodingKeys.self, forKey: index)
540+
extendedData.insert(.init(rowIndex: row,
541+
columnIndex: column,
542+
colspan: try cellContainer.decode(UInt.self, forKey: .colspan),
543+
rowspan: try cellContainer.decode(UInt.self, forKey: .rowspan)))
544+
}
545+
}
546+
547+
self = .init(header: try container.decode(RenderBlockContent.HeaderType.self, forKey: .header),
548+
rows: try container.decode([RenderBlockContent.TableRow].self, forKey: .rows),
549+
extendedData: extendedData,
550+
metadata: try container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata))
551+
}
552+
553+
public func encode(to encoder: Encoder) throws {
554+
var container = encoder.container(keyedBy: CodingKeys.self)
555+
556+
try container.encode(header, forKey: .header)
557+
try container.encode(rows, forKey: .rows)
558+
559+
if !extendedData.isEmpty {
560+
var dataContainer = container.nestedContainer(keyedBy: CodingKeys.self, forKey: .extendedData)
561+
for data in extendedData {
562+
var cellContainer = dataContainer.nestedContainer(keyedBy: CodingKeys.self,
563+
forKey: .index(row: data.rowIndex,
564+
column: data.columnIndex))
565+
try cellContainer.encode(data.colspan, forKey: .colspan)
566+
try cellContainer.encode(data.rowspan, forKey: .rowspan)
567+
}
568+
}
569+
570+
try container.encodeIfPresent(metadata, forKey: .metadata)
571+
}
572+
}
573+
445574
// Codable conformance
446575
extension RenderBlockContent: Codable {
447576
private enum CodingKeys: CodingKey {
@@ -488,11 +617,8 @@ extension RenderBlockContent: Codable {
488617
case .dictionaryExample:
489618
self = try .dictionaryExample(.init(summary: container.decodeIfPresent([RenderBlockContent].self, forKey: .summary), example: container.decode(CodeExample.self, forKey: .example)))
490619
case .table:
491-
self = try .table(.init(
492-
header: container.decode(HeaderType.self, forKey: .header),
493-
rows: container.decode([TableRow].self, forKey: .rows),
494-
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
495-
))
620+
// Defer to Table's own Codable implemenatation to parse `extendedData` properly.
621+
self = try .table(.init(from: decoder))
496622
case .termList:
497623
self = try .termList(.init(items: container.decode([TermListItem].self, forKey: .items)))
498624
case .row:
@@ -569,9 +695,8 @@ extension RenderBlockContent: Codable {
569695
try container.encodeIfPresent(e.summary, forKey: .summary)
570696
try container.encode(e.example, forKey: .example)
571697
case .table(let t):
572-
try container.encode(t.header, forKey: .header)
573-
try container.encode(t.rows, forKey: .rows)
574-
try container.encodeIfPresent(t.metadata, forKey: .metadata)
698+
// Defer to Table's own Codable implemenatation to format `extendedData` properly.
699+
try t.encode(to: encoder)
575700
case .termList(items: let l):
576701
try container.encode(l.items, forKey: .items)
577702
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)