From 37e0fe65a6430634cb6d5b6e6286fe6d4ccfd6d8 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 4 Sep 2026 12:18:18 +0200 Subject: [PATCH] engine: tabular* is a tabular that takes its width first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \begin{tabular*}{W}{spec} was undefined, so its width and its column spec were typeset as running text and the body set as a paragraph — "0.9@lcr un deux trois" on the page where a table belongs. One IEEEtran paper in the corpus uses it ten times; eight uses across four of the 200 arXiv papers. LaTeX reads the width and is then tabular, with the alignment packed to it (latex.ltx:12092): \@namedef{tabular*}#1{\setlength\dimen@{#1}\edef\@halignto{to\the\dimen@}\@tabular} so the body, the rules, \\ and & behave exactly as in tabular and the width is a target for the inter-column glue. Here the assembled box is packed to that width, which is what \extracolsep{\fill} in the preamble asks for; a table wider than the request keeps its natural width rather than overlapping its columns. Co-Authored-By: Claude Opus 5 --- primitives.go | 4 +++- tabular.go | 26 ++++++++++++++++++++++++++ tabular_test.go | 40 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/primitives.go b/primitives.go index 81b613c..58ec4f9 100644 --- a/primitives.go +++ b/primitives.go @@ -1859,7 +1859,9 @@ func (e *Engine) loadMore() { e.prim("bibliography", func(e *Engine) { e.doBibliography() }) // read .bib, emit thebibliography e.prim("gotex@putbib", func(e *Engine) { e.doPutbib() }) // bibunits: \input the current unit's bu.bbl e.prim("tabular", func(e *Engine) { e.doTabular() }) - e.prim("endtabular", func(e *Engine) {}) // consumed by doTabular; defined for safety + e.prim("tabular*", func(e *Engine) { e.doTabularStar() }) + e.prim("endtabular*", func(e *Engine) {}) // consumed by doTabularStar + e.prim("endtabular", func(e *Engine) {}) // consumed by doTabular; defined for safety e.prim("tabularx", func(e *Engine) { e.doTabularx() }) e.prim("endtabularx", func(e *Engine) {}) // consumed by doTabularx; defined for safety e.prim("minipage", func(e *Engine) { e.doMinipage() }) diff --git a/tabular.go b/tabular.go index c2d118c..fdc094b 100644 --- a/tabular.go +++ b/tabular.go @@ -106,6 +106,32 @@ func (e *Engine) doTabular() { e.place(e.buildTabularBox(aligns, pwidths, vrules, items)) } +// doTabularStar typesets a tabular* environment: \begin{tabular*}{W}[pos]{spec}. +// LaTeX reads the width and then IS tabular, with the alignment packed to it +// (latex.ltx:12092): +// +// \@namedef{tabular*}#1{\setlength\dimen@{#1}\edef\@halignto{to\the\dimen@}\@tabular} +// +// so the width is a target for the inter-column glue, and the body, the rules, +// \\ and & behave exactly as in tabular. Here the assembled box is packed to that +// width, which is what \extracolsep{\fill} in the preamble is asking for; when the +// natural table is wider, packing to a smaller width would overlap columns, so the +// natural box is kept (an overfull \hbox in TeX's terms). +// +// Undefined, its 10 uses in one IEEEtran paper each set their body as running text +// and the paper came out 43 pages against a reference of 27. +func (e *Engine) doTabularStar() { + width := e.readBraceDimen() + e.scanOptBracketToks() // optional [t]/[b]/[c], after the width for tabular* + aligns, pwidths, vrules := e.scanColSpec() + items := e.collectTabularBody("tabular*") + box := e.buildTabularBox(aligns, pwidths, vrules, items) + if width > box.width { + box = hpackSP([]node{box, glueNode{spec: glueSpec{stretch: unity, stretchOrder: 1}}}, packTo, width) + } + e.place(box) +} + // doTabularx typesets a tabularx environment: \begin{tabularx}{W}{spec}. Unlike // tabular it takes a leading {width} argument (a rigid , e.g. \hsize or // \linewidth); every X column in {spec} is a paragraph column whose width is diff --git a/tabular_test.go b/tabular_test.go index c0991ef..28f56dc 100644 --- a/tabular_test.go +++ b/tabular_test.go @@ -1,6 +1,9 @@ package engine -import "testing" +import ( + "strings" + "testing" +) func lastVbox(e *Engine) *boxNode { for i := len(e.mvl) - 1; i >= 0; i-- { @@ -283,3 +286,38 @@ func TestTabularxOptionalPlacement(t *testing.T) { } } } + +// tabular* takes a {width} before its column spec (latex.ltx:12092) and is then +// tabular. Undefined, the width and the spec were typeset as running text and the +// body set as a paragraph: one IEEEtran paper used it ten times. +func TestTabularStarTakesItsWidthAndSetsATable(t *testing.T) { + e := New() + if err := e.LoadLaTeX(); err != nil { + t.Fatal(err) + } + e.SetFont(spMock{}) + src := `\begin{tabular*}{200pt}{@{\extracolsep{\fill}}lr}` + + `un & deux \\ trois & quatre \\` + + `\end{tabular*}\par` + if _, err := e.Run(src); err != nil { + t.Fatal(err) + } + txt := mvlText(e.mvl) + for _, leak := range []string{"200pt", "extracolsep", "lr"} { + if strings.Contains(txt, leak) { + t.Errorf("%q leaked into the text: %q", leak, txt) + } + } + var box *boxNode // packed to the requested width, so an hbox around the table vbox + for _, n := range e.mvl { + if b, ok := n.(*boxNode); ok { + box = b + } + } + if box == nil { + t.Fatal("no table box was placed") + } + if box.width < ptToSP(200) { + t.Errorf("table width %d, want at least the requested 200pt (%d)", box.width, ptToSP(200)) + } +}