Ox-Slimhtml Table Transcoder

Posted on June 11, 2021

A previous post went over how to create figures using a wrapper around Ox-Slimhtml's link transcoder. Feeling buffeted by my successes there, I decided to tackle writing a table transcoder next. As a disclaimer, I'm not trying to write extensible and versatile transcoders. In my case, I think it would have actually been a bad idea to take that approach. It would have taken a lot longer, and I honestly don't see a use case where I'll need much more than what I describe here. This is neither here nor there, but I also think extensible transcoders for every situation go against the spirit of Ox-Slimhtml a bit. The framework itself is extensible, the transcoders don't really have to be.

That being said, my table transcoder is very simple: the classes for the tags are currently hard-coded, there aren't any flags for using table footers or headers; it just provides a table with a header and a body. Eventually, when a use-case comes up, I'll add in some way to add footer rows, but for now this is all I need. The last thing I want to mention before I get started is some context: I'm deriving a backend from Ox-Slimhtml, so the code snippets here reflect that. I provided some resources with more information in the post linked above for those who are interested. That's it for preamble, let's get started!

When exporting, backends break tables down into three components: the table, its rows, and its cells. So I started with the code for the table:

(defun ab/org-html-table (table contents info)
  "Transcodes a TABLE from Org to HTML.
CONTENTS is the contents of the table.
INFO is a plist used as a communication channel."
  (let ((caption (car (car (car (org-element-property :caption table))))))
    (concat "<table>\n"
            (when caption
              (format "<caption>%s</caption>\n" caption))
            contents
            "</tbody>\n</table>")))

It's hard to see at first, especially when using the complicated implementation provided by the default HTML backend as a reference, but the functions for transcoding tables are used by the backend in a sort of opaque way. The only way I could get it to make sense was to hack at each function until I got something that looked reasonable. In retrospect, just fiddling with the output of each function to understand how they worked together was a good approach.

Once I got a feel for how the functions played together, I was able to come up with the above snippet. I wanted to retain the default HTML backend's support for captions, so the function looks for a caption property on the table, and if it finds it, the contents of the property are inserted just below the opening table tag between caption tags per the HTML spec. The anomalous closing tbody tag is part of a hack I used to get thead and tbody tags into my tables. These tags aren't even really necessary from a markup perspective, but I wanted my tables to have them. Eventually, I'll probably try to work out a way to support tfooter tags, but until I run across a use case for it, what I have works just fine.

Moving on to the row transcoder, things get a little more complex:

(defun ab/org-html-table-row (table-row contents info)
  "Transcodes a TABLE-ROW from Org to HTML.
CONTENTS is the contents of the row.
INFO is a plist used as a communication channel."
  (if contents
      (concat (when (eq 1 (org-export-table-row-group table-row info))
                "<thead>\n")
              "<tr>\n"
              contents
              "</tr>"
              (when (eq 1 (org-export-table-row-group table-row info))
                "</thead>\n<tbody>\n"))))

At the end here, we see the opening tbody tag matching the closing tag in the previous function. This is also where I add the thead tags I mentioned. Again, if you didn't care about these, you could just omit them. The hard part about this function was figuring out what a table-row-group was. The org-export-table-row-group function used here comes from the default HTML backend. Org defines a row-group by the horizontal dividers comprised of hyphens between rows. I decided all of my tables will have dividers between the header rows and the rest of the table, so that's how this transcoder is set up. If it detects the row is in the first row group, it's a header row and we want to surround the row with thead tags and start open the tbody tag at the end of the row. All other row groups just get formatted with tr tags. If you don't use dividers and you want to use thead and tbody tags, you'll have to figure something else out.

The last function provides the formatting for the cells:

(defun ab/org-html-table-cell (table-cell contents info)
  "Transcodes a TABLE-CELL from Org to HTML.
CONTENTS is the contents of the cell.
INFO is a plist used as a communication channel."
  (if (eq 1 (org-export-table-row-group (org-element-property :parent table-cell) info))
      (concat "<th>" contents "</th>")
  (concat "<td>" contents "</td>\n")))

This function is all about wrapping the individual cells in the proper tags. Once I figured out the row function, this one was easy for me to figure out. Again, we're using table-row-groups to do some conditional formatting. This time, the conditional code is used to wrap the cells in the first row with th tags, and all of the other cells with td tags. That's about all there is to this transcoder. Oddly enough, I remember these functions being more complicated.

With the transcoder functions out of the way, all that's left is to add them to the backend:

(org-export-define-derived-backend 'site-html
    'slimhtml
  :translate-alist
  '((table . ab/org-html-table)
    (table-cell . ab/org-html-table-cell)
    (table-row . ab/org-html-table-row)))

And there you have it, a working table transcoder. A dumb one, granted, but this simple code is pretty approachable, which I think is in keeping with the philosophy of Ox-Slimhtml.