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) + } + } +}