From d592de04565f298e3e7db0e6140c3cbb98502539 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 23:03:01 +0200 Subject: [PATCH] engine: two \column's in a frame no longer cost a page break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beamer's \beamer@colclose is not just an \end{minipage}: \def\beamer@colclose{\end{minipage}\hfill\end{actionenv}\ignorespaces} (beamerbaseframecomponents.sty:283) A body scanner that meets the next \column has honoured only its first half — the \end{minipage} — but emptied the WHOLE macro, dropping the \end{actionenv} that pairs with the \begin{actionenv} the column opened. Two columns then left an environment group open, the \end{frame} that followed closed THAT instead of the frame, and the next frame never began its own page. With material after \end{columns} the pages even came out in the wrong order: the text that followed the columns on the page BEFORE the frame's own content. So only the honoured half is dropped now. This needed #214 first: the talk that made the naive version look like a regression has an lstlisting in its first column, and the listing was eating the column. Measured against tectonic, with #214 in: beamer page error 40 → 38 over 79 talks, 62 → 63 exact — 0029f300 goes 5 → 7 pages against a reference of 7, and 039a234b02c5 keeps its 4 pages while drawing 1307 → 1493 glyphs. arXiv unchanged at 583 over 157 papers. Closes #205. Co-Authored-By: Claude Opus 5 --- beamercolumns_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++ minipage.go | 47 ++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 beamercolumns_test.go diff --git a/beamercolumns_test.go b/beamercolumns_test.go new file mode 100644 index 0000000..091b592 --- /dev/null +++ b/beamercolumns_test.go @@ -0,0 +1,70 @@ +package engine + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Two \column's in a frame must not cost a page break. beamer's \beamer@colclose +// carries \end{minipage}\hfill\end{actionenv}\ignorespaces +// (beamerbaseframecomponents.sty:283); honouring only the \end{minipage} and +// emptying the whole macro dropped the \end{actionenv} pairing the +// \begin{actionenv} the column opened, so two columns left an environment group +// open and the \end{frame} that followed closed THAT — the next frame never began +// its own page, and material written after \end{columns} came out on the page +// BEFORE the frame's own content. +func TestTwoBeamerColumnsKeepTheirPageBreaks(t *testing.T) { + // \beamer@colclose exists only in the REAL class; the built-in emulation makes + // \column a \par and never opens an actionenv, so it cannot show this defect. + // Skip rather than pass for the wrong reason where the tree is not installed. + tree := os.Getenv("GOTEX_TEXMF") + if tree == "" { + tree = "/Users/Shared/gotex/measure/texmf" + } + if _, err := os.Stat(filepath.Join(tree, "beamer.cls")); err != nil { + t.Skip("no real beamer.cls under GOTEX_TEXMF: the emulation does not exercise \\beamer@colclose") + } + t.Setenv("GOTEX_TEXMF", tree) + src := `\documentclass{beamer}\begin{document}` + + `\begin{frame}{Un}\begin{columns}` + + `\column{0.5\textwidth}gauche` + + `\column{0.5\textwidth}droite` + + `\end{columns}\end{frame}` + + `\begin{frame}{Deux}beta\end{frame}` + + `\begin{frame}{Trois}gamma\end{frame}\end{document}` + e, err := compile([]byte(src), Options{Lenient: true}) + if err != nil { + t.Fatal(err) + } + pages := e.Pages() + if len(pages) != 3 { + var got []string + for _, p := range pages { + got = append(got, mvlText(p.list)) + } + t.Fatalf("pages=%d want 3: %q", len(pages), got) + } + if txt := mvlText(pages[1].list); !strings.Contains(txt, "Deux") || strings.Contains(txt, "Trois") { + t.Errorf("page 2 should carry the second frame alone: %q", txt) + } +} + +// colCloseAfterEndMinipage keeps what follows the \end{minipage} it honours, and +// answers nil for a closer that does not begin with one (nothing honoured, nothing +// to drop). +func TestColCloseKeepsWhatFollowsTheEndMinipage(t *testing.T) { + body := tokenizeTeX(`\end{minipage}\hfill\end{actionenv}\ignorespaces`) + rest := colCloseAfterEndMinipage(body) + e := New() + if got := e.toksToString(rest); got != `\hfill \end {actionenv}\ignorespaces ` && !strings.Contains(got, "actionenv") { + t.Errorf("rest = %q, want the \\end{actionenv} tail", got) + } + if colCloseAfterEndMinipage(tokenizeTeX(`\hfill`)) != nil { + t.Error("a closer with no leading \\end{minipage} must be left alone") + } + if colCloseAfterEndMinipage(nil) != nil { + t.Error("an empty closer must stay empty") + } +} diff --git a/minipage.go b/minipage.go index d24ca66..73796cf 100644 --- a/minipage.go +++ b/minipage.go @@ -35,6 +35,51 @@ func (e *Engine) doMinipage() { e.place(alignParbox(vbox, pos)) } +// honourColClose takes the \end{minipage} off the front of \beamer@colclose and +// leaves the REST of it in place. The closer beamer builds is +// +// \def\beamer@colclose{\end{minipage}\hfill\end{actionenv}\ignorespaces} +// +// (beamerbaseframecomponents.sty:283), and a body scanner that meets the next +// \column has honoured only its FIRST half — the \end{minipage}. Emptying the whole +// macro, as this did, dropped the \end{actionenv} that pairs with the +// \begin{actionenv} the column opened: two columns then left one environment group +// open, and the \end{frame} that followed closed THAT instead of the frame, so the +// next frame never started its own page. A talk with two columns lost a page break +// and could emit its pages out of order (issue #205). +func (e *Engine) honourColClose() { + m := e.eq["beamer@colclose"] + rest := []tok(nil) + if m != nil && m.kind == mMacro { + rest = colCloseAfterEndMinipage(m.body) + } + e.define("beamer@colclose", &meaning{kind: mMacro, body: rest}, true) +} + +// colCloseAfterEndMinipage returns what follows a leading \end{minipage} in a +// \beamer@colclose body, or nil when the body does not begin with one (nothing was +// honoured, so nothing is dropped). +func colCloseAfterEndMinipage(body []tok) []tok { + if len(body) == 0 || !body[0].cs_ || body[0].cs != "end" { + return nil + } + i, name := 1, "" + if i < len(body) && !body[i].cs_ && body[i].cat == catBegin { + i++ + for i < len(body) && !body[i].cs_ && body[i].cat != catEnd { + name += string(body[i].ch) + i++ + } + if i < len(body) { + i++ // the closing brace + } + } + if name != "minipage" { + return nil + } + return append([]tok(nil), body[i:]...) +} + // collectEnvBody reads raw tokens (no expansion) up to the matching \end{name} and // returns the body tokens. It tracks nesting: a \begin{name} deeper in the body // increments the depth and a \end{name} decrements it, so only the \end{name} at @@ -118,7 +163,7 @@ func (e *Engine) collectEnvBody(name string) []tok { // \beamer@colclose is emptied because its \end{minipage} has just been // honoured, which is what beamer itself does after running it (:269). e.back(t) - e.define("beamer@colclose", &meaning{kind: mMacro}, true) + e.honourColClose() e.endEnvGroup() return body case t.cs_ && t.cs != "end" && t.cs != "begin" && e.expandsToEnd(t):