Export table.el tables as HTML tables !146

merged merged by cmc on 2026-10-07 23:25 UTC · krz/orgstar:table-el-html into main

3 files changed, +118 −0

Layout: unified · split

Sources/OrgCore/Export/HTMLExport.swift +14
@@ -287,6 +287,20 @@ final class HTMLRenderer {
287287 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
288288 case .tableEl:
289289 let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") }
290 // As `org-html-table--table.el-table`: cells with their spans, lines kept apart.
291 if let cells = TableEl.cells(lines.joined(separator: "\n")) {
292 var out = "<table class=\"table-el\">\n"
293 for row in Dictionary(grouping: cells, by: \.row).keys.sorted() {
294 out += "<tr>"
295 for cell in cells where cell.row == row {
296 let spans = (cell.colspan > 1 ? " colspan=\"\(cell.colspan)\"" : "") + (cell.rowspan > 1 ? " rowspan=\"\(cell.rowspan)\"" : "")
297 let content = cell.lines.reversed().drop { $0.isEmpty }.reversed().map(Self.escape).joined(separator: "<br>")
298 out += "<td\(spans)>\(content)</td>"
299 }
300 out += "</tr>\n"
301 }
302 return out + "</table>\n"
303 }
290304 return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n"
291305 case .dynamicBlock:
292306 let lines = node.text.components(separatedBy: "\n")
Sources/OrgCore/Export/TableEl.swift added +53
@@ -0,0 +1,53 @@
1import Foundation
2
3/// A table.el table's cells, read from its drawing: each cell's text lines and the grid rows
4/// and columns it spans, as `table-generate-source` finds them.
5enum TableEl {
6 struct Cell: Equatable {
7 var row: Int
8 var lines: [String]
9 var colspan: Int
10 var rowspan: Int
11 }
12
13 /// The cells in reading order, or nil when the drawing isn't a well-formed table.
14 static func cells(_ text: String) -> [Cell]? {
15 let raw = text.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
16 let indent = raw.map { $0.prefix { $0 == " " || $0 == "\t" }.count }.min() ?? 0
17 let grid = raw.map { Array($0.dropFirst(indent)) }
18 guard grid.count >= 3 else { return nil }
19 func at(_ r: Int, _ c: Int) -> Character? { r >= 0 && r < grid.count && c >= 0 && c < grid[r].count ? grid[r][c] : nil }
20 func vertical(_ r: Int, _ c: Int) -> Bool { at(r, c) == "|" || at(r, c) == "+" }
21 func horizontal(_ r: Int, _ c: Int) -> Bool { at(r, c) == "-" || at(r, c) == "+" }
22 // Grid lines: rows holding a horizontal border, columns holding a corner.
23 var rows: [Int] = []
24 var columns: Set<Int> = []
25 for (r, line) in grid.enumerated() {
26 for (c, ch) in line.enumerated() where ch == "+" {
27 columns.insert(c)
28 if horizontal(r, c + 1) || horizontal(r, c - 1) { if rows.last != r { rows.append(r) } }
29 }
30 }
31 var cells: [Cell] = []
32 for r in rows {
33 for c in grid[r].indices where grid[r][c] == "+" && at(r, c + 1) == "-" && vertical(r + 1, c) {
34 // Right edge: the next corner along the top with a border below it.
35 var x2 = c + 1
36 while x2 < grid[r].count, !(grid[r][x2] == "+" && vertical(r + 1, x2)) { x2 += 1 }
37 guard x2 < grid[r].count else { return nil }
38 // Bottom edge: the next corner down the left side with a border to its right.
39 var r2 = r + 1
40 while r2 < grid.count, !(at(r2, c) == "+" && horizontal(r2, c + 1)) { r2 += 1 }
41 guard r2 < grid.count, r2 > r + 1 else { return nil }
42 let lines = (r + 1..<r2).map { line -> String in
43 let row = grid[line]
44 let end = min(x2, row.count)
45 return c + 1 < end ? String(row[(c + 1)..<end]).trimmingCharacters(in: .whitespaces) : ""
46 }
47 cells.append(Cell(row: r, lines: lines, colspan: columns.filter { $0 > c && $0 < x2 }.count + 1,
48 rowspan: rows.filter { $0 > r && $0 < r2 }.count + 1))
49 }
50 }
51 return cells.isEmpty ? nil : cells
52 }
53}
Tests/OrgCoreTests/TableElTests.swift added +51
@@ -0,0 +1,51 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// table.el tables against ox-html: the rows, each cell's spans and text.
6struct TableElTests {
7 static let tables = [
8 "+-----+-----+\n| a | b |\n+-----+-----+\n| c d |\n| more |\n+-----+-----+\n| e | f |\n+-----+-----+\n",
9 "+---+---+\n| a | b |\n| +---+\n| | c |\n+---+---+\n",
10 " +----+---+----+\n | x | y | z |\n +----+---+----+\n | wide row |\n +----+--------+\n | p | q & <r>|\n +----+--------+\n",
11 "+---+\n| 1 |\n| 2 |\n+---+\n",
12 ]
13
14 static func describe(_ cells: [TableEl.Cell]) -> [String] {
15 Dictionary(grouping: cells, by: \.row).keys.sorted().map { row in
16 cells.filter { $0.row == row }.map { "\($0.colspan)x\($0.rowspan):" + $0.lines.reversed().drop { $0.isEmpty }.reversed().joined(separator: "/") }.joined(separator: " ")
17 }
18 }
19
20 /// The rows of ox-html's table, in the same terms.
21 static func describe(html: String) -> [String] {
22 html.components(separatedBy: "<tr>").dropFirst().map { row in
23 row.components(separatedBy: "<td").dropFirst().map { cell -> String in
24 let colspan = cell.firstMatch(of: /colspan="([0-9]+)"/).map { String($0.1) } ?? "1"
25 let rowspan = cell.firstMatch(of: /rowspan="([0-9]+)"/).map { String($0.1) } ?? "1"
26 let body = cell[cell.firstIndex(of: ">")!...].dropFirst().components(separatedBy: "</td>")[0]
27 let lines = body.components(separatedBy: "<br />").map {
28 $0.replacingOccurrences(of: "&nbsp;", with: " ").replacingOccurrences(of: "&amp;", with: "&")
29 .replacingOccurrences(of: "&lt;", with: "<").replacingOccurrences(of: "&gt;", with: ">")
30 .trimmingCharacters(in: .whitespacesAndNewlines)
31 }
32 return "\(colspan)x\(rowspan):" + lines.reversed().drop { $0.isEmpty }.reversed().joined(separator: "/")
33 }.joined(separator: " ")
34 }
35 }
36
37 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
38 func cellsMatchOxHtml() throws {
39 for table in Self.tables {
40 let html = try EmacsOracle.evaluate(table, "(progn (require 'ox-html) (list (org-export-as 'html nil nil t)))")[0]
41 let ours = try #require(TableEl.cells(table))
42 #expect(Self.describe(ours) == Self.describe(html: html), "\(table)\n\(html)")
43 }
44 }
45
46 @Test func exportsATable() {
47 let page = HTMLExport.page(Self.tables[0], fallbackTitle: "t")
48 #expect(page.contains("<td colspan=\"2\">c d<br>more</td>"))
49 #expect(!page.contains("<pre"))
50 }
51}