A web application that turns MetaPost and Graphviz source written inside an HTML page into SVG images, on the way out.
This is a Go port of mpresty, which did the
same thing with OpenResty and Lua. It keeps the page syntax, the routes and the
images/ cache layout of the original, so existing pages work unchanged.
go build -o mpresty ./cmd/mpresty
./mpresty -root webapp/htmlOr with Docker, which brings the engines with it:
docker compose up -dThen visit http://localhost:8080/all.html, /tutorial.html, /sunflower.html,
/benchmark.html or /preview.html.
Put the graphics source in a custom element, or point an ordinary <img> at a
source file and let the extension choose the engine:
<html>
<body>
<h1>Metapost</h1>
<metapost width="200">
beginfig(1)
u:=1.3cm; transform T; z1=(0,2u); n:=5;
for i=1 upto n-1: z[i+1]=z1 rotated (360*i/n); endfor;
path p; p = for i=1 upto n: z[i]--endfor cycle;
for i=0 upto 100:
fill p withcolor 0.2*white; p:=p transformed T;
endfor;
endfig;
</metapost>
<img src="tree.mp" width="200">
<img src="/source/dot.gv" cmd="neato" width="200">
<img src="https://example.org/source/rgb.mp" width="200">
</body>
</html>| element | file extension | engine | cmd may select |
|---|---|---|---|
<metapost> |
.mp |
mpost |
mpost, mpost-lua |
<graphviz> |
.gv |
dot |
dot, neato, fdp, sfdp, twopi, circo, osage, patchwork |
Attributes the renderer consumes and strips: src, cmd, and cache="no" to
force a rebuild of that one image. Appending ?debug to the page URL rebuilds
every image on it.
A source that fails to render does not take the page down with it — the engine log appears in red where the image would have been.
The routes mpresty.conf declared, and what replaced each one:
| route | Lua original | here |
|---|---|---|
/*.html |
content_by_lua_block |
default rewrite to <img> |
/benchmark.html |
timing wrapper | same, appends elapsed seconds |
/exemples.html |
lua/exemples.lua |
server.ExemplesUpdateNode |
/updatenode.html |
lua/upnode.lua |
server.UpdateNodeByTag |
/preview.gxn |
lua/preview.lua |
(*Server).preview |
| everything else | nginx static |
http.FileServer |
-addr string address to listen on (default ":8080")
-root string document root (default "webapp/html")
-workdir string directory for rendered SVGs, relative to root (default "images")
-timeout duration wall clock budget for one engine run (default 30s)
-jobs int concurrent engine runs (0 = GOMAXPROCS)
-cache int cache entries to keep (0 = default)
-v log at debug level
MetaPost and Graphviz, and nothing else. btex ... etex labels are typeset by
the plain eTeX that comes with MetaPost, so no LaTeX installation is needed;
the sample figures that once opened verbatimtex %&latex now say %&plain.
Since Graphviz 14 the graphviz package carries only dot; neato, fdp,
sfdp, twopi and circo need the neato-layout plugin
(libgvplugin-neato-layout8 on Debian and Ubuntu), which nothing pulls in for
you. The Dockerfile installs it, on Ubuntu 26.04 LTS, which
brings TeX Live 2025 and Graphviz 14 for a 334MB image.
mpresty names any engine it cannot find on PATH when it starts.
Four of these are fixes, one is a loss.
Graphics source survives the HTML parser. The HTML5 tokenizer reads <
followed by a letter as a tag open, so a line like if a<b: lost everything
from the < onward under gumbo. Each graphics element's body is now lifted out
before parsing and put back afterwards, exactly as written — the same treatment
HTML gives <script>. One consequence: entities inside a graphics element are
no longer decoded, so write &, not &.
No shared per-request state. lib/mpresty/base.lua stored cmd, doc and
use_cache on the module singleton while several light threads walked the
document. That was safe only because ngx.thread coroutines are cooperative and
single-threaded. Goroutines are not, so a Renderer here is immutable
configuration and everything per-request travels as an argument.
Each engine run gets its own directory. The Lua version ran every engine in
images/ and cleaned up with rm F.log F.mp*. MetaPost's makempx writes
fixed-name temporaries, so genuinely parallel runs would have collided. Jobs now run in a scratch directory
and only the finished SVG is moved into images/. Sources that input a
sibling still resolve: the document root is on the kpathsea search path.
A cmd attribute cannot reach a shell. It used to be pasted into a shell
string; commands now run as argv, and each renderer has an allow-list. Local
src paths are confined to the document root, which ngx.location.capture used
to do for free.
Customisation is compiled, not scripted. This is what the port gives up.
Dropping exemples.lua next to the config was enough before; now an
UpdateNodeFunc is registered on a route in internal/server. Embedding a Lua
VM would restore it if that flexibility is worth the dependency.
Rendering the 32 images of benchmark.html from a cold cache, on a 10-core M-series Mac:
-jobs |
time |
|---|---|
| 1 | 8.45s |
| 4 | 2.52s |
| 0 (GOMAXPROCS) | 1.43s |
Warm, the same page is served in about a millisecond: a rendered file is its own cache, so it survives a restart, and identical concurrent requests collapse into one engine run.
cmd/mpresty/ flags, wiring, graceful shutdown
internal/render/ the engines, the job runner, the document walker
internal/htmldoc/ parsing with shielded raw bodies, DOM helpers, serialization
internal/cache/ LRU for remote sources
internal/server/ routes, static files, the per-route updaters
webapp/html/ the sample pages, carried over unchanged
Public Domain, as the original.