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 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
287 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
| 288 | case .tableEl: |
288 | case .tableEl: |
| 289 | let lines = node.text.components(separatedBy: "\n").filter { !$0.lowercased().contains("#+tblfm") } |
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 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
304 | return "<pre class=\"example\">" + Self.escape(lines.joined(separator: "\n").trimmingCharacters(in: .newlines)) + "</pre>\n" |
| 291 | case .dynamicBlock: |
305 | case .dynamicBlock: |
| 292 | let lines = node.text.components(separatedBy: "\n") |
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 | } |