Labelled figures for Pandoc exporting, with native Obsidian syntax

I’ve been working on a report written completely in Obsidian. I use Pandoc to export my report to a PDF using Typst as the pdf engine, and a template.

I wanted to be able to export figures. One option is to use raw attributes, such as is done here. However, this doesn’t really allow me to take advantage of any of Obsidian’s features - I’m just writing Typst code in markdown at that point. There are some libraries such as pandoc-crossref designed for doing this for latex, but they, again, aren’t Obsidian-native syntax.

So the goal is: Use native Obsidian syntax. And have my output on Pandoc conversion (to Typst, but ideally to other formats, too) come out something like this:

See @figureLabel below.

#figure([A figure of some kind, maybe a code block, or a table, or an image.
  ],
  caption: [
    A caption for the figure.
  ]
)
<figureLabel>

Thankfully we have Lua filters :)! So I made one.

So, here is what the Obsidian syntax looks like for the above, which is transformed by a Lua filter into the above example.

See [[#^figureLabel]] below.

> [!figure] A caption for the figure. ^figureLabel
> A figure of some kind, maybe a code block, or a table, or an image.

Because we use a block link, in Obsidian we can click on [[#^figureLabel]] and get sent to the figure callout. This also gives us some syntax to look for in the filter.

The filter:

obsidian.lua
-- Made by https://forum.obsidian.md/u/kacii

local stringify = (require("pandoc.utils")).stringify

-- requires https://github.com/pandoc-ext/logging
-- handy for debugging
-- local logging = require("logging")

local metadata = {}

function Meta(meta)
	for key, value in pairs(meta) do
		metadata[key] = value
	end
end

-- We want to check if the block contains just one element
-- and it is an element of the type.
---@param content Blocks[]
---@param type string
---@return boolean
local function only_has_type(content, type)
	if #content ~= 1 then
		return false
	end
	if content[1] and content[1]["tag"] == type then
		return true
	end
	return false
end

-- modified from https://forum.obsidian.md/t/rendering-callouts-similarly-in-pandoc/40020/6
--
-- we are looking for a callout that looks like this:
-- > [!figure] the caption for your figure ^figureLabel
-- > insert whatever for the figure here
--
-- the figure label derives from: the block ID (i.e. ^figureLabel)
-- if the block ID is not present, it will also look for a metadata
-- value, so for the following:
--
-- > [!figure|figureLabel] caption for figure
-- > insert whatever for the figure here
--
-- its label/ID will become figureLabel.
-- If both are present, then the block ID takes priority.
--
-- p.s. untested on what happens with multiple metadata values such as [!figure|a b c] so no promise on what happens then. to do later maybe.
function BlockQuote(el)
	local start = el.content[1]
	if start.t == "Para" and start.content[1].t == "Str" and start.content[1].text:match("^%[![%w%|]+%][-+]?$") then
		local _, _, ctype = start.content[1].text:find("%[!([%w%|]+)%]")
		local s, _, figname = ctype:find("|([%w]+)")

		if figname then
			ctype = ctype:sub(0, s - 1)
		end

		local title = start.content
		local c = 0
		local found = -1
		local lastloc = nil
		for k, i in pairs(title) do
			if stringify(i):match("%^%w*") then
				lastloc = i
				found = k
			end
			c = k
		end

		if lastloc and found > -1 and found == c then
			figname = stringify(title:remove()):sub(2)
		end
		el.content:remove(1)
		start.content:remove(1)

		local blocktitle = pandoc.Blocks({ title })

		--- later: maybe customize what callout types we should transform.
		local div = el
		if ctype:lower() == "figure" then
			div = pandoc.Figure(el.content, blocktitle, pandoc.Attr(figname or ctype))

			-- while Typst will accept the above as a
			-- figure containing a table with a caption,
			-- latex is much fussier.
			if only_has_type(el.content, "Table") then
				---@type Table
				div = el.content[1]
				div["caption"] = blocktitle
				div["attr"].identifier = figname or ctype
			end
		else
			-- fallback.
			div = pandoc.Div(el.content, { classes = { "callout" } })
			div.attributes["data-callout"] = ctype:lower()
			div.attributes["title"] = stringify(start.content):gsub("^ ", "")
			div.attributes["metadata"] = figname:lower()
		end
		return div
	else
		return el
	end
end

-- Make sure you are using the wikilink markdown extension.
-- So you need to run something like `pandoc -f markdown+wikilinks_title_after_pipe
-- We will transform something like "[[#^blockID]]
-- into something that typst or latex will read as a reference/link
-- to the figure we have created above.
function Link(el)
	-- during latex output format, let's check for
	-- a meta-variable, `cref`, which pandoc-crossref
	-- uses to say to use cleveref format (\cref{}).
	-- Unlike pandoc-crossref we will not automatically
	-- add the usepackage for this...
	--
	-- let's also check a `pandoc_custom-ref` variable
	-- in case you have something else you want to use.
	local refstring = "\\ref{"
	if metadata["cref"] or metadata["pandoc_cref"] then
		refstring = "\\cref{"
	elseif metadata["pandoc_custom-ref"] then
		refstring = metadata["pandoc_custom-ref"]
	end

	local _, _, tx = stringify(el.content):find(".*%#%^(%w+)")

	if tx then
		if FORMAT == "typst" then
			-- typst seems to just treat references to a label
			-- the same as bibliographic reference cites.
			local c = pandoc.Cite("@" .. tx, { pandoc.Citation(tx, "NormalCitation") })
			return c
		elseif FORMAT == "latex" then
			return pandoc.RawInline("latex", refstring .. tx .. "}")
		else
			-- haven't tested other formats thoroughly. I think this is what the pandoc native referencing does.
			el.target = "#" .. tx
		end
	end

	return el
end

-- if we want to access metadata variables from the other
-- filters, we need to do it like this so Meta runs first.
return {
	{ Meta = Meta },
	{ Link = Link },
	{ BlockQuote = BlockQuote },
}

You can put this file in the filters folder of your data-dir. For example, on Linux and Mac, you can save the file as ~/.local/share/pandoc/filters/obsidian.lua.

As noted in the code, we should run pandoc with the “wikilinks_title_after_pipe” extension, for example, by doing pandoc -f markdown+wikilinks_title_after_pipe. See manual.

I have tested this with Typst and LaTeX outputs (as you can see in the code there are some adjustments made for latex output to ensure they come out correctly), and pdf-engine=typst. Feel free to adjust it yourself however. I may update this post over time too if I change the filter. This is working quite nicely for me so far.

So, as a working example, I have files in Obsidian which might look like this.

This is a big long paragraph blah blah. See [@citationHere]. See [[#^figureLabel]].

> [!figure] A caption for the figure. ^figureLabel
>
> A figure of some kind, maybe a code block, or a table, or an image.

Then I might have a bibliography file such as:

@online{citationHere,
  abstract = {Abstract for the webpage.},
  author = {Lastname, Firstname},
  title = {A webpage article title},
  urldate = {2025-12-02},
}

Now I can run my output command:

pandoc --lua-filter pagebreak.lua \
--lua-filter obsidian.lua \ 
--toc \
--number-sections \
--template default \
--bibliography bibliography.bib \
-f markdown+wikilinks_title_after_pipe \
--standalone \
-o ~/Desktop/mytypstfile.typ \
-t typst \
~/Documents/obsidian/my_markdown_file.md

(Bonus: pagebreak filter from here. Handy for easy pagebreaking.)

I don’t use --citeproc, because I’m going to rely on Typst to do the bibliography compilation for me. (As such, if you are compiling to Typst instead of directly to PDF, make sure that wherever you output your .typ file, it can see your bibliography file.)

In my Typst file, I get an output from the above that looks like this.


This is a big long paragraph blah blah. See @citationHere. See
@figureLabel.

#figure([A figure of some kind, maybe a code block, or a table, or an
  image.

  ],
  caption: [
    A caption for the figure.
  ]
)
<figureLabel>

#bibliography("bibliography.bib")

Great!

If I want to compile directly to PDF, my command will instead look like:

pandoc --lua-filter pagebreak.lua \
--lua-filter obsidian.lua \ 
--toc \
--number-sections \
--template default \
--bibliography bibliography.bib \
-f markdown+wikilinks_title_after_pipe \
--standalone \
-t pdf \
--pdf-engine typst \
-o ~/Desktop/mytypstfile.pdf \
~/Documents/obsidian/my_markdown_file.md

Here is what my PDF looks like!

This should generally work on a LaTeX file too, but this is not my primary usecase at the moment. I tried to make sure I could do that if I needed too, though.

So, there you go. I will attach the lua file below in a .zip. Hope this can be useful to some!

obsidian.lua.zip (2.1 KB)

1 Like