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 { |
| 287 | 287 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
| 288 | 288 | case .tableEl: |
| 289 | 289 | 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 | } |
| 290 | 304 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
| 291 | 305 | case .dynamicBlock: |
| 292 | 306 | let lines = node.text.components(separatedBy: "\n") |
Sources/OrgCore/Export/TableEl.swift
added
+53
| @@ -0,0 +1,53 @@ |
| 1 | import 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. |
| 5 | enum 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 @@ |
| 1 | import Foundation |
| 2 | import Testing |
| 3 | @testable import OrgCore |
| 4 | |
| 5 | /// table.el tables against ox-html: the rows, each cell's spans and text. |
| 6 | struct 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: " ", with: " ").replacingOccurrences(of: "&", with: "&") |
| 29 | .replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", 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 | } |