commit 17219495f34a017ba2918517de54d8c7bf15a888 from: ale date: Sun Aug 30 01:07:22 2026 UTC Fit stacked media to the viewport; warm the LaTeX cache mediaBudget() splits a slide's usable height across every media item it carries (each image and each $$…$$ block is a real graphic), so slides with several stacked figures/equations no longer overflow the viewport and get their last figure clipped off the bottom. Recomputed on every WindowSizeMsg, so it tracks Kitty font-size / zoom live. Replaces the fixed reservedRows=10 guess. latex.Warm() pre-renders every unique display-math block in the deck concurrently in the background from Load(), so navigating to a slide with equations is a cache hit instead of tectonic's cold start one block at a time. loadImagesCmd rasterizes a slide's blocks in parallel too. dpi 200 (down from 300) roughly halves Ghostscript's work; the PNG is downscaled to a few cells regardless, with TextAlphaBits=4 keeping edges smooth. commit - fc21959a0c4481b5f9725702e9e24df6458a7c46 commit + 17219495f34a017ba2918517de54d8c7bf15a888 blob - d77eeaf7b476355bf3d411bf0535d539e7be97b4 blob + af9ce89b658e1a7765277f564c2be8a9c8682758 --- internal/latex/render.go +++ internal/latex/render.go @@ -10,10 +10,12 @@ import ( "strings" ) -// dpi is a fixed rendering resolution; there's no cheap way to query the -// terminal's real cell pixel size without an extra async round-trip (same -// simplification internal/image makes for its own sizing). -const dpi = 300 +// dpi is a fixed rasterization resolution. The PNG is always downscaled to +// a handful of terminal cells by internal/image, so 200 is visually +// indistinguishable from 300 on screen while roughly halving Ghostscript's +// work and the PNG's decode/transmit cost -- the first run-through of a +// deck with many equations is noticeably quicker. +const dpi = 200 // preview, not the default box-register backend, is required: standalone's // default backend fails to detect an equation* (or \[...\]) as the @@ -88,6 +90,7 @@ func Render(source string, dark bool) (string, error) png := filepath.Join(tmp, "out.png") gs := exec.Command("gs", "-dSAFER", "-dBATCH", "-dNOPAUSE", "-dQUIET", + "-dTextAlphaBits=4", "-dGraphicsAlphaBits=4", "-sDEVICE=pngalpha", fmt.Sprintf("-r%d", dpi), "-sOutputFile="+png, pdf, ) blob - /dev/null blob + 44d6fe682893773f3b4758f6f5f7460a2545dc2a (mode 644) --- /dev/null +++ internal/latex/warm.go @@ -0,0 +1,54 @@ +package latex + +import ( + "runtime" + "sync" +) + +// Warm pre-renders every unique $$…$$ block across slides, concurrently, +// populating the on-disk PNG cache so the first navigation to a slide with +// equations is instant instead of paying tectonic's cold start. Best +// effort: individual render failures are ignored, and a block whose PNG +// already exists is skipped cheaply by Render itself. +func Warm(slides []string, dark bool) { + seen := make(map[string]struct{}) + var sources []string + for _, s := range slides { + blocks, _ := ParseDisplay(s) + for _, b := range blocks { + if _, ok := seen[b.Source]; ok { + continue + } + seen[b.Source] = struct{}{} + sources = append(sources, b.Source) + } + } + if len(sources) == 0 { + return + } + + workers := runtime.NumCPU() + if workers > 4 { + workers = 4 + } + if workers < 1 { + workers = 1 + } + + ch := make(chan string) + var wg sync.WaitGroup + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for src := range ch { + _, _ = Render(src, dark) + } + }() + } + for _, src := range sources { + ch <- src + } + close(ch) + wg.Wait() +} blob - 2e01e0a3fa3793d83c1b18981913c91b5f1248aa blob + dad863b30ac32f2fd1a3392e2953e7f5b9cf1aaa --- internal/model/model.go +++ internal/model/model.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/atotto/clipboard" @@ -153,19 +154,44 @@ func (m *Model) leftMargin() int { return margin } -// reservedRows is a fixed allowance for whatever text shares the slide -// with a figure -- title, intro sentence, caption, status bar. Fixed -// rather than a fraction of the viewport, since a percentage stops -// leaving enough room for fixed-size text once the viewport gets small -// (e.g. after zooming in). -const reservedRows = 10 - -// mediaSize returns the column/row budget for a single image or LaTeX -// block: images can use the entire content width, while their row budget -// leaves reservedRows for whatever else is on the slide. +// mediaSize returns the column/row budget for one media item on the +// current slide. See mediaBudget. func (m *Model) mediaSize() (maxCols, maxRows int) { + slide := "" + if m.Page >= 0 && m.Page < len(m.Slides) { + slide = m.Slides[m.Page] + } + return m.mediaBudget(slide) +} + +// mediaBudget splits the slide's usable height across every media item it +// carries -- each ![](…) image and each $$…$$ block is a real graphic -- +// so several stacked figures/equations all fit inside the viewport +// instead of the last one being clipped off the bottom. It is recomputed +// on every resize (WindowSizeMsg), so the budget tracks Kitty's +// font-size / zoom changes live. +func (m *Model) mediaBudget(slide string) (maxCols, maxRows int) { contentWidth, contentHeight := m.contentSize() - maxRows = contentHeight - reservedRows + + n := len(image.Parse(slide)) + if blocks, _ := latex.ParseDisplay(slide); len(blocks) > 0 { + n += len(blocks) + } + if n < 1 { + n = 1 + } + + // Fixed allowance for text sharing the slide (title, intro line, + // status bar) plus, per media item, its caption and a blank line of + // separation. Fixed rather than a fraction of the viewport, so it + // still leaves room for fixed-size text once the viewport gets small + // after zooming in. + reserved := 6 + 2*n + budget := contentHeight - reserved + if budget < 3*n { + budget = 3 * n + } + maxRows = budget / n if maxRows < 3 { maxRows = 3 } @@ -233,12 +259,26 @@ func loadImagesCmd(images *image.Cache, baseDir, slide seqs = append(seqs, seq) } } - for _, b := range display { - renderedPath, err := latex.Render(b.Source, darkBG) - if err != nil { + // Rasterize this slide's $$…$$ blocks concurrently -- tectonic's + // cold start dominates, so a slide with two or three equations + // would otherwise stall on them one after another. + rendered := make([]string, len(display)) + var wg sync.WaitGroup + for i, b := range display { + wg.Add(1) + go func(i int, src string) { + defer wg.Done() + if p, err := latex.Render(src, darkBG); err == nil { + rendered[i] = p + } + }(i, b.Source) + } + wg.Wait() + for _, p := range rendered { + if p == "" { continue } - if seq, ok := loadPathSeq(images, renderedPath, maxCols, maxRows); ok { + if seq, ok := loadPathSeq(images, p, maxCols, maxRows); ok { seqs = append(seqs, seq) } } @@ -347,6 +387,13 @@ func (m *Model) Load() error { } } + // Pre-render every $$…$$ block in the deck, in parallel, in the + // background: navigating to a slide with equations then costs a cache + // hit instead of tectonic's cold start one block at a time. A copy of + // the slice is passed so a concurrent file-watch reload can't race the + // warmer. + go latex.Warm(append([]string(nil), m.Slides...), m.darkBG) + return nil }