From 53baa0f66314a8cbd23e831a9973feff8d26035b Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 10:41:17 +0200 Subject: [PATCH] =?UTF-8?q?engine:=20make=20the=20font-size=20commands=20\?= =?UTF-8?q?tiny=E2=80=A6\Huge=20functional=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A size clo (size10/size11/size12.clo) redefines \normalsize, \small, …, \Huge as \@setfontsize\\{}, and the engine's \@setfontsize is a no-op gobble — so after the class loaded, EVERY size command was dead. A heading set with \Large, a caption with \small, a footnote with \footnotesize all came out at the body size, with the body leading: wrong glyph size AND wrong vertical space on essentially every document (proven: at 10/11/12pt every command sat at the base size with the base \baselineskip). Revive them the way #75 handled \normalsize — through the FONT SYSTEM, not by making \@setfontsize a live primitive. At \begin{document} (\AtBeginDocument), \gotex@sizeclosetup reads the (size, leading) the ACTIVE clo baked into each command's body and redefines the command as a \protected macro \gotexsize\relax\gotexleadingsp\relax where \gotexsize scales the class base font (as \large already did) to the clo's absolute point size, \gotexleading sets the matching \baselineskip (a new primitive, group-scoped via a new save-stack entry), and the clo's own tail — the \abovedisplayskip / \belowdisplayskip glue and \@listi list parameters that \small/\footnotesize carry — is preserved, since the dead gobble left it running. Reading the values off the active clo gives each class its own table, so \Large in a 12pt document is the 12pt clo's 17pt on 22pt leading, not the 10pt one. Marking the macro \protected is what keeps it robust in a moving context — a section title flowing into the ToC, a caption into the LoF — exactly the failure mode #75 avoided by refusing to make \@setfontsize functional. \normalsize is deliberately left as the dead gobble: #75 owns the base size and leading, and \normalsize is called on nearly every reset, so hardcoding a \baselineskip into it would fight a document's own \linespread/setspace and densify everything. Per-command proof (glyph size / \baselineskip), matching the clo byte-for-byte: 10pt tiny 5/6 scriptsize 7/8 footnotesize 8/9.5 small 9/11 large 12/14 Large 14/18 LARGE 17/22 huge 20/25 Huge 25/30 11pt … small 10/12 large 12/14 Large 14/18 huge 20/25 Huge 25/30 12pt … small 11/13.6 large 14/18 Large 17/22 LARGE 20/25 huge/Huge 25/30 Layout impact is MIXED, not a clear win. On the -layout harness (40 papers, seed 1, tectonic ground truth) the mean divergence moves 7.760 -> 8.377 and the total page-count error 123 -> 133 — a net REGRESSION driven almost entirely by ONE algorithm2e paper (2508.20008, whose \SetCommentSty is only partly supported): excluding it the mean improves 7.929 -> 7.879. Heading-heavy papers gain (one 13.76 -> 6.29), while caption/footnote/small-heavy papers lose a little, because the engine already packs denser than tectonic and correct sizing amplifies that pre-existing line-break/page-build divergence. This lands the correctness fix; the layout metric will reward it once dense-region breaking matches tectonic. Co-Authored-By: Claude Opus 4.8 --- amssubstrate.go | 8 ++ engine.go | 4 +- font.go | 17 +++ fontsizeclo.go | 183 ++++++++++++++++++++++++++++++ fontsizeclo_test.go | 269 ++++++++++++++++++++++++++++++++++++++++++++ primitives.go | 4 +- 6 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 fontsizeclo.go create mode 100644 fontsizeclo_test.go diff --git a/amssubstrate.go b/amssubstrate.go index 304d322..e9c953d 100644 --- a/amssubstrate.go +++ b/amssubstrate.go @@ -235,7 +235,15 @@ const AMSClassSubstrate = ` \def\f@series{m} \def\f@family{} \def\fontsize#1#2{} +% \@setfontsize gobbles: a size clo redefines \normalsize/\small/…/\Huge as +% \@setfontsize\\{}, and executing that primitive inside a +% \normalsize expanded in a moving/\edef context (the size clo's \MakeRobust) would +% corrupt argument scanning and swallow the document. The clo's (size,leading) +% still reach the glyphs — \gotex@sizeclosetup reads them off each redefined +% command at \begin{document} and rewires the command through the font system +% (\gotexsize + \gotexleading), robust because that macro is \protected. \def\@setfontsize#1#2#3{} +\AtBeginDocument{\gotex@sizeclosetup} \def\fontencoding#1{} \def\fontfamily#1{} \def\fontseries#1{} diff --git a/engine.go b/engine.go index 963d18c..ed79bec 100644 --- a/engine.go +++ b/engine.go @@ -368,7 +368,7 @@ type meaning struct { } type saveItem struct { - kind int // 0=eqtb 1=count 2=catcode 3=dimen 4=skip 5=font 6=leftskip 7=rightskip 10=box + kind int // 0=eqtb 1=count 2=catcode 3=dimen 4=skip 5=font 6=leftskip 7=rightskip 10=box 11=baselineskip name string old *meaning idx int @@ -993,6 +993,8 @@ func (e *Engine) endGroup() { e.everypar = s.oldtoks case 10: e.box[s.idx] = s.oldbox + case 11: + e.baselineskip = s.oldi } } // \aftergroup's tokens are put back once the group is closed and its values diff --git a/font.go b/font.go index e9111e0..28b8156 100644 --- a/font.go +++ b/font.go @@ -177,6 +177,23 @@ func (e *Engine) doFontSize() { e.selectFont(sf.atSizePx(px)) } +// doLeading implements the \gotexleading primitive: it reads a dimension and sets +// \baselineskip to it, saving the old value so the change is scoped to the current +// group — exactly as \selectfont scopes a font change. The size commands +// (\Large, \small, …) pair it with \gotexsize so a heading's larger baseline glue +// and a footnote's smaller one follow the font, the way a size clo's +// \@setfontsize\\{} intends. Driving the leading through the +// font system (not through \@setfontsize's tokens) keeps the size commands robust +// in a moving context. With no group open the assignment is global, like a +// dimension register set at the outer level. +func (e *Engine) doLeading() { + d := e.scanDimen() + if len(e.groups) > 0 { + e.save = append(e.save, saveItem{kind: 11, oldi: e.baselineskip}) + } + e.baselineskip = d +} + // scaleClassFontsToBase re-faces every bound TEXT face — the \normalsize base // (which \selectfont/\normalfont/\rmfamily reselect and the drivers render from) // and the roman/bold/italic/typewriter/sans bindings — to the class's base point diff --git a/fontsizeclo.go b/fontsizeclo.go new file mode 100644 index 0000000..c1fd0a5 --- /dev/null +++ b/fontsizeclo.go @@ -0,0 +1,183 @@ +// Copyright (c) the go-tex/engine authors. +// SPDX-License-Identifier: BSD-3-Clause + +package engine + +import ( + "strconv" + "strings" +) + +// sizeCloCommands are the font-size switches a size.clo (re)defines through +// \@setfontsize. In this engine \@setfontsize gobbles, so the clo leaves each one +// a no-op: a heading, caption or footnote set with \Large/\small/… came out at the +// body size with the body leading, losing the vertical space real LaTeX gives it. +// rewireSizeCommands turns each back into a working font+leading switch. +// +// \normalsize is deliberately NOT rewired. #75 already installs the class base size +// and its \baselineskip, and \normalsize is called on nearly every size reset — so +// hardcoding a \baselineskip into it would overwrite a document's own line spacing +// (\linespread, setspace) every time and densify the whole document. Left as the +// dead \@setfontsize gobble, \normalsize keeps executing its display-skip tail and +// leaves #75's base leading in force, which is what body text wants. +var sizeCloCommands = []string{ + "tiny", "scriptsize", "footnotesize", "small", + "large", "Large", "LARGE", "huge", "Huge", +} + +// rewireSizeCommands revives the size clo's \tiny…\Huge (\normalsize is left to +// #75, see the sizeCloCommands note). It runs +// once at \begin{document} (\AtBeginDocument, see amssubstrate.go), after the +// class, the size clo and any preamble redefinition have settled. +// +// For each command it reads the (size, leading) the ACTIVE clo baked into the +// command's body — \@setfontsize\\{} — so an [11pt]/[12pt] class +// gets its own larger clo table, not a hardcoded 10pt one. It then redefines the +// command as a \protected macro +// +// \gotexsize\relax\gotexleadingpt\relax +// +// where scales the class base font (the mechanism \large already used, +// see doFontSize) to the clo's absolute point size, and \gotexleading sets the +// matching \baselineskip (doLeading). Because the base font is itself the class +// base size (#75), scaling by permille = round(size*1000/base) lands exactly on the +// clo's size at every base. +// +// Making the macro \protected is what keeps it robust in a moving context — a +// section title flowing into the ToC, a caption into the LoF: like a real +// \DeclareRobustCommand it stays one token through an \edef instead of executing +// its font primitives mid-scan. That is the failure mode #75 avoided by refusing to +// make \@setfontsize itself functional; the same reasoning drives the leading +// through the font system rather than through the clo's tokens. +// +// A command whose body is not a clo \@setfontsize switch (no clo loaded, or a class +// that sizes another way) is left untouched. +func (e *Engine) rewireSizeCommands() { + base := e.baseFontPx + if base <= 0 { + base = 10 + } + for _, name := range sizeCloCommands { + m := e.eq[name] + if m == nil || m.kind != mMacro { + continue + } + sizePt, leadPt, end, ok := e.parseSetfontsize(m.body) + if !ok { + continue + } + px := int(sizePt + 0.5) + if px < 1 { + px = 1 + } + permille := (px*1000 + base/2) / base + // Build the body as real tokens: \gotexsize\relax scales the base + // font and \gotexleadingsp\relax sets \baselineskip. The leading is + // emitted in scaled points (an exact integer) so no decimal formatting can + // drift it. stringToToks is deliberately NOT used — it renders a control word + // as its literal characters, not a control sequence. + body := []tok{csTok("gotexsize")} + body = append(body, digitToks(permille)...) + body = append(body, csTok("relax"), csTok("gotexleading")) + body = append(body, digitToks(ptToSP(leadPt))...) + body = append(body, chTok('s', catLetter), chTok('p', catLetter), csTok("relax")) + // Keep everything the clo put AFTER \@setfontsize — the \abovedisplayskip / + // \belowdisplayskip glue and the \@listi list parameters that \small and + // \footnotesize carry. The dead \@setfontsize gobble left this tail running, + // so dropping it would silently change the spacing around display math and + // lists across the whole document. + body = append(body, m.body[end:]...) + e.define(name, &meaning{kind: mMacro, body: body, protected: true}, true) + } +} + +// parseSetfontsize reads a size clo's \@setfontsize\\{} switch +// from the front of a macro body and returns the size and leading in points and the +// index just past the switch (where the clo's display-skip / list tail begins). The +// first token must be \@setfontsize; the three arguments after it are read the way +// TeX reads undelimited arguments (a braced group or a single token), the first — +// the command itself — discarded. A body not beginning with \@setfontsize is not a +// clo size switch: ok is false. +func (e *Engine) parseSetfontsize(body []tok) (sizePt, leadPt float64, end int, ok bool) { + i := skipTokSpace(body, 0) + if i >= len(body) || !body[i].cs_ || body[i].cs != "@setfontsize" { + return 0, 0, 0, false + } + i++ + _, i = grabTokArg(body, i) // arg1: the command itself, discarded + a2, i := grabTokArg(body, i) // arg2: the size (a \@NNpt macro or a number) + a3, i := grabTokArg(body, i) // arg3: the leading ({14}, {9.5} or a \@NNpt macro) + sizePt, ok1 := e.evalNumToks(a2) + leadPt, ok2 := e.evalNumToks(a3) + return sizePt, leadPt, i, ok1 && ok2 +} + +// skipTokSpace advances past space tokens in a stored token slice. +func skipTokSpace(body []tok, i int) int { + for i < len(body) && !body[i].cs_ && body[i].cat == catSpace { + i++ + } + return i +} + +// grabTokArg reads one undelimited argument from a stored token slice starting at +// i, mirroring TeX: leading spaces are skipped, a { … } yields the balanced inner +// tokens, anything else yields the single next token. It returns the argument and +// the index just past it. +func grabTokArg(body []tok, i int) ([]tok, int) { + i = skipTokSpace(body, i) + if i >= len(body) { + return nil, i + } + if !body[i].cs_ && body[i].cat == catBegin { + depth := 1 + i++ + start := i + for i < len(body) { + if !body[i].cs_ { + if body[i].cat == catBegin { + depth++ + } else if body[i].cat == catEnd { + if depth--; depth == 0 { + return body[start:i], i + 1 + } + } + } + i++ + } + return body[start:i], i // unbalanced: take what there is + } + return body[i : i+1], i + 1 +} + +// evalNumToks reads a decimal number from tokens that are either a run of digit and +// '.' characters ({14}, {9.5}) or a single font-size macro that expands to such a +// run (\@xivpt → 14). It follows one level of macro indirection, which is all a +// size clo uses. +func (e *Engine) evalNumToks(ts []tok) (float64, bool) { + if len(ts) == 1 && ts[0].cs_ { + m := e.eq[ts[0].cs] + if m == nil || m.kind != mMacro { + return 0, false + } + return e.evalNumToks(m.body) + } + var b strings.Builder + for _, t := range ts { + switch { + case t.cs_: + return 0, false + case (t.ch >= '0' && t.ch <= '9') || t.ch == '.': + b.WriteRune(t.ch) + case t.cat == catSpace: + // skip + default: + return 0, false + } + } + f, err := strconv.ParseFloat(b.String(), 64) + if err != nil { + return 0, false + } + return f, true +} diff --git a/fontsizeclo_test.go b/fontsizeclo_test.go new file mode 100644 index 0000000..ccef33f --- /dev/null +++ b/fontsizeclo_test.go @@ -0,0 +1,269 @@ +package engine + +import "testing" + +// glyphSizePt returns the point size recorded on the first charNode for rune ch in +// the engine's main vertical list (0 if none) — the size at which that glyph is set. +func glyphSizePt(e *Engine, ch rune) int { + var found int + var walk func([]node) + walk = func(ns []node) { + for _, n := range ns { + if found != 0 { + return + } + switch c := n.(type) { + case charNode: + if c.ch == ch { + found = c.size + } + case *boxNode: + walk(c.list) + } + } + } + walk(e.mvl) + return found +} + +// The size commands \tiny…\Huge (and \normalsize) each set their clo point size and +// leading, and the ACTIVE size clo — size10/size11/size12.clo — decides the table, +// so an [11pt]/[12pt] class gets its own larger sizes. A \Large in a 12pt document +// is the 12pt clo's \Large (17pt on 22pt leading), not the 10pt one. Before this +// fix every command was a no-op: the clo defines each via \@setfontsize, which the +// engine gobbles, so headings/captions/footnotes all set at the body size. +func TestSizeCloCommands(t *testing.T) { + type sl struct { + cmd string + size int + leadPt float64 + } + for _, base := range []struct { + opt string + table []sl + }{ + {"10pt", []sl{ + {"tiny", 5, 6}, {"scriptsize", 7, 8}, {"footnotesize", 8, 9.5}, {"small", 9, 11}, + {"normalsize", 10, 12}, {"large", 12, 14}, {"Large", 14, 18}, {"LARGE", 17, 22}, + {"huge", 20, 25}, {"Huge", 25, 30}, + }}, + {"11pt", []sl{ + {"tiny", 6, 7}, {"scriptsize", 8, 9.5}, {"footnotesize", 9, 11}, {"small", 10, 12}, + {"normalsize", 11, 13.6}, {"large", 12, 14}, {"Large", 14, 18}, {"LARGE", 17, 22}, + {"huge", 20, 25}, {"Huge", 25, 30}, + }}, + {"12pt", []sl{ + {"tiny", 6, 7}, {"scriptsize", 8, 9.5}, {"footnotesize", 10, 12}, {"small", 11, 13.6}, + {"normalsize", 12, 14.5}, {"large", 14, 18}, {"Large", 17, 22}, {"LARGE", 20, 25}, + {"huge", 25, 30}, {"Huge", 25, 30}, + }}, + } { + for _, tc := range base.table { + src := `\documentclass[` + base.opt + `]{article}\begin{document}\` + tc.cmd + ` Q\end{document}` + e, err := compile([]byte(src), Options{}) + if err != nil { + t.Fatalf("[%s] \\%s: %v", base.opt, tc.cmd, err) + } + if got := glyphSizePt(e, 'Q'); got != tc.size { + t.Errorf("[%s] \\%s glyph = %dpt, want %dpt", base.opt, tc.cmd, got, tc.size) + } + if want := ptToSP(tc.leadPt); e.baselineskip != want { + t.Errorf("[%s] \\%s \\baselineskip = %d sp, want %d sp (%.1fpt)", + base.opt, tc.cmd, e.baselineskip, want, tc.leadPt) + } + } + } +} + +// A size change inside a group is undone when the group closes: {\footnotesize …} +// and {\Large …} each restore both the font and \baselineskip, so the text after +// the group is body text again. (\normalsize is not rewired — resetting is done by +// grouping, which is how the size commands are meant to be scoped.) +func TestSizeCloGrouping(t *testing.T) { + e, err := compile([]byte(`\documentclass[10pt]{article}\begin{document}`+ + `A {\footnotesize f} B {\Large g} C\end{document}`), Options{}) + if err != nil { + t.Fatal(err) + } + if got := glyphSizePt(e, 'A'); got != 10 { + t.Errorf("body A = %dpt, want 10", got) + } + if got := glyphSizePt(e, 'f'); got != 8 { + t.Errorf("footnotesize f = %dpt, want 8", got) + } + if got := glyphSizePt(e, 'B'); got != 10 { + t.Errorf("B after grouped footnotesize = %dpt, want 10 (restored)", got) + } + if got := glyphSizePt(e, 'g'); got != 14 { + t.Errorf("grouped \\Large g = %dpt, want 14", got) + } + if got := glyphSizePt(e, 'C'); got != 10 { + t.Errorf("C after grouped \\Large = %dpt, want 10 (restored)", got) + } + if e.baselineskip != 12*unity { + t.Errorf("final \\baselineskip = %d sp, want %d (restored)", e.baselineskip, 12*unity) + } +} + +// A section heading is set with \Large (its clo size), which is the point of the +// fix: the heading now occupies its true, larger vertical space. +func TestSizeCloSectionHeading(t *testing.T) { + e, err := compile([]byte(`\documentclass[10pt]{article}\begin{document}`+ + `\section{Zoo}Body text long enough to wrap onto a line.\end{document}`), Options{}) + if err != nil { + t.Fatal(err) + } + if got := glyphSizePt(e, 'Z'); got != 14 { + t.Errorf("\\section heading Z = %dpt, want 14 (\\Large)", got) + } +} + +// A \protected size command survives a moving context (a section title flowing into +// the table of contents) without corrupting the scan: the document compiles in full +// rather than being truncated, which is why the command is driven through the font +// system and marked \protected rather than making \@setfontsize a live primitive. +func TestSizeCloMovingContext(t *testing.T) { + e, err := compile([]byte(`\documentclass[10pt]{article}\begin{document}`+ + `\tableofcontents\section{Alpha}First body paragraph with several words to set.`+ + `\section{Beta}Second body paragraph, also with enough words to wrap here.\end{document}`), Options{}) + if err != nil { + t.Fatalf("section+toc compile failed: %v", err) + } + // The bodies must reach the page — a corrupted scan would swallow them. + for _, ch := range []rune{'F', 'S'} { + if glyphSizePt(e, ch) == 0 { + t.Errorf("body starting %q missing — moving context truncated the document", ch) + } + } +} + +// Without a size clo (no \documentclass loading one), the kernel's own \large etc. +// are not \@setfontsize switches, so rewireSizeCommands leaves them untouched. +func TestSizeCloUntouchedWithoutClo(t *testing.T) { + e := New() + if err := e.LoadLaTeX(); err != nil { + t.Fatal(err) + } + before := e.eq["Large"] + e.rewireSizeCommands() + if e.eq["Large"] != before { + t.Errorf("\\Large redefined though its body is not a clo \\@setfontsize switch") + } +} + +// rewireSizeCommands skips a size name that is absent or not a macro, and clamps a +// sub-1pt clo size to at least 1pt. +func TestRewireSizeCommandsEdges(t *testing.T) { + e := New() + e.LoadLaTeX() + e.SetFont(scaleMock{px: 10}) // base 10pt, scalable + // tiny is not a macro (a primitive here) — left untouched. + e.eq["tiny"] = &meaning{kind: mPrim, name: "tiny"} + // small is absent — skipped, not defined. + delete(e.eq, "small") + // large carries a sub-1pt size {0.4} → the px rounds to 0 and is clamped to 1pt. + e.eq["large"] = &meaning{kind: mMacro, body: []tok{csTok("@setfontsize"), csTok("large"), + chTok('{', catBegin), chTok('0', catOther), chTok('.', catOther), chTok('4', catOther), chTok('}', catEnd), + chTok('{', catBegin), chTok('6', catOther), chTok('}', catEnd)}} + e.rewireSizeCommands() + if e.eq["tiny"].kind != mPrim { + t.Errorf("non-macro \\tiny was rewired") + } + if e.eq["small"] != nil { + t.Errorf("absent \\small was defined") + } + if _, err := e.Run(`\large Q`); err != nil { + t.Fatal(err) + } + if e.curFont.sizePt() != 1 { + t.Errorf("sub-1pt \\large clamped to %dpt, want 1", e.curFont.sizePt()) + } +} + +// parseSetfontsize reads the (size, leading) a clo baked into a size command; the +// size and leading may each be a braced number or a \@NNpt macro, and a body that +// does not begin with \@setfontsize is rejected. +func TestParseSetfontsize(t *testing.T) { + e := New() + e.LoadLaTeX() + // \@setfontsize\Large\@xivpt{18} + body := []tok{csTok("@setfontsize"), csTok("Large"), csTok("@xivpt"), + chTok('{', catBegin), chTok('1', catOther), chTok('8', catOther), chTok('}', catEnd)} + if s, l, _, ok := e.parseSetfontsize(body); !ok || s != 14 || l != 18 { + t.Errorf("size/leading = %v/%v ok=%v, want 14/18 true", s, l, ok) + } + // leading as a macro: \@setfontsize\tiny\@vpt\@vipt + body2 := []tok{csTok("@setfontsize"), csTok("tiny"), csTok("@vpt"), csTok("@vipt")} + if s, l, _, ok := e.parseSetfontsize(body2); !ok || s != 5 || l != 6 { + t.Errorf("macro leading: size/leading = %v/%v ok=%v, want 5/6 true", s, l, ok) + } + // decimal braced leading: {9.5} + body3 := []tok{csTok("@setfontsize"), csTok("footnotesize"), csTok("@viiipt"), + chTok('{', catBegin), chTok('9', catOther), chTok('.', catOther), chTok('5', catOther), chTok('}', catEnd)} + if _, l, _, ok := e.parseSetfontsize(body3); !ok || l != 9.5 { + t.Errorf("decimal leading = %v ok=%v, want 9.5 true", l, ok) + } + // not a \@setfontsize body + if _, _, _, ok := e.parseSetfontsize([]tok{csTok("gotexsize"), chTok('1', catOther)}); ok { + t.Errorf("non-\\@setfontsize body accepted") + } + // empty body + if _, _, _, ok := e.parseSetfontsize(nil); ok { + t.Errorf("empty body accepted") + } + // unresolvable size macro + bad := []tok{csTok("@setfontsize"), csTok("x"), csTok("@notdefined"), chTok('{', catBegin), chTok('1', catOther), chTok('}', catEnd)} + if _, _, _, ok := e.parseSetfontsize(bad); ok { + t.Errorf("undefined size macro accepted") + } +} + +// evalNumToks reads a number from a digit run, a decimal, or one macro level; it +// rejects a control sequence in a multi-token run and a non-numeric body. +func TestEvalNumToks(t *testing.T) { + e := New() + e.LoadLaTeX() + if v, ok := e.evalNumToks([]tok{chTok('1', catOther), chTok('4', catOther)}); !ok || v != 14 { + t.Errorf("14 -> %v %v", v, ok) + } + if v, ok := e.evalNumToks([]tok{csTok("@xivpt")}); !ok || v != 14 { + t.Errorf("\\@xivpt -> %v %v", v, ok) + } + if _, ok := e.evalNumToks([]tok{chTok('1', catOther), csTok("relax")}); ok { + t.Errorf("digit+cs run accepted") + } + if _, ok := e.evalNumToks([]tok{chTok('x', catLetter)}); ok { + t.Errorf("non-numeric accepted") + } + if _, ok := e.evalNumToks([]tok{csTok("relax")}); ok { + t.Errorf("non-macro single cs accepted") + } + // digit/'.' characters that do not form a number ("..") fail to parse. + if _, ok := e.evalNumToks([]tok{chTok('.', catOther), chTok('.', catOther)}); ok { + t.Errorf("unparseable \"..\" accepted") + } +} + +// grabTokArg reads one undelimited argument from a token slice: a single token, a +// braced group (balanced), or an unbalanced tail; leading spaces are skipped. +func TestGrabTokArg(t *testing.T) { + body := []tok{chTok(' ', catSpace), csTok("a"), + chTok('{', catBegin), chTok('x', catLetter), chTok('{', catBegin), chTok('y', catLetter), chTok('}', catEnd), chTok('}', catEnd)} + a1, i := grabTokArg(body, 0) + if len(a1) != 1 || !a1[0].cs_ || a1[0].cs != "a" { + t.Errorf("arg1 = %v, want [\\a]", a1) + } + a2, _ := grabTokArg(body, i) + if len(a2) != 4 { // x { y } + t.Errorf("arg2 len = %d, want 4 (balanced inner group)", len(a2)) + } + // past the end + if a, j := grabTokArg(body, len(body)); a != nil || j != len(body) { + t.Errorf("past-end grab returned %v %d", a, j) + } + // unbalanced group runs to the end + ub := []tok{chTok('{', catBegin), chTok('z', catLetter)} + if a, _ := grabTokArg(ub, 0); len(a) != 1 || a[0].ch != 'z' { + t.Errorf("unbalanced grab = %v, want [z]", a) + } +} diff --git a/primitives.go b/primitives.go index 724c790..3449e79 100644 --- a/primitives.go +++ b/primitives.go @@ -1608,7 +1608,9 @@ func (e *Engine) loadMore() { e.prim("hypertarget", func(e *Engine) { e.doHypertarget() }) // hyperref: named in-document destination e.prim("hyperlink", func(e *Engine) { e.doHyperlink() }) // hyperref: same-document link to a target e.prim("footnote", func(e *Engine) { e.doFootnote() }) - e.prim("gotexsize", func(e *Engine) { e.doFontSize() }) // \gotexsize: scale the base font + e.prim("gotexsize", func(e *Engine) { e.doFontSize() }) // \gotexsize: scale the base font + e.prim("gotexleading", func(e *Engine) { e.doLeading() }) // \gotexleading: set \baselineskip, group-scoped + e.prim("gotex@sizeclosetup", func(e *Engine) { e.rewireSizeCommands() }) // \begin{document}: revive the size clo's \tiny…\Huge e.prim("includegraphics", func(e *Engine) { e.doIncludegraphics() }) e.prim("graphicspath", func(e *Engine) { e.grabUndelimited() }) // {dir} search path — accepted, not modelled // BibTeX bibliography (see bibtex.go): \nocite records keys, \citep/\citet are