Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions classkernel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions classoptionslist_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
32 changes: 31 additions & 1 deletion packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading