diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 4ef0999d1a..1471eb89e0 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -240,9 +240,14 @@ public enum RenderBlockContent: Equatable { } /// A table that contains a list of row data. - public struct Table: Equatable { + public struct Table { /// The style of header in this table. public var header: HeaderType + /// The text alignment of each column in this table. + /// + /// A `nil` value for this property is the same as all the columns being + /// ``RenderBlockContent/ColumnAlignment/unset``. + public var alignments: [ColumnAlignment]? /// The rows in this table. public var rows: [TableRow] /// Any extended information that describes cells in this table. @@ -251,11 +256,23 @@ public enum RenderBlockContent: Equatable { public var metadata: RenderContentMetadata? /// Creates a new table with the given data. - public init(header: HeaderType, rows: [TableRow], extendedData: Set, metadata: RenderContentMetadata? = nil) { + /// + /// - Parameters: + /// - header: The style of header in this table. + /// - rawAlignments: The text alignment for each column in this table. If all the + /// alignments are ``RenderBlockContent/ColumnAlignment/unset``, the ``alignments`` + /// property will be set to `nil`. + /// - rows: The cell data for this table. + /// - extendedData: Any extended information that describes cells in this table. + /// - metadata: Additional metadata for this table, if necessary. + public init(header: HeaderType, rawAlignments: [ColumnAlignment]? = nil, rows: [TableRow], extendedData: Set, metadata: RenderContentMetadata? = nil) { self.header = header self.rows = rows self.extendedData = extendedData self.metadata = metadata + if let alignments = rawAlignments, !alignments.allSatisfy({ $0 == .unset }) { + self.alignments = alignments + } } } @@ -374,6 +391,18 @@ public enum RenderBlockContent: Equatable { /// The table doesn't contain headers. case none } + + /// The methods by which a table column can have its text aligned. + public enum ColumnAlignment: String, Codable, Equatable { + /// Force text alignment to be left-justified. + case left + /// Force text alignment to be right-justified. + case right + /// Force text alignment to be centered. + case center + /// Leave text alignment to the default. + case unset + } /// A table row that contains a list of row cells. public struct TableRow: Codable, Equatable { @@ -548,11 +577,35 @@ public enum RenderBlockContent: Equatable { } } +extension RenderBlockContent.Table: Equatable { + public static func == (lhs: RenderBlockContent.Table, rhs: RenderBlockContent.Table) -> Bool { + guard lhs.header == rhs.header + && lhs.extendedData == rhs.extendedData + && lhs.metadata == rhs.metadata + && lhs.rows == rhs.rows + else { + return false + } + + var lhsAlignments = lhs.alignments + if let align = lhsAlignments, align.allSatisfy({ $0 == .unset }) { + lhsAlignments = nil + } + + var rhsAlignments = rhs.alignments + if let align = rhsAlignments, align.allSatisfy({ $0 == .unset }) { + rhsAlignments = nil + } + + return lhsAlignments == rhsAlignments + } +} + // 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 + case header, alignments, rows, extendedData, metadata } // TableCellExtendedData encodes the row and column indices as a dynamic key with the format "{row}_{column}". @@ -589,6 +642,14 @@ extension RenderBlockContent.Table: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.header = try container.decode(RenderBlockContent.HeaderType.self, forKey: .header) + + let rawAlignments = try container.decodeIfPresent([RenderBlockContent.ColumnAlignment].self, forKey: .alignments) + if let alignments = rawAlignments, !alignments.allSatisfy({ $0 == .unset }) { + self.alignments = alignments + } else { + self.alignments = nil + } + self.rows = try container.decode([RenderBlockContent.TableRow].self, forKey: .rows) self.metadata = try container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata) @@ -610,6 +671,9 @@ extension RenderBlockContent.Table: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(header, forKey: .header) + if let alignments = alignments, !alignments.isEmpty, !alignments.allSatisfy({ $0 == .unset }) { + try container.encode(alignments, forKey: .alignments) + } try container.encode(rows, forKey: .rows) try container.encodeIfPresent(metadata, forKey: .metadata) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 0808fdccb2..4d16754306 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -262,8 +262,30 @@ struct RenderContentCompiler: MarkupVisitor { } rows.append(RenderBlockContent.TableRow(cells: cells)) } + + var tempAlignments = [RenderBlockContent.ColumnAlignment]() + for alignment in table.columnAlignments { + switch alignment { + case .left: tempAlignments.append(.left) + case .right: tempAlignments.append(.right) + case .center: tempAlignments.append(.center) + case nil: tempAlignments.append(.unset) + } + } + while tempAlignments.count < table.maxColumnCount { + tempAlignments.append(.unset) + } + if tempAlignments.allSatisfy({ $0 == .unset }) { + tempAlignments = [] + } + let alignments: [RenderBlockContent.ColumnAlignment]? + if tempAlignments.isEmpty { + alignments = nil + } else { + alignments = tempAlignments + } - return [RenderBlockContent.table(.init(header: .row, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, extendedData: extendedData, metadata: nil))] + return [RenderBlockContent.table(.init(header: .row, rawAlignments: alignments, rows: [RenderBlockContent.TableRow(cells: headerCells)] + rows, extendedData: extendedData, metadata: nil))] } mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> [RenderContent] { diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index a354f6c0ec..a1fc6bcad8 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -949,6 +949,18 @@ "none" ] }, + "alignments": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "left", + "right", + "center", + "unset" + ] + } + }, "rows": { "type": "array", "items": { diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index e1c85e9b25..07d79cac60 100644 --- a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift @@ -102,6 +102,7 @@ class RenderContentMetadataTests: XCTestCase { XCTAssertEqual(t.rows[0].cells.map(renderCell), ["Column 1", "Column 2"]) XCTAssertEqual(t.rows[1].cells.map(renderCell), ["Cell 1", "Cell 2"]) XCTAssertEqual(t.rows[2].cells.map(renderCell), ["Cell 3", "Cell 4"]) + XCTAssertNil(t.alignments) default: XCTFail("Unexpected element") } } @@ -152,11 +153,127 @@ class RenderContentMetadataTests: XCTestCase { for expectedData in expectedExtendedData { XCTAssert(t.extendedData.contains(expectedData)) } + XCTAssertNil(t.alignments) default: XCTFail("Unexpected element") } try assertRoundTripCoding(renderedTable) } + + func testRenderingTableColumnAlignments() 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 | four | + | :-- | --: | :---: | ---- | + | one | two | three | four | + """ + 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 + } + } + } + + switch renderedTable { + case .table(let t): + XCTAssertEqual(t.header, .row) + XCTAssertEqual(t.rows.count, 2) + guard t.rows.count == 2 else { return } + XCTAssertEqual(t.rows[0].cells.map(renderCell), ["one", "two", "three", "four"]) + XCTAssertEqual(t.rows[1].cells.map(renderCell), ["one", "two", "three", "four"]) + XCTAssertEqual(t.alignments, [.left, .right, .center, .unset]) + default: XCTFail("Unexpected element") + } + + try assertRoundTripCoding(renderedTable) + } + + /// Verifies that a table with `nil` alignments and a table with all-unset alignments still compare as equal. + func testRenderedTableEquality() 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 = """ + | Column 1 | Column 2 | + | ------------- | ------------- | + | Cell 1 | Cell 2 | + | Cell 3 | Cell 4 | + """ + let document = Document(parsing: source) + + let result = try XCTUnwrap(renderContentCompiler.visit(document.child(at: 0)!)) + let renderedTable = try XCTUnwrap(result.first as? RenderBlockContent) + guard case let .table(decodedTable) = renderedTable else { + XCTFail("Unexpected RenderBlockContent element") + return + } + XCTAssertNil(decodedTable.alignments) + var modifiedTable = decodedTable + modifiedTable.alignments = [.unset, .unset] + + XCTAssertEqual(decodedTable, modifiedTable) + } + + /// Verifies that two tables with otherwise-identical contents but different column alignments compare as unequal. + func testRenderedTableInequality() 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 decodedTableWithUnsetColumns: RenderBlockContent.Table + do { + let source = """ + | Column 1 | Column 2 | + | ------------- | ------------- | + | Cell 1 | Cell 2 | + | Cell 3 | Cell 4 | + """ + let document = Document(parsing: source) + + let result = try XCTUnwrap(renderContentCompiler.visit(document.child(at: 0)!)) + let renderedTable = try XCTUnwrap(result.first as? RenderBlockContent) + guard case let .table(decodedTable) = renderedTable else { + XCTFail("Unexpected RenderBlockContent element") + return + } + decodedTableWithUnsetColumns = decodedTable + } + + let decodedTableWithLeftColumns: RenderBlockContent.Table + do { + let source = """ + | Column 1 | Column 2 | + | :------------ | :------------ | + | Cell 1 | Cell 2 | + | Cell 3 | Cell 4 | + """ + 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) + guard case let .table(decodedTable) = renderedTable else { + XCTFail("Unexpected RenderBlockContent element") + return + } + decodedTableWithLeftColumns = decodedTable + } + + XCTAssertNotEqual(decodedTableWithUnsetColumns, decodedTableWithLeftColumns) + } func testStrikethrough() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle")