Skip to content

Add support for row- and column-span in tables #385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 21, 2022
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
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

124 changes: 115 additions & 9 deletions Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,13 +235,16 @@ public enum RenderBlockContent: Equatable {
public var header: HeaderType
/// The rows in this table.
public var rows: [TableRow]
/// Any extended information that describes cells in this table.
public var extendedData: Set<TableCellExtendedData>
/// Additional metadata for this table, if present.
public var metadata: RenderContentMetadata?

/// Creates a new table with the given data.
public init(header: HeaderType, rows: [TableRow], metadata: RenderContentMetadata? = nil) {
public init(header: HeaderType, rows: [TableRow], extendedData: Set<TableCellExtendedData>, metadata: RenderContentMetadata? = nil) {
self.header = header
self.rows = rows
self.extendedData = extendedData
self.metadata = metadata
}
}
Expand Down Expand Up @@ -382,6 +385,36 @@ public enum RenderBlockContent: Equatable {
cells = try container.decode([Cell].self)
}
}

/// Extended data that may be applied to a table cell.
public struct TableCellExtendedData: Equatable, Hashable {
/// The row coordinate for the cell described by this data.
public let rowIndex: Int
/// The column coordinate for the cell described by this data.
public let columnIndex: Int

/// The number of columns this cell spans over.
///
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
/// over" by a previous cell in this row. A value of greater than 1 means that this cell
/// "spans over" later cells in this row.
public let colspan: UInt

/// The number of rows this cell spans over.
///
/// A value of 1 is the default. A value of zero means that this cell is being "spanned
/// over" by another cell in a previous row. A value of greater than one means that this
/// cell "spans over" other cells in later rows.
public let rowspan: UInt

public init(rowIndex: Int, columnIndex: Int,
colspan: UInt, rowspan: UInt) {
self.rowIndex = rowIndex
self.columnIndex = columnIndex
self.colspan = colspan
self.rowspan = rowspan
}
}

/// A term definition.
///
Expand Down Expand Up @@ -442,6 +475,83 @@ public enum RenderBlockContent: Equatable {
}
}

// Writing a manual Codable implementation for tables because the encoding of `extendedData` does
// not follow from the struct layout.
extension RenderBlockContent.Table: Codable {
enum CodingKeys: String, CodingKey {
case header, rows, extendedData, metadata
}

// TableCellExtendedData encodes the row and column indices as a dynamic key with the format "{row}_{column}".
struct DynamicIndexCodingKey: CodingKey, Equatable {
let row, column: Int
init(row: Int, column: Int) {
self.row = row
self.column = column
}

var stringValue: String {
return "\(row)_\(column)"
}
init?(stringValue: String) {
let coordinates = stringValue.split(separator: "_")
guard coordinates.count == 2,
let rowIndex = Int(coordinates.first!),
let columnIndex = Int(coordinates.last!) else {
return nil
}
row = rowIndex
column = columnIndex
}
// The key is only represented by a string value
var intValue: Int? { nil }
init?(intValue: Int) { nil }
}

enum ExtendedDataCodingKeys: String, CodingKey {
case colspan, rowspan
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

self.header = try container.decode(RenderBlockContent.HeaderType.self, forKey: .header)
self.rows = try container.decode([RenderBlockContent.TableRow].self, forKey: .rows)
self.metadata = try container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)

var extendedData = Set<RenderBlockContent.TableCellExtendedData>()
if container.contains(.extendedData) {
let dataContainer = try container.nestedContainer(keyedBy: DynamicIndexCodingKey.self, forKey: .extendedData)

for index in dataContainer.allKeys {
let cellContainer = try dataContainer.nestedContainer(keyedBy: ExtendedDataCodingKeys.self, forKey: index)
extendedData.insert(.init(rowIndex: index.row,
columnIndex: index.column,
colspan: try cellContainer.decode(UInt.self, forKey: .colspan),
rowspan: try cellContainer.decode(UInt.self, forKey: .rowspan)))
}
}
self.extendedData = extendedData
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(header, forKey: .header)
try container.encode(rows, forKey: .rows)
try container.encodeIfPresent(metadata, forKey: .metadata)

if !extendedData.isEmpty {
var dataContainer = container.nestedContainer(keyedBy: DynamicIndexCodingKey.self, forKey: .extendedData)
for data in extendedData {
var cellContainer = dataContainer.nestedContainer(keyedBy: ExtendedDataCodingKeys.self,
forKey: .init(row: data.rowIndex, column: data.columnIndex))
try cellContainer.encode(data.colspan, forKey: .colspan)
try cellContainer.encode(data.rowspan, forKey: .rowspan)
}
}
}
}

// Codable conformance
extension RenderBlockContent: Codable {
private enum CodingKeys: CodingKey {
Expand Down Expand Up @@ -488,11 +598,8 @@ extension RenderBlockContent: Codable {
case .dictionaryExample:
self = try .dictionaryExample(.init(summary: container.decodeIfPresent([RenderBlockContent].self, forKey: .summary), example: container.decode(CodeExample.self, forKey: .example)))
case .table:
self = try .table(.init(
header: container.decode(HeaderType.self, forKey: .header),
rows: container.decode([TableRow].self, forKey: .rows),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
))
// Defer to Table's own Codable implemenatation to parse `extendedData` properly.
self = try .table(.init(from: decoder))
case .termList:
self = try .termList(.init(items: container.decode([TermListItem].self, forKey: .items)))
case .row:
Expand Down Expand Up @@ -569,9 +676,8 @@ extension RenderBlockContent: Codable {
try container.encodeIfPresent(e.summary, forKey: .summary)
try container.encode(e.example, forKey: .example)
case .table(let t):
try container.encode(t.header, forKey: .header)
try container.encode(t.rows, forKey: .rows)
try container.encodeIfPresent(t.metadata, forKey: .metadata)
// Defer to Table's own Codable implemenatation to format `extendedData` properly.
try t.encode(to: encoder)
case .termList(items: let l):
try container.encode(l.items, forKey: .items)
case .row(let row):
Expand Down
11 changes: 10 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,23 +185,32 @@ struct RenderContentCompiler: MarkupVisitor {
}

mutating func visitTable(_ table: Table) -> [RenderContent] {
var extendedData = Set<RenderBlockContent.TableCellExtendedData>()

var headerCells = [RenderBlockContent.TableRow.Cell]()
for cell in table.head.cells {
let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))})
headerCells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))])
if cell.colspan != 1 || cell.rowspan != 1 {
extendedData.insert(.init(rowIndex: 0, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan))
}
}

var rows = [RenderBlockContent.TableRow]()
for row in table.body.rows {
let rowIndex = row.indexInParent + 1
var cells = [RenderBlockContent.TableRow.Cell]()
for cell in row.cells {
let cellContent = cell.children.reduce(into: [], { result, child in result.append(contentsOf: visit(child))})
cells.append([RenderBlockContent.paragraph(.init(inlineContent: cellContent as! [RenderInlineContent]))])
if cell.colspan != 1 || cell.rowspan != 1 {
extendedData.insert(.init(rowIndex: rowIndex, columnIndex: cell.indexInParent, colspan: cell.colspan, rowspan: cell.rowspan))
}
}
rows.append(RenderBlockContent.TableRow(cells: cells))
}

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

mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> [RenderContent] {
Expand Down
18 changes: 18 additions & 0 deletions Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -861,11 +861,29 @@
}
}
},
"extendedData": {
"type": "object",
"description": "Additional data that can be applied per-cell. Property keys have the pattern 'X_Y', where X is the numerical row index and Y is the numerical column index, both starting from zero.",
"additionalProperties": {
"$ref": "#/components/schemas/TableExtendedData"
}
},
"metadata": {
"$ref": "#/components/schemas/RenderContentMetadata"
}
}
},
"TableExtendedData": {
"type": "object",
"properties": {
"colspan": {
"type": "integer"
},
"rowspan": {
"type": "integer"
}
}
},
"Step": {
"required": [
"type",
Expand Down
54 changes: 53 additions & 1 deletion Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ class RenderContentMetadataTests: XCTestCase {
RenderInlineContent.text("Content"),
])

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

Expand Down Expand Up @@ -105,6 +105,58 @@ class RenderContentMetadataTests: XCTestCase {
default: XCTFail("Unexpected element")
}
}

func testRenderingTableSpans() throws {
let (bundle, context) = try testBundleAndContext(named: "TestBundle")
var renderContentCompiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/path", fragment: nil, sourceLanguage: .swift))

let source = """
| one | two | three |
| --- | --- | ----- |
| big || small |
| ^ || small |
"""
let document = Document(parsing: source)

// Verifies that a markdown table renders correctly.

let result = try XCTUnwrap(renderContentCompiler.visit(document.child(at: 0)!))
let renderedTable = try XCTUnwrap(result.first as? RenderBlockContent)

let renderCell: ([RenderBlockContent]) -> String = { cell in
return cell.reduce(into: "") { (result, element) in
switch element {
case .paragraph(let p):
guard let para = p.inlineContent.first else { return }
result.append(para.plainText)
default: XCTFail("Unexpected element"); return
}
}
}

let expectedExtendedData: [RenderBlockContent.TableCellExtendedData] = [
.init(rowIndex: 1, columnIndex: 0, colspan: 2, rowspan: 2),
.init(rowIndex: 1, columnIndex: 1, colspan: 0, rowspan: 1),
.init(rowIndex: 2, columnIndex: 0, colspan: 2, rowspan: 0),
.init(rowIndex: 2, columnIndex: 1, colspan: 0, rowspan: 1)
]

switch renderedTable {
case .table(let t):
XCTAssertEqual(t.header, .row)
XCTAssertEqual(t.rows.count, 3)
guard t.rows.count == 3 else { return }
XCTAssertEqual(t.rows[0].cells.map(renderCell), ["one", "two", "three"])
XCTAssertEqual(t.rows[1].cells.map(renderCell), ["big", "", "small"])
XCTAssertEqual(t.rows[2].cells.map(renderCell), ["", "", "small"])
for expectedData in expectedExtendedData {
XCTAssert(t.extendedData.contains(expectedData))
}
default: XCTFail("Unexpected element")
}

try assertRoundTripCoding(renderedTable)
}

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