From 5b3a1ac2d0ca89c9e92d0f777d4696a3111c656e Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 1 Sep 2026 09:36:50 +0200 Subject: [PATCH] engine: flatten only the body that was substituted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #179 says it runs "the substituted material" through the gullet; it ran the WHOLE maths source. The rest of that source is the author's maths, and the engine's text-mode stand-ins won there: \def\cdot{\char183\relax} (latex.go) \def\begin#1{\gotex@checkenv{#1}\csname #1\endcsname} so every \cdot became \char — 935 dropped formulas over 45 papers — and every \begin{bmatrix} became \bmatrix, 676 over 42. Worse than the drops: with \begin expanded, \gotex@checkenv opened the environment and the ENGINE typeset the matrix as text. One paper came out with 411251 glyphs on seven pages, 58000 a page, where it has 69284. Now only the body just substituted is flattened, which is what the seam was for: a class's \the@inst is \number\c@inst and that still resolves. \begin and \end are marked \noexpand inside the body too, for a macro that wraps an environment. Measured over 200 arXiv papers and 200 beamer talks, against main: formulas the maths layer refuses 4312 -> 2715 arXiv page error (157 renders) 624 -> 634, exacts 17 -> 19 the flooded paper 411251 -> 69284 glyphs The page error moves the wrong way by ten because the flood was flattering it. Against the state before #179 the pair now stands at 651 -> 634 with 14 -> 19 exact, for 212 more dropped formulas — a trade the exact matches and the junk removal earn. --- math.go | 46 ++++++++++++++++++++++++++++------------ mathprotectbegin_test.go | 33 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) create mode 100644 mathprotectbegin_test.go diff --git a/math.go b/math.go index 000a2e4..f247964 100644 --- a/math.go +++ b/math.go @@ -260,17 +260,6 @@ func (e *Engine) renderMathResolvingMacros(r *texmath.Renderer, src string, disp continue } next, ok := e.expandMacroInMathSource(src, name) - if ok { - // The body just spliced in is TeX, not maths: a class's \the@inst is - // \number\c@inst, an affiliation mark is an \@for loop over \expandafter - // and \edef. Those primitives are the ENGINE's to run, and the maths layer - // can only refuse them. Run the substituted material through the gullet — - // bounded to what was spliced, and unable to read past it since an isolated - // expansion stops at its own sentinel. - if flat := e.toksToString(e.expandList(tokenizeTeX(next))); flat != "" { - next = flat - } - } if !ok { // Colour commands (\color, \textcolor, …) are primitives go-tex/math // cannot render. Strip them from the source — keeping any content @@ -338,7 +327,7 @@ func (e *Engine) expandMacroInMathSource(src, name string) (string, bool) { return "", false } if len(m.params) == 0 { - return replaceMathCS(src, name, e.toksToString(m.body)), true + return replaceMathCS(src, name, e.flattenMathBody(e.toksToString(m.body))), true } needle := "\\" + name + " " n := len(m.params) @@ -362,7 +351,7 @@ func (e *Engine) expandMacroInMathSource(src, name string) (string, bool) { src = rest continue } - out.WriteString(e.substituteMathBody(m.body, args)) + out.WriteString(e.flattenMathBody(e.substituteMathBody(m.body, args))) src = rest[consumed:] changed = true } @@ -971,6 +960,37 @@ func (e *Engine) substituteMathBody(body []tok, args []string) string { return b.String() } +// flattenMathBody runs a substituted macro body through the gullet, as \edef would. +// A class's body is TeX, not maths — \the@inst is \number\c@inst, an author's mark +// is an \@for loop over \expandafter and \edef — and the maths layer can only refuse +// those primitives, which costs the whole formula. +// +// Only the body just substituted goes through, never the surrounding source: the +// source holds commands the maths layer renders itself, and the engine's text-mode +// stand-ins for them would win. \cdot is \char183 here (latex.go) and \begin is +// +// \def\begin#1{\gotex@checkenv{#1}\csname #1\endcsname} +// +// so flattening the whole source turned every \cdot into \char and every +// \begin{bmatrix} into \bmatrix — 935 dropped formulas over 45 papers for the first, +// 676 over 42 for the second, and one paper typeset its matrices as text, 411251 +// glyphs on seven pages. +// +// \begin and \end are still marked \noexpand inside the body itself, where the same +// reasoning applies to a macro that wraps an environment. +func (e *Engine) flattenMathBody(body string) string { + ts := tokenizeTeX(body) + for i, t := range ts { + if t.cs_ && (t.cs == "begin" || t.cs == "end") { + ts[i].noexp = true + } + } + if flat := e.toksToString(e.expandList(ts)); flat != "" { + return flat + } + return body +} + // unknownMathCommand extracts the command name X from a go-tex/math "unknown command // \X" error, or "" when the message is a different failure. The name runs over letters // and @ (a LaTeX-internal control word), matching a TeX control-word token. diff --git a/mathprotectbegin_test.go b/mathprotectbegin_test.go new file mode 100644 index 0000000..85bd25a --- /dev/null +++ b/mathprotectbegin_test.go @@ -0,0 +1,33 @@ +// Copyright (c) the go-tex/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import "testing" + +// A substituted macro body is flattened through the gullet (#179) — but \begin and +// \end are macros here: +// +// \def\begin#1{\gotex@checkenv{#1}\csname #1\endcsname} +// +// so expanding them turns \begin{bmatrix} into \bmatrix, a control sequence the +// maths layer has never heard of, where the environment it wrote is one it renders. +// A paper's own \newcommand{\mymat}[2]{\begin{bmatrix}#1\\#2\end{bmatrix}} lost every +// formula that used it; over the corpus, bmatrix, pmatrix, cases, aligned, split and +// array accounted for 676 fresh drops in 42 papers. +func TestMathKeepsAnEnvironmentInsideAMacro(t *testing.T) { + for _, c := range []struct{ nom, def, math string }{ + {"bmatrix", `\newcommand{\mymat}[2]{\begin{bmatrix}#1\\#2\end{bmatrix}}`, `A=\mymat{a}{b}`}, + {"cases", `\newcommand{\mycase}[1]{\begin{cases}#1 & x>0\end{cases}}`, `f=\mycase{1}`}, + {"array", `\newcommand{\myarr}[1]{\begin{array}{c}#1\end{array}}`, `M=\myarr{z}`}, + } { + e, err := compile([]byte(`\documentclass{article}\usepackage{amsmath}`+c.def+ + `\begin{document}$`+c.math+`$\end{document}`), Options{Lenient: true}) + if err != nil { + t.Fatalf("%s: %v", c.nom, err) + } + if len(e.mathDropped) != 0 { + t.Errorf("%s: la couche maths a refusé la formule (%v)", c.nom, e.mathDropped) + } + } +}