Getting Figure Tags Using Ox-Slimhtml

Posted on June 2, 2021

I'm using Ox-Slimhtml to generate this website now, and it works great once it's configured as long as you don't need anything it doesn't provide. In my experience, Ox-Slimhtml works pretty well for one-off exports, but I wasn't able to get it to export projects on its own using the example in the documentation. The backend is a bit bare-bones, so there's a good chance you will find yourself deriving your own backend anyway. Ox has an API for this, in addition to creating new backends from scratch. I'm not bold enough to create my own, though, and if I were, this post would probably have a different name. So deriving a backend it is!

Worg has some documentation on deriving your own backend, but if you're like me and need a little more, I also suggest perusing this publish file or my own config for a concrete example.

I'm a big fan of semantic HTML and using those semantic tags as much as possible. For the most part, this has been easy to do with Ox-Slimhtml, as long as I was only modifying transcoders that already exist. I wanted to be able to put inline images in my posts, and in the spirit of semantic HTML I wanted to be able to make use of the figure and figcaption tags. Ox-Slimhtml's link transcoder doesn't support images, though, much less figures. To remedy this, you could probably add advice around the transcoder, but I still don't really understand how that works, so I wrote a wrapper around the existing implementation and call the wrapper instead.

Writing the Transcoder

I went through a couple iterations to come up with something I liked. At first, I dove in without reading the documentation and started hacking away at the link transcoder. I got pretty far with this approach: not only had I added image support, but I also wrapped it in figure tags and added hacky support for captions so an org link looking like this:

This is an image caption!

would export to this:

  <img src="http://website-root/path/to/image.png />
    <figcaption>This is an image caption!</figcaption>

This is where not reading the documentation comes in: It turns out, Org already has a convention for captions that I was flagrantly breaking. Though I prefer the look of my implementation, the Org version of my first code block to get the same HTML would look like this:

#+caption: This is an image caption!

I waffled on this for a while, but since I'm ultimately a convention nerd I capitulated to convention and landed on this as a link transcoder:

(defun ab/org-html-link (link contents info)
  "Transcode a LINK from Org to HTML.
CONTENTS is the contents of the link.
INFO is a plist used as a communication channel."
  (let ((path (org-element-property :path link))
        (base-url (plist-get info :base-url)))
    (when (string= 'file (org-element-property :type link))
      (org-element-put-property link :path (concat base-url
                                                   (file-relative-name (expand-file-name path
                                                                       (concat ab--root ab--org-dir)))))
    (let ((ext (file-name-extension (org-element-property :path link)))
          (caption (car (car (car (org-element-property :caption (org-element-property :parent link))))))
          (file-link (org-element-property :path link)))
      (if (and ext
               (member (downcase ext) '("png" "svg"))
               (string= contents nil))
          (format "<figure>\n%s</figure>" (concat (format "<img src=\"%s\" />\n" file-link)
                                                  (when caption
                                                    (format "<figcaption>%s</figcaption>\n" caption))))
        (org-export-with-backend 'slimhtml link contents info)))))

The when form is for making intra-site links point to the right places. Not sure why, but the default :path property was always missing the posts directory when I was exporting. Hopefully, ab--site-base-url is self-explanatory, ab--absolute-pub-dir is the absolute path to the publish directory (the value of the :publishing-directory property in the org-publish-project-alist variable).

The code related to this post is the second let form. I'm defining an ext variable containing the file extension I want to link to, and if that variable isn't nil, I downcase it, check if the extension is in a predefined list of extension strings, and check that contents is nil. If all of these checks pass, I have myself an image and I want to export it as a figure. If the link turns out to not be an image, I just pass it to Ox-Slimhtml's built-in link transcoder.

Adding the caption isn't too hard from this point. Luckily, I looked at the Org link transcoder before writing mine, and found that the whole thing is a bit of a hack. When you add #+CAPTION: above your link, you're really adding it to the paragraph encapsulating the link. In order to get that property in the transcoder, you have to get the link's parent (i.e. the paragraph) and pull the caption from there. You don't get a string back, though! You get text properties. After some car nesting I was able to finally get the string. and plug it into figcaption tags.

The only thing left to do is add a line to my derived backend, telling it to use the new transcoder:

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

Now when an Org link pointing to an image gets exported, the HTML looks good, but I don't like how the figure is still wrapped in p tags. There may be another way to fix this, but I decided to do it with a filter.

Adding a Filter to Remove Unnecessary HTML

When a project gets published, it hooks into filters once it's done processing the document's components. Just about every component has a list of filters you can add to if you want additional processing logic. There are three places that stand out to me as viable options for this filter: final-output, body, and paragraph. Although body would have the filter run fewer times, this is ultimately a paragraph issue, so that's where I put it. Here's the code:

(defun ab/html-paragraph-figure-filter (output backend info)
  "Remove p-tags wrapping figure-tags.
OUTPUT is a string containing transcoded HTML.
BACKEND is the export backend.
INFO is a plist used as a communication channel."
  (let ((fig-regexp "<p>[\n\t ]*?\\(<figure>\n*?<img src.*? />\n*?.*?\n*?</figure>\\)[\n\t ]*?</p>"))
    (replace-regexp-in-string fig-regexp
                              (lambda (matched) (string-match fig-regexp matched)
                                (match-string 1 matched)) output)))

For those who have a hard time with regular expressions, I set this filter up to look for a p tag followed by an indeterminate amount of newlines, tabs and spaces and a figure tag. The expression then makes a group consisting of the opening and closing figure tags, provided that the only thing in between them is an img tag, new lines, and an optional line of any characters (the figcaption tags match on this line). The expression then looks for another indeterminate number of newlines, tabs, or spaces followed by a closing p tag. If a match is hit, the filter replaces the matched text (in this case, the entire paragraph including tags) with the capture group (the figure tags and everything between them).

From here, the last thing to do is add the filter to the pipeline:

(setq org-export-filter-paragraph-functions '(ab/html-paragraph-figure-filter))

Now when the project gets published, figure tags are no longer wrapped with p tags!

The code wasn't too bad to write after asking a couple general Elisp questions on Reddit. I did struggle with finally landing on how I wanted the implementation to work though. My vision was to have my Org files look the way I want them to, and to have the HTML exporter accommodate this. For me, this means adding the image, putting the caption directly below, and letting the exporter take care of the rest. Far be it from me to go against convention, though. Especially when figures aren't the only thing with captions. How should tables work? One of the main reasons I like Org so much is because it has an established convention that's pretty rigorously followed. In the end, I guess one cosmetic grievance is a small price to pay for an awesome markup schema.

Aside: if you're interested in the answer to the tables question, I'm writing a post on my table transcoder next.