PSTricks semantic layer + conformance harness against real PSTricks - #26
Conversation
Rewrites the parser around an ordered AST with diagnostics and source-order rendering, and roughly doubles the PSTricks surface. Commands added: pscustom, psgrid, psdots, psellipse, psbezier, pscurve, psecurve, psccurve, pswedge, multido. Text macros and header environments gain textbf/textit/texttt/underline, itemize/description, and the theorem family. A new expression parser in @latex2js/utils evaluates algebraic plot bodies (x^2, implicit multiplication, ternaries). Adds a Vite playground with a headless-friendly render page, a Playwright suite covering every corpus example plus interaction, and a CI workflow that runs both and uploads the rendered gallery. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Renders the same source through LaTeX2JS and through genuine PSTricks in Docker, then ranks the pairs by disagreement, so conformance questions are answered by comparison rather than by reading code. render-examples splits the project's own examples into individual pictures (57 from 24 files) and rasterizes each via latex/dvips/ps2pdf/gs; fuzz-corpus generates 336 systematic cases covering every command crossed with each style axis plus draw-order and degenerate-input probes; compare scores pairs on ink coverage, colour histogram and layout occupancy, weighted worst-first. The score is deliberately heuristic: SVG in a browser and Ghostscript output cannot match pixel for pixel, so it orders where to look and never gates. LaTeX2JS-only macros have no upstream equivalent, so they are rewritten to their static state with plot variables pinned and the bindings recorded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
…th a tombstone
parseTextExpression matched the pristine line while matchrepl replaced inside
the accumulated contents, so any macro whose argument an earlier transform had
already rewritten silently failed to replace. \section{Cauchy--Schwarz} was
left as literal source text once -- had become an en dash; the same held for
\subsection, \textbf, \textit and \footnote over dashes and quotes. Matching
the accumulated value fixes the whole class.
The proof environment emitted $\qed$, and MathJax defines no such macro, so
every proof ended in a visible "Undefined control sequence" box. It now emits
the open square amsthm uses, set flush right.
Both defects rendered visibly wrong while the suite stayed green, so they are
covered by tests over the interaction that produced them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
The Y transform inverts the axis, so PSTricks' counter-clockwise sweep is SVG's sweep-flag 0. Both psarc and pswedge passed 1, which traces the complementary arc: every wedge bowed inward, turning 17-pie's five slices into a star and leaving the circle unfilled. Sweep span is now normalised into [0, 2pi), so an end angle preceding the start takes the long way round as PSTricks does, and the large-arc flag follows the real span rather than an absolute difference. A full turn cannot be one SVG arc — start and end coincide — so it is emitted as two half-turns. Sweep direction is invisible to data-extraction tests, so these assert the emitted path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Every renderer decided its own fill, in three mutually inconsistent ways: fillstyle=hlines became a solid fill on pspolygon and psarc, and no fill at all on psellipse, pswedge and pscurve, so the same document rendered differently depending on which shape drew it. All twelve sites now route through resolveFill. solid and none behave exactly as before; hlines, vlines and crosshatch gain a real rendering as SVG patterns honouring hatchwidth, hatchsep, hatchangle and hatchcolor, with the starred forms laying their lines over the fill colour. A style that is still unimplemented resolves to no fill everywhere rather than guessing solid on some shapes. hasFill separates the shape decision — whether a path must be closed before it can be filled — from the paint itself, so hatched shapes are built as fillable regions too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
The initial draw walked env.elements in document order, but the pointer handler removed the .userline and .psplot elements and appended them again, iterating the type-grouped plot map. Re-appending put them at the end of the SVG — above every later shape — and regrouped them by command type, so a diagram that was correct on load silently reordered itself the first time the pointer crossed it. Drawing now happens once, into a layer group that the handler replaces wholesale, so the interactive path uses exactly the same source-order walk as the initial one. Interactive elements recompute against the pointer through resolveData; everything else redraws from the data the parser produced. Order after an event is only observable by dispatching one, so the regression test asserts it there rather than on the initial render alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
\userline takes its head and tail expressions as brace groups, but the
lightblue vector was written (sin(x)}{-y} with an opening parenthesis where a
brace belongs. LaTeX2JS parses it without complaint, so it went unnoticed; real
LaTeX rejects it outright, and it was the only thing keeping four example
pictures from compiling under PSTricks.
The same line appears inside a verbatim block on the site, so the typo was
being published as documentation for how \userline is written.
With this corrected, all 57 example pictures rasterize against real PSTricks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
The splitter took pspicture blocks verbatim, dropping any \psset above them. The corpus routinely sets the unit once for several pictures, so those references rendered at the wrong scale — and under standalone cropping that produced a plausible-looking image rather than an obvious failure. 16-curves came out looking rotated, which reads as a renderer bug until you check the document that was actually compiled. Each block now carries the settings in scope where it appeared, and the manifest records them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Colour options were handed to the browser verbatim, so `fillcolor=gray!40` arrived as an unusable fill value. An unparsable fill is not ignored — it falls back to black — and example 10's light grey plane rendered as a solid black shape covering the vectors and labels drawn over it. parseOptions now resolves the colour-valued keys through resolveColor, which implements xcolor's mix semantics: `base!N` against white, `base!N!other` against a named colour, chained left to right. Anything without a mix term is returned untouched, so plain colour names keep exactly the CSS meaning they already had. An unknown name, a bad percentage, or an unknown second operand returns the input rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
\uservariable{alpha}(0.1,0){x} states the initial cursor position in its
coordinate argument, so alpha starts at 0.1. The harness ignored that and
pinned every such variable to 1, producing a faithful reference of a
different diagram — example 09's tangent line was drawn at a slope the source
never asks for, which reads as a plot bug rather than a harness one.
The expression is now evaluated at the declared position, falling back to the
old pin only when it is not numeric.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
…ranking Slider chrome, whole-document typesetting, and unbound plot variables produce differences no renderer change can close. Ranked alongside everything else they sat at the bottom looking like the worst defects, which is exactly where attention goes first. Those pairs are now listed with the reason they cannot be judged on equal terms, and the ranking covers only pairs that can. The README records the divergences that are deliberate — starred shapes honouring fillcolor, document numbering, CSS colour names — so they are not re-discovered as bugs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
EnvContent matches Command before Line, and a command's tail stops at the
next command, so `\item First with \textbf{bold} text` arrives as two command
nodes from one source line. Walking them individually rendered each as its own
line, breaking every list item at its first macro — the same text outside a
list was unaffected, which is why it read as a list-styling quirk.
Text environments now rejoin nodes that share a source line before the text
passes run. An empty Line closes the line being built, or is a paragraph break
when there is nothing to close, so blank-line handling is unchanged. pspicture
keeps the per-node walk, since it depends on receiving commands separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
psaxes parsed `ticks` and `labels` and then discarded both, so ticks=none still drew ticks and no axis could ever carry a number — the reference renders number every tick. Both options are now carried onto the data and select which axes get marks and numbers. Two errors surfaced while adding the labels. Ticks stepped from the end of the axis rather than the origin, so an axis spanning -3.5 to 3.5 was marked at half-integers; they now step outward from the origin and land on whole multiples. And because Y inverts the axis, a vertical span arrives with its ends reversed, which made the y loop's condition false immediately — no y tick has ever been drawn. SVGSelection.text narrowed on SVGTextElement, a constructor jsdom does not expose as a global, so the first axis label threw a ReferenceError. Every Element carries textContent, so no narrowing is needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Ghostscript crops to the PostScript bounding box while the browser fills a fixed viewport, so ink as a fraction of the whole canvas mostly reported how differently the two pad their output. Simple pictures scored worst purely for sitting in more whitespace. Ink is now measured against the drawing's own bounding box, the same frame the layout signal already uses. The ranking now separates on what the renderers actually draw: the two lowest are the starred-fill divergence, and their gap is entirely colour, with ink and layout at 92% and above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Fixes applied since the PR openedNine defects found and fixed by comparing against real PSTricks. All 177 tests green; every fix carries a regression test over the interaction that produced it, because each of these rendered visibly wrong while the suite stayed green. Rendering
Text
Corpus
Harness corrections (it was wrong twice, in ways that looked like renderer bugs)
Where it stands18 pairs ranked, 3 listed separately as structurally incomparable (slider chrome, whole-document typesetting). The two lowest ranked are the starred-fill divergence — 🤖 Generated with Claude Code |
…lling by default Endpoints were transformed from `r*cos(theta)` alone, placing them as though every arc were centred on the picture origin. A pie at (0,0) looked correct, which is why this survived — the same wedge anywhere else collapsed into a spike reaching back to the origin. The centre is now added before the transform, so an arc is the same shape wherever it sits. psarc and pswedge also defaulted to fillstyle=solid with a black fillcolor, so an unstarred \psarc drew a solid black wedge instead of the open curve PSTricks draws. PSTricks fills nothing unless a fillstyle is given or the starred form is used; both now default to none. No existing example relies on the old default — every pswedge and the one psarc in the corpus either sets a fillstyle or is starred. Both surfaced from stress cases placing arcs away from the origin, which the example corpus never did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Nine stress cases covering combinations the example corpus never had: arcs at every angle configuration including reversed and full turns, arcs placed away from the origin, every fill style side by side, deep interleaved layering, xcolor tint ramps, and five axis configurations. Two of the bugs fixed in this branch were found by these and by nothing else. before-after.mjs pairs each example as it rendered before a change, as it renders now, and what PSTricks draws for the same source, ordered by how much moved. Where the canvases match it compares per pixel — an ink-position signature cannot see a recolour, so a plane turning from black to light grey registered as no change at all; where the drawing changed size it falls back to the coarse signature and says so rather than reporting a meaningless percentage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
…s arrowhead A PSTricks grid is three things: fine subdivision lines, a heavier line on each unit, and optional coordinate numbers. Only the unit lines were drawn, in `linecolor` — which `gridcolor` could not override, so setting it did nothing. subgriddiv, subgridcolor, subgridwidth, gridcolor and gridwidth are now honoured, with the subdivisions drawn first so the unit lines sit over them. Grid numbers are opt-in rather than on by default. PSTricks draws them outside the grid on an unbounded page; an SVG is sized to the picture's declared bounds, so on a grid reaching the edge they would land outside the viewport and be clipped. When asked for they are clamped inside so they always show. psaxes also gives an arrowed end of an axis to its arrowhead: verified against PSTricks, where an arrow suppresses both the tick and its number at that end while the un-arrowed end keeps both. Only a tick coincident with the tip is dropped, so one that merely falls short of it survives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
A blank source line becomes a <br>, and a heading carries its own margins, so a break next to one produced two gaps where the author asked for one. Runs of breaks also survived verbatim — the parser snapshot pinned nine consecutive <br> between two paragraphs — which spread a short document over roughly half again the height it needs. Breaks adjacent to a block element are dropped, a run collapses to one, and trailing breaks are trimmed. Spacing is left to the elements' own margins, where it can be controlled from CSS. pspicture and verbatim keep their lines untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
pnpm/action-setup refuses to install when the version is named both in its own `version` input and in package.json's `packageManager`, so every run has failed during setup — before installing a single dependency. Dropping the input leaves `packageManager` as the one place the version is declared, which is also what keeps local and CI runs on the same pnpm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Adds a third job that renders every example with genuine PSTricks and scores it against the LaTeX2JS output, publishing the comparison as an artifact. Only the deterministic half gates: a picture that stops compiling under PSTricks fails the job, while the similarity score is a heuristic between two rasterizers and is published for review rather than enforced. The runner is amd64, so the image runs natively there instead of emulated. Also: superseded runs on a branch are cancelled rather than queued behind each other, jobs carry timeouts, Playwright browsers are cached against their exact version so an upgrade misses the cache instead of restoring browsers the new Playwright refuses, and the e2e job publishes one browsable gallery page beside the loose PNGs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
CI was failing on every run — fixed, then extendedRoot cause: Both original jobs now pass in ~80s. Third job: PSTricks conformanceRenders every example with genuine PSTricks in What gates and what doesn't: a picture that stops compiling under PSTricks fails the job — that's deterministic and a definite regression. The similarity score is a heuristic between two different rasterizers and is published, not enforced; SVG and Ghostscript output can never match pixel for pixel, so a threshold there would only produce noise. 158s total, and the breakdown says where it goes:
The image pull is the bulk. If that becomes annoying it's worth a paths filter so the job only runs when the renderer or corpus changes — happy to add it, but I'd rather see it run on everything first. Operational fixes
Artifacts per run
🤖 Generated with Claude Code |
The conformance job pulls a multi-gigabyte TeX image and takes over two minutes, which is wasted on a change that cannot alter what gets drawn. A preceding job diffs against the base commit and decides. The package list is the dependency closure of the html5 renderer rather than a guess: html5 pulls in latex2js, pstricks, utils and macros; latex2js and pstricks both pull in settings; settings pulls in utils. settings and macros were missing from the first draft of the list, which would have skipped the job on a change that can move a rendering. The lockfile and root manifest are included too, since a dependency bump can shift output without a source edit. The filter fails open: an unknown or unreachable base commit, a failed diff, or a manual run all take the job. Skipping a correctness check on a bad guess is the expensive mistake; a wasted run is only slow. It also prints the file list and the reason it decided, so a surprising skip can be read straight from the log rather than reproduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
Records what the conformance job gates on and what it only publishes, the artifacts each run produces, and the dependency closure the paths filter is derived from — including the warning to widen it when a package gains an edge into that closure, since the failure mode is a job that silently stops covering something. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf
|
Filed #27 with a full plan for the dialect work and the divergences found while building the conformance harness. The short version: LaTeX2JS accepts a dialect of PSTricks that is partly a deliberate extension and partly accident, and nothing currently distinguishes the two. #27 proposes a Three things from it are strictly fixes with no design question attached, and could land before any dialect work:
Nothing in #27 blocks this PR. |
Two commits: the semantic-layer rewrite, and a harness that checks it against genuine PSTricks.
Semantic layer
Parser rebuilt around an ordered AST with diagnostics and source-order rendering. Command coverage roughly doubles —
pscustom,psgrid,psdots,psellipse,psbezier,pscurve,psecurve,psccurve,pswedge,multido— plus text macros, header environments, and a new expression parser in@latex2js/utilsfor algebraic plot bodies.Adds a Vite playground with a headless render page, a Playwright suite over every corpus example, and CI running both.
pnpm testis green across all 10 projects.Conformance harness (
tools/pstricks-conformance/)Renders the same source twice — LaTeX2JS and real PSTricks in
pyramation/pstricks-latex— and ranks pairs by disagreement. Ground truth is the spec made visual.render-examples.mjs— splits the 24 example files into 57 pictures, renders each. 53/57 succeed in ~30s.fuzz-corpus.mjs— 336 systematic cases: every command × style axis, plus draw-order and degenerate-input probes. 334/336 render.compare.mjs— scores pairs on ink / colour histogram / layout occupancy.The score is deliberately a triage ordering, not a gate — SVG and Ghostscript output cannot match pixel for pixel.
Why this is needed: the existing corpus cannot measure coverage.
\pslineappears 256 times across all examples while every command added here appears once, and\psecurveappears zero times despite being implemented.What the harness already found
Not fixed in this PR — recorded here so they are tracked.
psgraph.tspspicture()/userEventremoves and re-appends.userline/.psplot, so they jump to the top layer and reorder among themselves on first pointer move. The initial draw respects source order; the interactive path does not. A diagram is correct until the pointer moves over it.\pswedgedraws concave arcs.17-pierenders as a petal/star rather than a filled pie. Source is pure PSTricks, no shims.fillstyle=solidis implemented, and the fallbacks disagree —hlinesbecomes a solid fill onpspolygon/psarcbut no fill onpsellipse/pswedge.\sectionrenders as literal source text, and the proof environment emits an undefined\qed.\psframe*) honourfillcolor; PSTricks fills withlinecolorand ignores it.\userline...(2,2)(sin(x)}{-y}, where(should be{. LaTeX rejects it; LaTeX2JS parses it silently.Dialect divergences needing a decision
LaTeX2JS accepts input PSTricks rejects:
pow(a,b), infix bodies withoutalgebraic=true, variable plot bounds ({alpha-3}), a barealgebraickey,plotpoints=1(PSTricks requires at least 2), implicit multiplication (0.5x^3). Each is keep-the-extension or conform.🤖 Generated with Claude Code
https://claude.ai/code/session_01HBYcp3Go9naDWgFThChaDf