diff --git a/classkernel.go b/classkernel.go index 92727fd..afd120c 100644 --- a/classkernel.go +++ b/classkernel.go @@ -106,6 +106,18 @@ const LaTeX2eClassKernel = ` % and with \@colht undefined the skipped command left \textheight (which IS \vsize % here) standing in vertical mode, where it read the FOLLOWING token as its value % and set the page height to zero. Every beamer frame then went on one endless page. +% \@classoptionslist holds the options given to \documentclass, and \@raw@classoptionslist +% the same list before spaces are stripped. ltclass.dtx initialises BOTH to \relax +% (\let\@classoptionslist\relax) and \documentclass overwrites them on the first class +% load; code then tests \ifx\@classoptionslist\relax before iterating. +% +% They matter because packages iterate over the list to pick up class options they +% recognise, and \@for over an UNDEFINED control sequence does not iterate — it +% swallows what follows. beamer does exactly that, unguarded, in both +% \beamer@filterclassoptions and \ProcessOptionsBeamer, so a theme built on +% \ProcessOptionsBeamer took the rest of the document with it. +\let\@classoptionslist\relax +\let\@raw@classoptionslist\relax \newdimen\@colht \newdimen\@colroom \newdimen\Gm@lmargin diff --git a/classoptionslist_test.go b/classoptionslist_test.go new file mode 100644 index 0000000..1201ba9 --- /dev/null +++ b/classoptionslist_test.go @@ -0,0 +1,69 @@ +// Copyright (c) the go-tex/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import ( + "strings" + "testing" +) + +// \@classoptionslist holds the options given to \documentclass. ltclass.dtx starts it +// (and \@raw@classoptionslist) at \relax and has \documentclass overwrite them on the +// FIRST class load: +// +// \ifx\@classoptionslist\relax +// \protected@xdef\@classoptionslist{\zap@space#2 \@empty}% +// \gdef\@raw@classoptionslist{#2}% +// +// Leaving it undefined was not merely a blank. \@for over an undefined control +// sequence does not iterate over nothing — it SWALLOWS what follows. beamer runs that +// loop unguarded in \beamer@filterclassoptions and again in \ProcessOptionsBeamer, so +// any theme built on \ProcessOptionsBeamer took the rest of the document with it. + +func clsRun(t *testing.T, src string) string { + t.Helper() + e, err := buildEngine(Options{Lenient: true}, true) + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + out, err := e.Run(src) + if err != nil { + t.Fatalf("Run(%q): %v", src, err) + } + return out +} + +func TestClassOptionListRecordsTheOptions(t *testing.T) { + for _, c := range []struct{ name, src, want string }{ + {"no options", `\documentclass{article}\makeatletter\message{[\@classoptionslist]}`, "[]"}, + {"one option", `\documentclass[11pt]{article}\makeatletter\message{[\@classoptionslist]}`, "[11pt]"}, + {"several", `\documentclass[11pt,a4paper]{article}\makeatletter\message{[\@classoptionslist]}`, "[11pt,a4paper]"}, + {"raw list too", `\documentclass[11pt]{article}\makeatletter\message{[\@raw@classoptionslist]}`, "[11pt]"}, + } { + if got := clsRun(t, c.src); !strings.Contains(got, c.want) { + t.Errorf("%s: got %q, want it to contain %q", c.name, got, c.want) + } + } +} + +func TestForOverTheClassOptionListDoesNotSwallow(t *testing.T) { + // The shape beamer uses, unguarded. Before \@classoptionslist existed, the loop + // ate everything after it. + src := `\documentclass{article}\makeatletter` + + `\@for\CurrentOption:=\@classoptionslist\do{\message{[tour]}}` + + `\makeatother\message{[apres]}` + if got := clsRun(t, src); !strings.Contains(got, "[apres]") { + t.Errorf("got %q — \\@for over \\@classoptionslist swallowed what followed", got) + } +} + +func TestClassOptionListIsSetOnlyByTheFirstClass(t *testing.T) { + // ltclass.dtx guards the assignment with \ifx\@classoptionslist\relax, so a class + // that loads another class does not overwrite the document's own list. + src := `\documentclass[11pt]{article}\documentclass[12pt]{article}` + + `\makeatletter\message{[\@classoptionslist]}` + if got := clsRun(t, src); !strings.Contains(got, "[11pt]") { + t.Errorf("got %q, want the FIRST class's options [11pt]", got) + } +} diff --git a/packages.go b/packages.go index e450d4b..52aa78b 100644 --- a/packages.go +++ b/packages.go @@ -334,6 +334,35 @@ func (e *Engine) realAmsart() bool { return ok } +// setClassOptionList records \documentclass's options in \@classoptionslist, and the +// same list in \@raw@classoptionslist, as ltclass.dtx does on the first class load: +// +// \ifx\@classoptionslist\relax +// \protected@xdef\@classoptionslist{\zap@space#2 \@empty}% +// \gdef\@raw@classoptionslist{#2}% +// +// Both start out \relax (see the class kernel), so a second \documentclass — or a +// \LoadClass from inside a class — leaves the first one's list alone, which is the +// test the reference makes. +// +// A package reads the list back to pick up class options it recognises. Leaving it +// undefined was not merely a blank: \@for over an undefined control sequence does not +// iterate over nothing, it SWALLOWS what follows. beamer runs that loop unguarded in +// \beamer@filterclassoptions and again in \ProcessOptionsBeamer, so any theme built on +// \ProcessOptionsBeamer — beamerthemesplit, and the fifteen themes that share its shape +// — took the rest of the document with it. +// +// The engine's option scanner has already trimmed each item, which is what \zap@space +// is there for. +func (e *Engine) setClassOptionList(opts []string) { + if m := e.eq["@classoptionslist"]; m != nil && !(m.kind == mPrim && m.name == "relax") { + return // a class list is already recorded: the first one wins + } + body := charToks(strings.Join(opts, ",")) + e.define("@classoptionslist", &meaning{kind: mMacro, body: body}, true) + e.define("@raw@classoptionslist", &meaning{kind: mMacro, body: body}, true) +} + // doDocumentClass implements \documentclass[options]{class}: for a non-emulated // class it loads class.cls when it can be resolved; for a standard class (and when // the file cannot be found) it falls back to the built-in emulation. @@ -343,7 +372,8 @@ func (e *Engine) doDocumentClass() { if name == "" { return } - e.setPtsize(opts) // record 10pt/11pt/12pt for \@ptsize even without the .cls + e.setPtsize(opts) // record 10pt/11pt/12pt for \@ptsize even without the .cls + e.setClassOptionList(opts) // \@classoptionslist, for packages that read them back if name == "beamer" && !e.realBeamer() { e.loadBeamer() return