Table of Contents

texrender Plugin Plan

texrender is a DokuWiki syntax plugin that renders LaTeX pasted directly into wiki source through a real LaTeX engine and embeds the result as inline SVG. It has two syntaxes: \documentclass{standalone} ... \end{document} for a figure that gets a line of its own, and \tikz ... ; for one small enough to sit in a sentence. This page is the design, the results of verifying the toolchain, and two problems found while verifying it — one of them a security hole that changes the shape of the plugin.

Verdict up front

Feasible, everything needed is installed, and the pipeline works — I ran it end to end by hand while writing this. The id clash you flagged is real and confirmed. But testing turned up a second, worse problem: dvisvgm will happily emit attacker-supplied raw markup, including <script>, into the SVG. Any plugin that embeds that output inline is an XSS vector. The fix folds neatly into the same pass that fixes the id clash, so it costs little — but it moves “sanitize the SVG” from a nice-to-have to the load-bearing part of the design.

As built

This is now implemented and running — see TeX Figure Examples for 35 block figures and 34 inline ones. The plan below is kept as the design record; this section is what changed on contact with the code.

Layout. Backend.php (interface), LocalBackend.php, Sanitizer.php, helper.php, syntax/figure.php, syntax/inline.php, plus conf/, lang/en/ and style.css. The classes use DokuWiki's plugin namespace autoloading (\dokuwiki\plugin\texrender\X resolves to lib/plugins/texrender/X.php, inc/load.php:autoloadPluginClass), so nothing needs a manual require.

Setting names differ from the table below. timeout and nice would have meant both a binary path and a duration, so the binaries are timeoutbin and nicebin and the durations are maxtime and maxmemory. serviceurl and servicetimeout are not shipped: settings that do nothing have no business in a config UI, and they can arrive with the backend that uses them.

The step-1 “embed unsanitized behind a flag” scaffolding did not graduate into a setting. Shipping a checkbox that turns off XSS protection is a foot-gun, and a public plugin would eventually be found with it flipped. What the plugin has instead is debug, which lists what the sanitizer removed underneath each figure. That gives the diagnostic the flag was wanted for — “why did my figure lose its gradient” — without the hole.

Block figures are flush left, indented 2em, rather than centred. A figure in the middle of an argument reads as part of the argument when it lines up with an indented block and as an interruption when it is centred, and most of these figures are small enough that centring strands them in white space. align still takes center and right; only the flush-left case carries the indent, because on a centred figure a left padding just moves the centre. Error blocks take the same alignment class, so a failure sits where its figure would have.

-halt-on-error is passed to LaTeX. Without it, nonstopmode carries on after a real error, exits non-zero anyway, and still writes a DVI, which forces a choice between showing a possibly-broken figure and showing an error. With it the outcome is unambiguous and the log is about five lines instead of 150.

Measured. A cold figure costs roughly 250–550 ms, the heaviest (a 3D pgfplots surface, 140 KB of SVG) 2.4 s; an inline \tikz snippet is at the bottom of that range, around 300 ms, and the four TikZ libraries in its preamble account for about 20 ms of it. The examples page, 63 figures after the inline section was added, takes 38 s to build cold and 65 ms warm.

The figure limit became two limits, and both count renders rather than figures. It started as a single cap on how many figures a page may contain, which was defensible while a figure meant a display-sized diagram and stopped being so the moment a page could carry thirty inline marks. Two changes followed from that.

First, a cache hit costs nothing, so counting it toward a work limit caps a page forever over work that is never done twice; the counters now increment only on a cache miss. The other half of that is $renderer->nocache() when a limit does trip — without it the incomplete page, error blocks and all, is written into the XHTML cache and stays there until the page is edited. With it, each view renders the next batch and the page is only cached once it is whole. Verified: with the limit forced to 3, a ten-figure page filled in over three views (4 figures, then 7, then 9 plus the deliberate error) and was cached only on the third.

Second, maxfigures and maxinlinefigures are separate budgets, because block and inline figures are not the same kind of thing. A block figure is deliberate, there are a handful per page, and it is the expensive end of the range; an inline snippet is closer to punctuation and a page can reasonably carry dozens. Under one shared cap a paragraph full of small marks starves the diagrams below it, which is a strange way for a page to break. Verified with the caps forced to 3 and 2 on a page holding five of each: three block figures and two inline ones rendered, and neither budget touched the other's.

The sanitizer removes nothing from real figures. Run against all 37 rendered test figures — TikZ, CircuiTikZ, chemfig, forest, pgfplots, tikz-cd, quantikz and skak — the allowlist stripped not one element or attribute, while still blocking the \special{dvisvgm:raw <script>} case end to end. Output grows about 2% from the id nonces. That is the result the allowlist was aiming for, and the debug setting is how to check it stays true.

Two things do not work at all: TikZ shadings and anything drawn with PSTricks. See the section below; the second is the same wall as the first.

A trap worth knowing about, found while warming the cache from the CLI: data/cache/*/ subdirectories are mode 0770 without setgid, because DokuWiki chmods them to $conf['dmode'] on creation, which clears the inherited bit. Cache files therefore take the creating user's primary group, so anything bin/render.php writes as iv is unreadable by www-data and vice versa — DokuWiki logs a pile of Permission denied warnings and silently re-renders everything. This is pre-existing and affects every cache type, not just this plugin (.code files from GeSHi hit it too). Setting $conf['dmode'] = 02770 would fix it at the source. Do not warm this plugin's cache from the CLI until that is decided.

Inline `\tikz`, added after the fact

syntax/inline.php claims TikZ's own shorthand for a small picture — \tikz ... ; and \tikz{ ... } — and renders it in the run of text rather than on a line of its own. It shares the helper, the cache, the sanitizer and the backend with the figure component; everything below is what it does not share.

Finding the end of the command

Neither terminator can be found by scanning for the character. A node label may contain a semicolon (\tikz \node {a; b};) and a braced snippet may contain nested groups, so braces have to be matched. PCRE cannot count, so the balanced group is spelled out to a fixed depth of four, which covers \tikz[baseline={[yshift=-2pt]...}] and anything else written inline.

Two things about that pattern are load-bearing and neither is obvious:

Bounded repetition is not usable here. The natural way to stop an unterminated \tikz from swallowing the page is {0,2000}, and it does not work: PCRE compiles a counted repetition by replicating the subpattern that many times, and the balanced group is far too big for that. The first version failed to compile — and because DokuWiki merges every syntax mode into one ParallelRegex, the failure was not this plugin's, it was preg_match(): regular expression is too large from inc/Parsing/Lexer/ParallelRegex.php:103 with the whole page's parsing taken down with it. What bounds it instead is a lookahead that lets a path run over several lines but not past a blank one.

Every repetition is possessive. The alternation branches begin on disjoint characters and none can match the delimiter that follows, so backtracking into them can never find a match an ordinary quantifier would miss. It can, however, exhaust the JIT stack: with plain *, a snippet of about 14 KB made preg_match return false with JIT stack limit exhausted, which — again — is a failure of the combined pattern, not just this mode. *+ makes it impossible and is also five times faster.

Aligning to the baseline: tried, measured, removed

An inline figure sits on the text baseline, full stop — the wrapper is an inline-block with no in-flow line box, so its baseline is its own bottom edge, exactly like an image. The baseline option a snippet may carry has no visible effect, because dvisvgm crops to the ink and two snippets differing only in baseline produce SVGs of identical size.

It was not built that way. The first version reproduced TikZ's baseline exactly, and reproducing it needed no measurement: dvisvgm leaves the DVI reference point at y=0 and crops to the ink, so minY + height of the viewBox is the depth below the baseline, in the same unit as the SVG's own width and height — the PostScript big point, which is what CSS calls pt. Read it out, write vertical-align: -that, and it survives --scale for free. Checked against \the\dp of the TeX box for five cases, agreeing to a rounding step.

It was still wrong, and looking at a page of it is what showed why. “Exact” is exact against a 10pt Computer Modern document; the offset then lands in 10pt sans-serif body text with the figure already scaled 1.2×. For a small mark the offset is a point or two and invisible. For anything with real depth it is not:

vertical-align figure height
−0.5pt 6.2pt
−5.5pt 16.2pt
−11.8pt 28.8pt
−30.8pt 44.1pt

That last one is a boxed node dragged three text-lines below the baseline, forcing the line box open around it. Faithful to LaTeX, wrong for the page. What it bought at the other end of the scale — a [baseline=-0.5ex] bullet centred on the x-height rather than resting on the baseline — was a difference of about 1pt.

\documentclass[preview]{standalone} is kept in the wrapper even so. It is the mode meant for material set inline and it does not reshape the page around the snippet; plain standalone renders the same to within a rounding step, checked on five snippets, so there is nothing to gain by changing it. It also leaves the reference point on the snippet's own baseline, so the depth is still recoverable from the SVG if this is ever wanted back.

The wrapper is otherwise \usepackage{tikz} plus the inlinepreamble setting. A snippet cannot load packages of its own — that is what the \documentclass form is for.

Where it is allowed to appear

getType() is 'substition', not the figure component's 'protected'. Only substition is connected inside DokuWiki's formatting modes (inc/parser/parser.php:18), and emphasis is the one place a small figure most obviously belongs — a figure that stopped working inside **bold** would not be much of an inline figure. Both groups are excluded from code, file and nowiki, so a snippet quoted as source is still inert.

The cost is that ''...'' does not escape it, since DokuWiki treats that as formatting rather than as code. %%...%% does, and so do fenced blocks.

Failing inside a sentence

The block error path emits <div><p><pre>. Inside a paragraph that is not a smaller version of the same problem, it is a different one: the browser closes the enclosing <p> at the <div> and the rest of the paragraph goes with it. The inline path is therefore phrasing content throughout — a single <code> carrying the one line of the log that says what went wrong, with the full filtered log on title for hovering.

Shadings: the one thing that does not render

\shade, \shadedraw and the shadings library produce an empty page — not an error, nothing at all.

PGF implements gradients as PostScript, written into the DVI as specials for an interpreter to execute later. dvisvgm --list-specials does list ps, and /usr/bin/gs and libgs.so.10 are both present, but this build has no --libgs option and does not execute them, so the drawing operations are dropped and the page comes out 0pt × 0pt. Compiling with pdflatex and converting with dvisvgm --pdf gets the page geometry right and still draws nothing, so it is not simply a DVI-route problem.

Because an empty SVG embeds happily and displays as an invisible nothing — the worst possible failure mode, since there is no error to search for — LocalBackend::isBlank() checks for it explicitly and reports it with the likely cause. Opacity and layered fills are real SVG features and work correctly, so they are the workaround.

PSTricks, and packages that choose their backend from `\ifpdf`

The same wall catches more than shadings. PSTricks draws in PostScript, so any package routing through it comes out empty at best — and often does not even load, since PSTricks is not installed here.

What makes this worth writing down is how a package ends up there. chessboard does:

\ifpdf\else\RequirePackage{pst-node}\fi

It draws the board with PGF either way; PSTricks is only for the arrows and square marks. But this pipeline runs latex --output-format=dvi, so \pdfoutput is 0, \ifpdf is false, and the package hard-fails at load time on a dependency it barely uses. xskak inherits it by loading chessboard. skak itself is unaffected and does boards, FEN and figurine notation fine.

Nothing to fix — DVI is what dvisvgm reads, and a --pdf route would trade this for a different set of problems. It is worth knowing as a diagnosis, because “package X wants pst-node” reads like a missing-package problem and is not one.

Threat model

This wiki has no untrusted users: registration is disabled, and everyone who can edit a page is already trusted with the server. Nothing below is a live risk here.

The reason to take it seriously anyway is that this plugin is intended to be publishable, and a published LaTeX-rendering plugin will be installed on wikis that do have untrusted editors. “Runs arbitrary LaTeX on your server” is the kind of feature people reasonably expect to have been thought about. So the security work is scoped as build it right the first time, not harden it later — the sanitizer and the environment scrubbing are cheap now and awkward to retrofit, and the parts that are genuinely expensive (real container isolation) are deferred behind an interface rather than either built prematurely or designed out.

Concretely, that means: v1 runs on the host's own binaries, with the cheap protections in place, and is honest in its documentation that host execution assumes trusted editors. The strong isolation story is a second backend, sketched below, that can land later without touching the rest of the plugin.

Toolchain: verified present

Tool Path Version
latex / pdflatex /usr/bin/latex pdfTeX 3.141592653-2.6-1.40.26 (TeX Live 2025/dev/Debian)
dvisvgm /usr/bin/dvisvgm 3.4.4
texfot /usr/bin/texfot rev 1.53
timeout, nice, prlimit /usr/bin/ present
standalone.cls, tikz.sty, amsmath.sty texmf-dist present

dvipng is not installed, and inkscape + convert are — irrelevant here, since the plan goes DVI → SVG directly and never produces a raster.

PHP has proc_open available with an empty disable_functions. Apache runs as www-data; there is no passwordless sudo, so bin/texrender's --runas option cannot be used from PHP. LaTeX will run as www-data, the same user as the web server. That constrains the sandboxing options below.

I confirmed the pipeline by hand:

latex -interaction=nonstopmode --output-format=dvi fig.tex
dvisvgm --no-fonts --exact --scale=1.2 fig.dvi -o fig.svg

A TikZ arrow with an inline $Ax$ label produced a clean 3.5 KB SVG in about 0.1 s of dvisvgm time.

Syntax component

Mirror the existing markdowku/syntax/latexfigure.php, which already claims this pattern, but widen it and actually render:

$this->Lexer->addSpecialPattern(
    '\\\\documentclass(?:\[[^\]]*\])?\{standalone\}.+?\\\\end\{document\}',
    $mode,
    'plugin_texrender_figure'
);

Two notes on the existing pattern. It hardcodes \documentclass{standalone} with no optional argument, so the very common \documentclass[border=2pt]{standalone} and \documentclass[tikz]{standalone} forms do not match today — worth fixing on the way past. And the lazy .+? does span newlines: DokuWiki's ParallelRegex::getPerlMatchingFlags() returns msSi, so s is on (inc/Parsing/Lexer/ParallelRegex.php:197).

getType() stays 'protected' and getPType() stays 'block', so the pattern is inert inside code blocks. getSort() can keep 88; nothing competes for this pattern.

This plugin supersedes markdowku's latexfigure.php, which currently just dumps the source into a latex-highlighted code block. Both must not be enabled at once — whichever sorts lower wins and the other silently does nothing. Deleting latexfigure.php from markdowku is part of shipping this, and it lines up with the plan in DokuWiki Consolidation Feasibility to return markdowku to pristine upstream.

Pipeline: PHP, not the shell script

Recommend mirroring bin/texrender in PHP rather than shelling out to it, for the same reason the man plugin does its own proc_open: bin/texrender builds its commands as shell strings and runs them through sh -c, which means every input path is a quoting question. A PHP helper using proc_open with an argv array has no shell in the loop at all.

Sketch of helper.php:

  1. Make a private temp dir. data/tmp/ is drwxrws--- iv:www-data, so data/tmp/texrender/<nonce>/ works and inherits the group via setgid. Register a shutdown/finally cleanup.
  2. Write the matched source verbatim to fig.tex. No preamble injection, no wrapping — the whole point of the syntax is that pasted standalone LaTeX is already a complete document.
  3. Run latex with argv ['latex', '-interaction=nonstopmode', '-no-shell-escape', '--output-format=dvi', 'fig.tex'], cwd set to the temp dir, and a scrubbed environment (see below).
  4. Run dvisvgm with argv ['dvisvgm', '--no-fonts', '--exact', '--scale=<conf>', 'fig.dvi', '-o', 'fig.svg'].
  5. Post-process the SVG (sanitize + re-id, one DOM pass).
  6. On any failure, produce the texfot-shortened error instead.

Environment scrubbing

Pass an explicit env array, not the inherited one:

openin_any=p          # LaTeX may not read outside the working tree
openout_any=p         # ...nor write outside it
shell_escape=p        # no \write18
HOME=<tmpdir>
TEXMFVAR=<tmpdir>/texmf
PATH=/usr/bin:/bin

openin_any=p is not decoration. I tested it — with default settings, \input{/etc/hostname} inside a standalone document reads the file and typesets its contents into the output:

--- default ---
(/etc/hostname)
--- openin_any=p ---
! LaTeX Error: File `/etc/hostname.tex' not found.

So without openin_any=p, anyone who can edit a page can exfiltrate any file readable by www-dataconf/local.php, conf/acl.auth.php, the user database — by rendering it into a picture. This single variable is the most important line in the plugin.

Note bin/texrender has a typo here: it sets shell_escpe=p, not shell_escape=p (line 89). The protection it looks like it provides is not being applied. In practice -disable-write18 on the same line does cover it, and TeX Live defaults to restricted shell escape anyway, but the variable as written does nothing.

Resource limits

timeout and nice are straightforward. For memory, PHP has no ulimit, so either wrap in sh -c 'ulimit -Sv N; exec latex ...' (reintroduces a shell, but with no user data in the command) or use prlimit --as=<bytes> -- latex ..., which is installed and keeps the argv-array property. Recommend prlimit.

Limits worth having, all configurable: wall-clock timeout (default 5 s, matching bin/texrender), address-space cap (~250 MB, matching), nice 5, a max input length (say 64 KB), and a cap on figures rendered per page to stop one page from pinning the CPU for a minute. TeX's own main_memory will stop most runaway macro expansion before the timeout does, but neither alone is sufficient — keep both.

bwrap and firejail are not installed; systemd-run is, but creating a scope as www-data with no user session isn't practical. So the isolation story is: unprivileged user, scrubbed env, no shell escape, no filesystem reads outside the temp dir, capped time and memory. That is a reasonable posture for a wiki where editing is already restricted, and it is not sufficient for a wiki with open registration. Registration is currently disabled ($conf['disableactions'] = 'register'), which is what makes this acceptable.

Problem 1: colliding `id` attributes (confirmed)

Real, exactly as you described. dvisvgm --no-fonts turns each glyph into a <path> in <defs> with an id derived from font index and character code, then references it with <use xlink:href='#...'>. Two unrelated figures on the same page collide whenever they share a glyph. Two different figures I rendered:

fig1 ids: id='g0-120' id='g0-65' id='page1'
fig2 ids: id='g0-121' id='g0-65' id='page1'

Both define g0-65 (the letter A) and both define page1. In one HTML document, every #g0-65 reference resolves to whichever came first, and the second figure silently renders the first figure's glyph — at the first figure's font and size. page1 collides on every single figure unconditionally.

dvisvgm has no --id-prefix option (I checked; it errors out), so this has to be fixed in post-processing.

The fix: a per-figure nonce suffixed onto every id and every reference to one. References appear in three forms — xlink:href='#x', href='#x', and url(#x) inside presentation attributes (clip-path, fill, mask, filter, marker-*) — so a naive id= rewrite alone would break the figure rather than fix it.

On nonce choice: prefer substr(md5($source . $pos), 0, 8) over a random string. It is unique per figure instance on a page (position differs even for two identical figures), but stable across re-renders, so the XHTML cache and any page diffing don't churn on every rebuild.

Problem 2: `dvisvgm` will emit raw `<script>` (found while testing)

This one wasn't on the list and matters more than the ids. dvisvgm implements a dvisvgm:raw special that injects arbitrary markup into the output verbatim. It is reachable straight from document source:

\documentclass{standalone}
\begin{document}
\special{dvisvgm:raw <script>alert(1)</script>}X
\end{document}

The resulting SVG contains, verbatim:

<script>alert(1)</script>

Embedded inline, that executes in the context of the wiki, with the reader's session. Every page view, for everyone.

The obvious mitigation — disabling the specials with dvisvgm -S dvisvgmdoes not work: PGF/TikZ's DVI-to-SVG driver is itself built on dvisvgm:raw, and with specials off the run produced no output file at all. Losing TikZ defeats the purpose of the plugin.

So the SVG must be sanitized before embedding, with an allowlist, not a blocklist:

Do this with DOMDocument, not regex, and do it in the same traversal as the id rewriting — one pass that walks every element, drops disallowed nodes, drops disallowed attributes, and rewrites ids and #/url(#) references. Regex over untrusted markup is how sanitizers get bypassed, and here the DOM walk is needed for the ids anyway.

Note that core's inlineSVG() (inc/common.php:1989) is not a sanitizer and must not be borrowed for this — it strips comments, the XML header and the doctype, and nothing else. <script> passes straight through it. It's fine for the template's own trusted icon files, which is all it was written for.

Execution backends: local now, containerised later

Everything above describes running LaTeX on the host. That is what v1 should do, and the binary paths should be configurable exactly like the man plugin does it$conf['pandoc'] = '/usr/bin/pandoc' with an is_executable() guard at the call site (lib/plugins/man/helper.php:538), which degrades to a fallback instead of exploding when the tool isn't there. Same shape here: $conf['latex'], $conf['dvisvgm'], $conf['texfot'], each checked before use, with a missing binary producing the error block rather than a PHP warning.

But the host route has a ceiling. No matter how well the environment is scrubbed, LaTeX still runs as www-data, in the web server's own process tree, with read access to everything www-data can read — which on a DokuWiki install is the entire wiki including conf/ and the user database. openin_any=p closes the door that matters, and it is a door in a wall we didn't build and don't fully control. TeX is a Turing-complete macro language with decades of accumulated file-touching surface; a confident claim that no path out exists is not one worth making.

The delegated service

The way out is to stop running LaTeX in the web server's trust domain at all: a small HTTP service on localhost:3333, backed by a container with TeX Live, dvisvgm and friends installed, that accepts LaTeX source and returns SVG.

Contract, roughly:

POST /render
Content-Type: application/json
{ "source": "\\documentclass{standalone}...", "scale": 1.2, "timeout": 5 }

200 { "svg": "<svg …>" }
422 { "error": "! Undefined control sequence …" }

The interesting properties are all on the container side, and they're things the host route simply cannot offer:

Where sanitization happens is a real design question. Tempting to say the service returns “sanitized SVGs” and be done. Better: sanitize on both sides, and treat the service's output as untrusted anyway. The plugin's DOM pass runs regardless of backend. Reasons: the id-rewriting pass has to run plugin-side no matter what (the nonce depends on the figure's position in the page, which the service doesn't know); a service that is only reachable on loopback is still a network dependency whose output the plugin shouldn't blindly trust; and keeping the sanitizer in one place means the two backends can't drift into different security properties. The service sanitizing too is defence in depth, not a reason for the plugin to skip it.

SSRF applies in the small. The service URL is admin-configured, not user-supplied, so this isn't the classic case — but the plugin still shouldn't follow redirects, should require an explicit host and port with no DNS surprises, should cap the response size before parsing it, and should have its own client-side timeout shorter than the service's. Loopback-by-default with a documented warning about pointing it anywhere else.

What this means for the design now

Nothing structural — but it does mean the pipeline should sit behind a narrow interface from the start:

interface texrender_backend {
    /** @return array{svg: ?string, log: ?string} */
    public function render(string $source, array $opts): array;
}

texrender_backend_local (procopen, v1) and texrender_backend_service (HTTP, later) both implement it; a $conf['backend'] setting picks one. Caching, sanitizing, id-rewriting and the error block all live above this line and are written once. That is a handful of lines of extra structure in v1 and it removes the “rewrite the plugin to add containers” problem entirely.

Rendering: inline SVG vs `<img>`

Worth stating the trade-off explicitly, because one option makes both problems above disappear.

Serving the SVG as a file and referencing it with <img src="..."> would mean no id collisions at all (each document is a separate DOM) and no XSS (browsers do not execute scripts in SVGs loaded via <img>). The costs: an extra HTTP request per figure, and the SVG can't inherit page CSS — no currentColor, no dark-mode adaptation, no styling hooks.

Inline embedding, as specified, is the better end result and is what this plan assumes. But it is the option that requires the sanitizer to be correct, whereas <img> is safe by construction. If the sanitizer ever feels like more risk than it's worth, <img> via a fetch.php-style handler is the fallback, and it is a small change late in the process rather than a rewrite.

Separately: dvisvgm emits stroke='#000' literally. The site is light-themed so this is fine today, but a future dark mode would render figures nearly invisible. Rewriting #000 to currentColor during the same DOM pass is cheap insurance and only possible on the inline route.

Caching

Follow the man plugin's model, which already works well here.

Cache the raw, sanitized, un-nonced SVG keyed on md5($source . $engineOptions . $pluginVersion), via getCacheName($key, '.texsvg') so it lands under data/cache/. Apply the id nonce at render time, after the cache read — that keeps a figure that appears twice on one page from colliding with itself, and keeps the cached artifact reusable across pages.

Cache failures too, keyed the same way, with a short TTL. Otherwise a page with a broken figure re-runs LaTeX on every view, which is exactly the situation where you least want to be running LaTeX.

Include __FILE__ and conf/local.php in the cache dependency list, as the man plugin does, so editing the plugin or its settings invalidates.

Error path

On non-zero exit from either stage, or a missing/empty output file, render a code block instead of a figure.

Use texfot to shorten it, with the same ignore list bin/texrender already tuned:

texfot --quiet --no-stderr \
    --ignore='^Overfull' --ignore='^Output written' \
    --ignore='^LaTeX Warning' --ignore='^This is' \
    latex ...

Two things to add on top of bin/texrender's behaviour:

Render it via the standard code instruction with language text — the log is not LaTeX and highlighting it as LaTeX makes it harder to read, which is the one thing the existing latexfigure.php behaviour gets backwards.

Proposed layout

lib/plugins/texrender/
    plugin.info.txt
    syntax/figure.php        \documentclass{standalone}...\end{document}
    helper.php               pipeline, sanitizer, id-rewriter, cache
    conf/default.php
    conf/metadata.php
    lang/en/settings.php
    style.css                figure alignment, indent, overflow

helper.php is where essentially all of this lives; syntax/figure.php should be thin. Splitting the sanitizer into its own class is worthwhile if it grows — it is the part most deserving of unit tests, and it is testable in isolation without invoking LaTeX at all.

Give it its own git repo, like man and socialicons, since lib/plugins/ is gitignored by the main repo.

Configuration

As shipped:

Setting Default Notes
backend local only choice for now; the seam for the container backend
latex /usr/bin/latex required, is_executable()-guarded, man-plugin style
dvisvgm /usr/bin/dvisvgm required
texfot /usr/bin/texfot optional; without it error logs are long, not fatal
timeoutbin /usr/bin/timeout optional; without it renders are not time limited
nicebin /usr/bin/nice optional
prlimit /usr/bin/prlimit optional; without it renders are not memory limited
maxtime 5 seconds per external command
maxmemory 250000 KB of address space, via prlimit --as
niceness 5
maxinput 65536 bytes of figure source accepted
maxsvg 2097152 bytes of SVG accepted back from the backend
maxfigures 48 block figure renders per page request; cache hits do not count
maxinlinefigures 144 inline \tikz renders per page request; cache hits do not count
scale 1.2 passed to dvisvgm --scale
inlinepreamble \usetikzlibrary{arrows.meta,calc,positioning,shapes.geometric} preamble for inline \tikz snippets
align left flush left is indented 2em; center and right are not
currentcolor 1 rewrite pure black to currentColor
errorlines 20 truncation cap on the error block
errorttl 3600 seconds a failed render is remembered
debug 0 list what the sanitizer removed under each figure

Suggested order

  1. Syntax component + texrender_backend_local behind the backend interface, embedding unsanitized output behind a config flag defaulting off — gets the pipeline working end to end on host binaries.
  2. DOM pass: sanitizer and id-rewriter together. Test with the \special{dvisvgm:raw <script>} case above as a fixture; it should render an empty-but-valid figure, not a script tag.
  3. Caching, including negative caching.
  4. Error path with texfot, path scrubbing, truncation.
  5. Resource limits and env scrubbing. Verify with a \def\x{\x}\x expansion bomb and an \input{/etc/hostname} disclosure attempt, both of which should fail cleanly and produce an error block.
  6. Remove latexfigure.php from markdowku; enable this instead.

Steps 1–6 are the whole plugin as a working thing. texrender_backend_service and its container are a separate piece of work afterwards, gated on whether the plugin actually gets published — there's no reason to build it for a single-user wiki, and the interface from step 1 is what keeps that deferral cheap.

Open questions