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 25 block figures and 35 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.
-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.
maxfigures now counts renders, not figures. It was written as a cap on how many figures a page may contain, which was defensible when a figure was a display-sized diagram and indefensible the moment a page could carry thirty inline marks: a cache hit costs nothing, so counting it toward a work limit caps a page forever over work that is never done twice. It now increments only on a cache miss. The other half of that fix is $renderer->nocache() when the 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.
The sanitizer removes nothing from real figures. Run against all 27 rendered test figures — TikZ, CircuiTikZ, chemfig, forest, pgfplots, tikz-cd — 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.
One thing does not work at all: TikZ shadings. See the new section below.
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
A block figure is centred and needs no vertical reference. An inline one has to sit on the text baseline the way TikZ says it should, and TikZ has four answers depending on the baseline option. Reproducing them turned out to need no measurement at all.
dvisvgm leaves the DVI reference point at y=0 of the SVG it writes and crops to the ink, so the bottom edge of the viewBox is the depth below the baseline — negative when the picture clears the line. Better still, the viewBox is in the same unit as the SVG's own width and height, which dvisvgm writes as pt meaning the PostScript big point, and that is exactly what CSS calls pt. So the number transfers unchanged: read minY + height out of the plugin's own output, write vertical-align: -that on the wrapper, done. It survives --scale for free. Checked against \the\dp of the TeX box for five cases and agreeing to within a rounding step.
The one thing this depends on is the wrapper using \documentclass[preview]{standalone} rather than plain standalone. Crop mode moves the content into the corner of the page, and the depth goes with it: two snippets with visibly different baselines produced byte-identical SVGs. Preview mode leaves the content on the reference point, which is what makes y=0 mean anything.
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.
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:
- Make a private temp dir.
data/tmp/isdrwxrws--- iv:www-data, sodata/tmp/texrender/<nonce>/works and inherits the group via setgid. Register a shutdown/finallycleanup. - 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. - Run
latexwith argv['latex', '-interaction=nonstopmode', '-no-shell-escape', '--output-format=dvi', 'fig.tex'],cwdset to the temp dir, and a scrubbed environment (see below). - Run
dvisvgmwith argv['dvisvgm', '--no-fonts', '--exact', '--scale=<conf>', 'fig.dvi', '-o', 'fig.svg']. - Post-process the SVG (sanitize + re-
id, one DOM pass). - 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-data — conf/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 dvisvgm — does 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:
- Elements:
svg,g,defs,use,path,rect,circle,ellipse,line,polyline,polygon,text,tspan,clipPath,mask,pattern,linearGradient,radialGradient,stop,symbol,marker,title,desc,image. Everything else is dropped,<script>and<foreignObject>most of all. - Attributes: geometry and presentation only. Drop every
on*handler,style(or parse it), and anyhref/xlink:hrefthat isn't a local#fragment— with a narrow exception forimagewhere adata:image/...URI may be allowed, if embedded bitmaps are wanted at all. Simplest is to disallowimageentirely in v1. - Drop the XML declaration and the
<!-- generated by dvisvgm -->comment;<?xml ... ?>is invalid inside an HTML body.
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:
- No filesystem worth reading. The container holds TeX Live and nothing else.
\input{/etc/passwd}returns the container's inert/etc/passwd, not the wiki's config. File disclosure stops being a vulnerability and becomes a curiosity. - No network.
--network=noneon the render container. Nothing to exfiltrate to even if something is read. - Real resource limits.
--memory,--cpus,--pids-limitare enforced by the kernel, not by a softprlimitthe process might raise back. - Disposable filesystem.
--read-onlywith atmpfsscratch mount, one container per render, or a pool that resets. No persistence between renders means no cross-figure contamination. - The blast radius of a TeX escape is a container with nothing in it, rather than the wiki.
- Deployment stops depending on the host having TeX Live. That is a genuine practical win independent of security — a ~5 GB dependency becomes someone else's problem, and it's the same argument already made in DokuWiki Docker Feasibility about
texrender's toolchain being the dominant size driver of a wiki image. If the wiki itself ends up containerised, this service is a second compose service and the two arguments collapse into one.
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:
- Strip the temp directory path from the log before displaying it. Raw LaTeX output is full of absolute paths, and this block is shown to every reader of the page, not just the editor. Replace the temp path with a neutral placeholder.
- Cap the length. A misplaced
\begincan produce hundreds of lines even aftertexfot. Truncate to a configurable number of lines with an explicit “… truncated” marker.
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, overflow, centering
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 | 16 | 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 | center | |
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
- Syntax component +
texrender_backend_localbehind the backend interface, embedding unsanitized output behind a config flag defaulting off — gets the pipeline working end to end on host binaries. - 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. - Caching, including negative caching.
- Error path with
texfot, path scrubbing, truncation. - Resource limits and env scrubbing. Verify with a
\def\x{\x}\xexpansion bomb and an\input{/etc/hostname}disclosure attempt, both of which should fail cleanly and produce an error block. - Remove
latexfigure.phpfrommarkdowku; 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
- Preamble policy. The plan assumes arbitrary
\usepackageis allowed, withopenin_any=pand the resource limits as the real controls. A package allowlist is possible but brittle and would break the copy-paste-anything property that motivates the syntax. Recommend no allowlist, but say if you'd rather have one. bin/texrender's fate. The plugin doesn't need it. Keep it as a standalone CLI tool, or retire it once the PHP path is proven?- Does the service backend ever get built? Only worth it if the plugin is published, or if the wiki itself moves into containers and TeX Live's size becomes the deciding factor. Until one of those is true, the interface is the deliverable and the implementation is a stub.
- Cache lifetime. Successful renders can cache indefinitely (they're content-keyed). Negative caching needs a TTL — an hour is a reasonable starting point, but that's a judgement call about how quickly you want a fixed figure to reappear after an edit elsewhere.
