commit - fc21959a0c4481b5f9725702e9e24df6458a7c46
commit + 17219495f34a017ba2918517de54d8c7bf15a888
blob - d77eeaf7b476355bf3d411bf0535d539e7be97b4
blob + af9ce89b658e1a7765277f564c2be8a9c8682758
--- internal/latex/render.go
+++ internal/latex/render.go
"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
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
+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
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"github.com/atotto/clipboard"
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
}
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)
}
}
}
}
+ // 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
}