Site Tools


meta:texrender

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

Next revision
Previous revision
meta:texrender [August 13, 2026 at 17:29] – created - external edit 127.0.0.1meta:texrender [August 13, 2026 at 22:03] (current) – external edit 127.0.0.1
Line 1: Line 1:
 # texrender Plugin Plan # texrender Plugin Plan
  
-**texrender** is a proposed DokuWiki syntax plugin that renders `\documentclass{standalone} ... \end{document}` blocks pasted directly into wiki source through a real LaTeX engine and embeds the result as inline SVG. 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.+**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 ## 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. 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 [[meta:texrender-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:
 +
 +```latex
 +\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 ## Threat model
Line 17: Line 111:
 ## Toolchain: verified present ## Toolchain: verified present
  
-Tool Path Version +Tool Path Version ^
-| --- | --- | --- |+
 | `latex` / `pdflatex` | `/usr/bin/latex` | pdfTeX 3.141592653-2.6-1.40.26 (TeX Live 2025/dev/Debian) | | `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 | | `dvisvgm` | `/usr/bin/dvisvgm` | 3.4.4 |
Line 196: Line 289:
 ``` ```
  
-`texrender_backend_local` (proc_open, 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.+`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>` ## Rendering: inline SVG vs `<img>`
Line 248: Line 341:
     conf/metadata.php     conf/metadata.php
     lang/en/settings.php     lang/en/settings.php
-    style.css                figure alignment, overflowcentering+    style.css                figure alignment, indentoverflow
 ``` ```
  
Line 257: Line 350:
 ## Configuration ## Configuration
  
-Setting Default Notes +As shipped: 
-| --- | --- | --- | + 
-| `backend` | `local` | `local` or `service` +Setting Default Notes ^ 
-| `latex` | `/usr/bin/latex` | `is_executable()`-guarded, `man`-plugin style | +| `backend` | `local` | only choice for now; the seam for the container backend 
-| `dvisvgm` | `/usr/bin/dvisvgm` | as above +| `latex` | `/usr/bin/latex` | required, `is_executable()`-guarded, `man`-plugin style | 
-| `texfot` | `/usr/bin/texfot` | as aboveskipped, not fatal, if absent +| `dvisvgm` | `/usr/bin/dvisvgm` | required 
-| `prlimit` | `/usr/bin/prlimit` | as abovelimits simply unenforced if absent +| `texfot` | `/usr/bin/texfot` | optionalwithout it error logs are long, not fatal | 
-| `serviceurl` | `http://127.0.0.1:3333/render` | `service` backend only +| `timeoutbin` | `/usr/bin/timeout` | optionalwithout it renders are not time limited 
-| `servicetimeout` | `10` | client-side, must exceed `timeout` | +| `nicebin` | `/usr/bin/nice` | optional 
-| `engine` | `latex` | DVI route`pdflatex` would need a PDF→SVG path instead +| `prlimit` | `/usr/bin/prlimit` | optionalwithout it renders are not memory limited 
-| `scale` | `1.2` | passed to `dvisvgm --scale` | +| `maxtime` | `5` | seconds per external command 
-| `timeout` | `5` | secondsper LaTeX run +| `maxmemory` | `250000` | KB of address space, via `prlimit --as` |
-| `memory` | `250000` | KB, via `prlimit --as` |+
 | `niceness` | `5` | | | `niceness` | `5` | |
-| `maxinput` | `65536` | bytes of source accepted | +| `maxinput` | `65536` | bytes of figure source accepted 
-| `maxfigures` | `16` | per page |+| `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 | | `errorlines` | `20` | truncation cap on the error block |
-| `align` | `center` | | +| `errorttl` | `3600` | seconds a failed render is remembered 
-| `currentcolor` | `1` | rewrite `#000` → `currentColor` |+| `debug` | `0` | list what the sanitizer removed under each figure |
  
 ## Suggested order ## Suggested order
meta/texrender.1786642144.md.gz · Last modified: by 127.0.0.1