Commit Diff


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
 }