From db510f3e4ac09eb2453091390dd34a521ed4c616 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 3 Sep 2026 18:14:10 +0200 Subject: [PATCH] engine: the float placer keeps its body's assignments, and places a float on its own page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the gated single-column placer, both found by measuring it over the 157-paper arXiv corpus, where it was 1059 pages WORSE than the inline path. The whole of that came from one paper. A captured float body was executed with no group of its own, so an assignment inside it escaped into the document — where LaTeX sets the float inside \vbox\bgroup…\egroup (latex.ltx:12950) and the engine's own inline \@float opens a \begingroup that \end@float closes. The paper has a figure holding \put(-0.33\textwidth,0.5\textwidth){…}: \put is undefined here, so \textwidth reads as the start of an assignment and takes its missing number as zero, and \hsize stayed 0pt for the remaining 230 pages. They set one word per line, and the paper ran to 1439 pages against a reference of 333. Second, a float was only a candidate for a page when it was anchored BEFORE that page began, so every float came out at least a page late and, once the text ran out, on a float page of its own. LaTeX contributes a float at its anchor — \@xfloat fires the output routine there — and \@addtocurcol (latex.ltx:15636) tests it against the room LEFT in the column: it rides the page being built whenever \@colroom exceeds the height already set plus \textfraction plus the float. Floats now join the page they are written on, in their own order per caption type (\@bitor\@currtype\@deferlist: figure 3 never precedes figure 2). Measured over 157 arXiv papers against tectonic, with GOTEX_FLOATS=1: page error 1691 -> 620 (inline, flag off: 632) exact 21 -> 21 (inline: 20) divergence 4.438 (inline: 4.408 — flat within this measure's noise) beamer is untouched (40 over 79 decks either way). The flag stays OFF by default; this makes the placer worth turning on, which is the next step. Co-Authored-By: Claude Opus 5 (1M context) --- floatplace.go | 66 +++++++++++++++++++++++++++++++++++++++++++++- floatplace_test.go | 50 +++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/floatplace.go b/floatplace.go index c26ab7d..05eeded 100644 --- a/floatplace.go +++ b/floatplace.go @@ -64,6 +64,7 @@ const FloatPlacementSubstrate = ` type floatNode struct { box *boxNode place string // placement bits (h/t/b/p), lower-cased; "" means default "tbp" + kind string // caption type: "figure" or "table" — floats of one type keep their order } func (*floatNode) isNode() {} @@ -98,9 +99,20 @@ func (e *Engine) doFloatBegin() { } e.define("@captype", &meaning{kind: mMacro, body: stringToToks(kind)}, true) body := e.collectEnvBody(env) + // The body is a BOX being built, so what it assigns must stay inside it: \@xfloat + // sets the float in \vbox\bgroup…\egroup (latex.ltx:12950), and the engine's own + // inline \@float opens a \begingroup that \end@float closes. Capturing the body + // and running it with no group of its own let an assignment escape into the + // document. One real paper shows the cost: a figure holding + // \put(-0.33\textwidth,0.5\textwidth){…} — \put undefined, so \textwidth reads as + // the start of an assignment and takes the missing number as zero — left \hsize + // at 0pt for the remaining 230 pages, which then set ONE WORD PER LINE and ran to + // 1439 pages against a reference of 333. + e.beginGroup() box := e.typesetGroupToVbox(append([]tok{csTok("centering")}, body...)) + e.endGroup() box.width = e.hsize - e.contribute(&floatNode{box: box, place: place}) + e.contribute(&floatNode{box: box, place: place, kind: kind}) } // isStandardFloatEnv reports whether name is one of the standard float environments, @@ -131,6 +143,18 @@ func (e *Engine) currentEnvName() string { return "figure" } +// kindDeferred reports whether a float of this caption type is still waiting, which +// forbids a later one of the same type from being placed first (\@bitor\@currtype +// \@deferlist in \@addtocurcol): figure 3 must never appear before figure 2. +func kindDeferred(deferred []anchoredFloat, kind string) bool { + for _, af := range deferred { + if af.f.kind == kind { + return true + } + } + return false +} + // mvlHasFloats reports whether the main vertical list carries any captured floatNode. func (e *Engine) mvlHasFloats() bool { for _, n := range e.mvl { @@ -376,6 +400,46 @@ func (e *Engine) pagesWithFloats() []*boxNode { e.vsize = pageVsize end := e.findPageBreak(text, start) e.vsize = savedVsize + + // A float written INSIDE this page is a candidate for this page's own top. + // LaTeX contributes a float at its anchor — \@xfloat fires the output routine + // there with \@floatpenalty — and \@addtocurcol (latex.ltx:15636) then tests it + // against the room LEFT in the column: it goes to the top of the page being + // built whenever \@colroom exceeds the height already set plus \textfraction + // plus the float. That is why a figure declared halfway down a page comes out + // at the top of THAT page. Taking only the floats anchored before the page + // began pushed every one of them a page later, and at the end of the document + // onto float pages of their own. + for fi < len(floats) && floats[fi].at < end { + af := floats[fi] + fi++ + // \@bitor\@currtype\@deferlist: a float may not overtake an earlier one + // of its own type that is still waiting. + if len(top) < 2 && af.c.allowTop && !kindDeferred(deferred, af.f.kind) { + need := fh(af) + if len(top) > 0 { + need += e.floatSep() + } + want := topH + need + wantReserve := want + botH + e.textFloatSep() + if botH > 0 { + wantReserve += e.textFloatSep() + } + if want <= topCap && wantReserve <= vsize-textMin { + top = append(top, af) + topH, reserve = want, wantReserve + pageVsize = vsize - reserve + if pageVsize < textMin { + pageVsize = textMin + } + e.vsize = pageVsize + end = e.findPageBreak(text, start) // less room now: the text stops earlier + e.vsize = savedVsize + continue + } + } + deferred = append(deferred, af) + } pageText := trimTrailingGlue(text[start:end]) // Emit only a non-empty page (as paginateSingleList does): an empty text break with diff --git a/floatplace_test.go b/floatplace_test.go index 6acdc35..ae9764e 100644 --- a/floatplace_test.go +++ b/floatplace_test.go @@ -153,3 +153,53 @@ func TestFloatTopPlacement(t *testing.T) { t.Error("[t] float box was not found at the top of any page") } } + +// The captured body is a box being built, so an assignment inside it must not +// escape into the document. The real case is a figure holding +// \put(-0.33\textwidth,…): \put is undefined, so \textwidth reads as the start of +// an assignment, takes the missing number as zero, and — before the body was given +// a group of its own — left \hsize at 0pt for the rest of the paper, which then set +// one word per line (1439 pages against a reference of 333). +func TestCapturedFloatBodyCannotChangeTheTextWidth(t *testing.T) { + t.Setenv("GOTEX_FLOATS", "1") + e := New() + if err := e.LoadLaTeX(); err != nil { + t.Fatal(err) + } + e.SetFont(spMock{}) + if _, err := e.Run(`\documentclass{article}\begin{document}` + + `\begin{figure}\textwidth=0pt\caption{Plot}\end{figure}` + + strings.Repeat(`Body text paragraph. `, 20) + `\par`); err != nil { + t.Fatal(err) + } + if e.hsize <= 0 { + t.Fatalf("\\hsize = %d sp after the float: an assignment escaped the captured body", e.hsize) + } +} + +// A float written halfway down a page belongs at the top of THAT page: LaTeX +// contributes it at its anchor and \@addtocurcol (latex.ltx:15636) tests it against +// the room left in the column. Taking only floats anchored before the page began +// pushed every one of them at least a page later. +func TestFloatAnchoredInsideThePageRidesIt(t *testing.T) { + t.Setenv("GOTEX_FLOATS", "1") + e := New() + if err := e.LoadLaTeX(); err != nil { + t.Fatal(err) + } + e.SetFont(spMock{}) + // Text, then a figure, then more text: the figure is anchored inside page 1. + if _, err := e.Run(`\documentclass{article}\begin{document}` + + strings.Repeat(`Opening paragraph. `, 10) + `\par` + + `\begin{figure}\caption{Plot}\end{figure}` + + strings.Repeat(`Body text paragraph. `, 10) + `\par`); err != nil { + t.Fatal(err) + } + pages := e.Pages() + if len(pages) != 1 { + t.Fatalf("the whole document fits one page, got %d", len(pages)) + } + if txt := mvlText(pages[0].list); !strings.Contains(txt, "Figure1:Plot") { + t.Errorf("the float did not ride the page it was written on: %q", txt) + } +}