From c64495b3916a83876b36cb036c6b0dca480a2631 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Mon, 26 Oct 2026 14:25:12 -0400 Subject: [PATCH 01/10] Feat(config): [terminal] math, render_timeout and render_concurrency The reading view renders display math only through typst, and passes the block to typst verbatim, so real LaTeX such as \frac{a}{b} falls back to source. This adds the settings the LaTeX engine builds on: math picks typst (the default) or latex, render_timeout bounds one external render in seconds (default 10), and render_concurrency caps how many run at once (default 2). A bad value never stops zn from starting: an unknown engine reads as typst and a non-positive number as the default. zn doctor reports it, and under math = "latex" it also names a missing pdftex or pdftocairo. Saving a setting stays strict, the way images already is. The starter config.toml carries the keys with a comment naming the tools, and the settings screen offers typst or latex. Co-Authored-By: Claude Opus 5.5 --- internal/cli/commands_diagnostics.go | 13 ++ internal/cli/commands_diagnostics_test.go | 93 +++++++++++++ internal/config/dirs.go | 5 + internal/config/prefs.go | 33 ++++- internal/config/prefs_test.go | 157 ++++++++++++++++++++++ internal/config/prefs_validate.go | 16 ++- internal/config/prefs_values.go | 2 +- internal/config/prefs_write.go | 2 +- internal/config/prefs_write_test.go | 18 +++ internal/tui/settings_controls.go | 2 +- 10 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 internal/cli/commands_diagnostics_test.go diff --git a/internal/cli/commands_diagnostics.go b/internal/cli/commands_diagnostics.go index 8011bda..433e312 100644 --- a/internal/cli/commands_diagnostics.go +++ b/internal/cli/commands_diagnostics.go @@ -125,6 +125,19 @@ func cmdDoctor(ctx context.Context, args Args) error { checks = append(checks, diagnosticCheck{Name: name, OK: err == nil, Message: message}) } add("configuration", config.ConfigTomlPath(), config.ValidateConfig()) + if prefs, _, err := config.LoadPrefs(); err == nil && prefs.TerminalMath == "latex" { + missing := []string{} + for _, tool := range []struct{ name, from string }{{"pdftex", "TeX Live or MacTeX"}, {"pdftocairo", "poppler"}} { + if _, err := exec.LookPath(tool.name); err != nil { + missing = append(missing, tool.name+" (from "+tool.from+")") + } + } + var err error + if len(missing) > 0 { + err = fmt.Errorf("math = \"latex\" needs %s on PATH", strings.Join(missing, " and ")) + } + add("latex tools", "pdftex and pdftocairo are available", err) + } ws, wsErr := config.LoadWorkspacesFile() add("saved vaults", fmt.Sprintf("%d vaults and %d servers in %s", len(ws.Vaults), len(ws.Servers), config.WorkspacesPath()), wsErr) if installation, err := selfupdate.Current(); err == nil { diff --git a/internal/cli/commands_diagnostics_test.go b/internal/cli/commands_diagnostics_test.go new file mode 100644 index 0000000..5f69fdb --- /dev/null +++ b/internal/cli/commands_diagnostics_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +// doctorWithTools runs `zn doctor --json` with config.toml holding terminal +// under [terminal] (none when empty) and PATH holding only stubs of tools. +func doctorWithTools(t *testing.T, terminal string, tools ...string) (string, []diagnosticCheck) { + t.Helper() + isolatedCLI(t) + if terminal != "" { + if err := os.MkdirAll(filepath.Dir(config.ConfigTomlPath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.ConfigTomlPath(), []byte("[terminal]\n"+terminal+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + bin := t.TempDir() + for _, tool := range tools { + name := tool + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + out := captureOutput(t, func() { _ = Main([]string{"doctor", "--json"}) }) + var report struct { + Checks []diagnosticCheck `json:"checks"` + } + if err := json.Unmarshal([]byte(strings.SplitN(out, "\n{\"error\"", 2)[0]), &report); err != nil { + t.Fatalf("doctor json: %v %s", err, out) + } + return out, report.Checks +} + +func latexToolsCheck(t *testing.T, checks []diagnosticCheck, out string) diagnosticCheck { + t.Helper() + for _, c := range checks { + if c.Name == "latex tools" { + return c + } + } + t.Fatalf("no \"latex tools\" check under math = \"latex\":\n%s", out) + return diagnosticCheck{} +} + +// AC75: under latex, a missing pdftex is a failing check that names it. +func TestDoctorLatexReportsMissingPdftex(t *testing.T) { + out, checks := doctorWithTools(t, `math = "latex"`, "pdftocairo") + c := latexToolsCheck(t, checks, out) + if c.OK || !strings.Contains(c.Message, "pdftex") { + t.Fatalf("AC75: latex tools check %+v", c) + } + if strings.Contains(c.Message, "pdftocairo") { + t.Fatalf("AC75: pdftocairo is on PATH but reported missing: %+v", c) + } +} + +// AC76: under latex, a missing pdftocairo is a failing check that names it. +func TestDoctorLatexReportsMissingPdftocairo(t *testing.T) { + out, checks := doctorWithTools(t, `math = "latex"`, "pdftex") + c := latexToolsCheck(t, checks, out) + if c.OK || !strings.Contains(c.Message, "pdftocairo") { + t.Fatalf("AC76: latex tools check %+v", c) + } + if strings.Contains(strings.ReplaceAll(c.Message, "pdftocairo", ""), "pdftex") { + t.Fatalf("AC76: pdftex is on PATH but reported missing: %+v", c) + } +} + +// AC77: under typst, or with no math key, doctor never mentions the TeX tools. +func TestDoctorTypstNeverMentionsTexTools(t *testing.T) { + for _, terminal := range []string{`math = "typst"`, ``} { + out, _ := doctorWithTools(t, terminal) + for _, tool := range []string{"pdftex", "pdftocairo"} { + if strings.Contains(out, tool) { + t.Errorf("AC77 %q: doctor output mentions %s:\n%s", terminal, tool, out) + } + } + } +} diff --git a/internal/config/dirs.go b/internal/config/dirs.go index a2e7a73..b694d72 100644 --- a/internal/config/dirs.go +++ b/internal/config/dirs.go @@ -64,6 +64,11 @@ func ConfigTomlPath() string { return filepath.Join(PortableConfigDir(), "config.toml") } +// PreamblePath is the user-owned LaTeX preamble for math = "latex". +func PreamblePath() string { + return filepath.Join(PortableConfigDir(), "preamble.tex") +} + // CustomThemesDir is where the desktop app keeps user-authored themes, one // folder each, next to config.toml. func CustomThemesDir() string { diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 4a08a03..cd48217 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -77,6 +77,13 @@ type Prefs struct { // TerminalTheme names a theme for zn alone: a family, a theme id or a // custom theme. Empty follows the desktop's [appearance] selection. TerminalTheme string + // TerminalMath is typst or latex: the engine for display math. Anything + // else loads as typst; zn doctor reports it. + TerminalMath string + // RenderTimeout (seconds) bounds one external render; RenderConcurrency + // caps how many run at once. Non-positive values load as the defaults. + RenderTimeout int + RenderConcurrency int } // DefaultPrefs are the shipped defaults. @@ -107,6 +114,9 @@ func DefaultPrefs() Prefs { ThemeTweaks: map[string]string{}, PreviewStyle: "auto", TerminalMouse: true, + TerminalMath: "typst", + RenderTimeout: 10, + RenderConcurrency: 2, UnifiedSidebar: true, NoteSortOrder: "none", GroupByKind: true, @@ -141,7 +151,7 @@ func LoadPrefs() (Prefs, string, error) { if _, err := toml.Decode(string(raw), &doc); err != nil { return prefs, path, err } - if err := validateKnownValues(doc); err != nil { + if err := validateKnownValues(doc, false); err != nil { return prefs, path, err } applyPrefs(&prefs, doc) @@ -225,9 +235,23 @@ func applyPrefs(p *Prefs, doc map[string]any) { setString(terminal, "preview_style", &p.PreviewStyle) setString(terminal, "images", &p.TerminalImages) setString(terminal, "theme", &p.TerminalTheme) + setString(terminal, "math", &p.TerminalMath) + setInt(terminal, "render_timeout", &p.RenderTimeout) + setInt(terminal, "render_concurrency", &p.RenderConcurrency) if strings.TrimSpace(p.PreviewStyle) == "" { p.PreviewStyle = "auto" } + if strings.EqualFold(strings.TrimSpace(p.TerminalMath), "latex") { + p.TerminalMath = "latex" + } else { + p.TerminalMath = "typst" + } + if p.RenderTimeout <= 0 { + p.RenderTimeout = 10 + } + if p.RenderConcurrency <= 0 { + p.RenderConcurrency = 2 + } p.KeymapOverrides = mergeStringTable(p.KeymapOverrides, section("keymaps")) p.SystemFolderLabels = mergeStringTable(p.SystemFolderLabels, section("folder_labels")) @@ -343,6 +367,13 @@ mouse = true # A theme for zn alone: a family, a theme id or a custom theme. Empty follows # [appearance], so the terminal matches the desktop app. theme = "" +# math = typst | latex: the engine for display math in the preview. latex +# needs pdftex (TeX Live or MacTeX) and pdftocairo (poppler) on PATH, and +# reads its packages from preamble.tex next to this file. +math = "typst" +# Seconds one diagram or math render may take, and how many run at once. +render_timeout = 10 +render_concurrency = 2 [keymaps] # "action.id" = "keys" (an empty string unbinds an action) diff --git a/internal/config/prefs_test.go b/internal/config/prefs_test.go index 4bf51e0..bc4077c 100644 --- a/internal/config/prefs_test.go +++ b/internal/config/prefs_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -77,3 +78,159 @@ func TestPrefsReadTheTasksListOrder(t *testing.T) { } } } + +// writeTerminalConfig points ZENNOTES_CONFIG_DIR at a fresh dir whose +// config.toml holds body under [terminal]. +func writeTerminalConfig(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("[terminal]\n"+body+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// AC78: the starter file carries an active `math = "typst"` under [terminal] +// and a comment naming the tools latex needs. +func TestStarterConfigNamesMathAndTools(t *testing.T) { + section := DefaultConfigTOML + start := strings.Index(section, "[terminal]") + if start < 0 { + t.Fatal("starter config has no [terminal] table") + } + section = section[start+len("[terminal]"):] + if end := strings.Index(section, "\n["); end >= 0 { + section = section[:end] + } + active, comments := false, "" + for _, line := range strings.Split(section, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "#") { + comments += line + "\n" + continue + } + if strings.ReplaceAll(line, " ", "") == `math="typst"` { + active = true + } + } + if !active { + t.Fatalf("AC78: [terminal] has no active math = \"typst\" line:\n%s", section) + } + for _, want := range []string{"latex", "pdftex", "pdftocairo"} { + if !strings.Contains(comments, want) { + t.Errorf("AC78: [terminal] comments do not name %s:\n%s", want, comments) + } + } +} + +// AC1: no math key means typst. +func TestPrefsMathDefaultsToTypst(t *testing.T) { + if got := DefaultPrefs().TerminalMath; got != "typst" { + t.Fatalf("AC1 default: %q", got) + } + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + prefs, _, err := LoadPrefs() + if err != nil || prefs.TerminalMath != "typst" { + t.Fatalf("AC1 no file: math %q, err %v", prefs.TerminalMath, err) + } + writeTerminalConfig(t, `images = "auto"`) + prefs, _, err = LoadPrefs() + if err != nil || prefs.TerminalMath != "typst" { + t.Fatalf("AC1 no key: math %q, err %v", prefs.TerminalMath, err) + } + writeTerminalConfig(t, `math = "latex"`) + prefs, _, err = LoadPrefs() + if err != nil || prefs.TerminalMath != "latex" { + t.Fatalf("math = \"latex\" must load as latex: math %q, err %v", prefs.TerminalMath, err) + } +} + +// AC4: an unknown engine is not an error when zn loads; it behaves as typst. +func TestPrefsUnknownMathLoadsAsTypst(t *testing.T) { + writeTerminalConfig(t, `math = "foo"`) + prefs, _, err := LoadPrefs() + if err != nil { + t.Fatalf("AC4: LoadPrefs must not fail on math = \"foo\": %v", err) + } + if prefs.TerminalMath != "typst" { + t.Fatalf("AC4: math = \"foo\" loaded as %q, want typst", prefs.TerminalMath) + } +} + +// AC5: zn doctor's configuration check names the key and the allowed values. +func TestValidateConfigRejectsUnknownMath(t *testing.T) { + writeTerminalConfig(t, `math = "foo"`) + err := ValidateConfig() + if err == nil { + t.Fatal("AC5: ValidateConfig accepted math = \"foo\"") + } + for _, want := range []string{"terminal.math", "typst, latex"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("AC5: error %q does not contain %q", err, want) + } + } + for _, ok := range []string{`math = "typst"`, `math = "latex"`} { + writeTerminalConfig(t, ok) + if err := ValidateConfig(); err != nil { + t.Errorf("%s must validate: %v", ok, err) + } + } +} + +// AC37: no key, zero and negative all give a 10 second timeout. +func TestPrefsRenderTimeoutDefaultsAndNonPositive(t *testing.T) { + for _, body := range []string{``, `render_timeout = 0`, `render_timeout = -3`} { + writeTerminalConfig(t, body) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderTimeout != 10 { + t.Errorf("AC37 %q: timeout %v, err %v", body, prefs.RenderTimeout, err) + } + } + writeTerminalConfig(t, `render_timeout = 2`) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderTimeout != 2 { + t.Fatalf("render_timeout = 2: timeout %v, err %v", prefs.RenderTimeout, err) + } +} + +// AC38: a non-integer timeout is a wrong-type error in doctor; zn uses 10. +func TestPrefsRenderTimeoutWrongType(t *testing.T) { + writeTerminalConfig(t, `render_timeout = "ten"`) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderTimeout != 10 { + t.Fatalf("AC38 load: timeout %v, err %v", prefs.RenderTimeout, err) + } + err = ValidateConfig() + if err == nil || !strings.Contains(err.Error(), "terminal.render_timeout") || !strings.Contains(err.Error(), "wrong type") { + t.Fatalf("AC38 doctor: %v", err) + } +} + +// AC50: no key, zero and negative all give a cap of 2. +func TestPrefsRenderConcurrencyDefaultsAndNonPositive(t *testing.T) { + for _, body := range []string{``, `render_concurrency = 0`, `render_concurrency = -1`} { + writeTerminalConfig(t, body) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderConcurrency != 2 { + t.Errorf("AC50 %q: cap %v, err %v", body, prefs.RenderConcurrency, err) + } + } + writeTerminalConfig(t, `render_concurrency = 4`) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderConcurrency != 4 { + t.Fatalf("render_concurrency = 4: cap %v, err %v", prefs.RenderConcurrency, err) + } +} + +// AC51: a non-integer cap is a wrong-type error in doctor; zn uses 2. +func TestPrefsRenderConcurrencyWrongType(t *testing.T) { + writeTerminalConfig(t, `render_concurrency = "two"`) + prefs, _, err := LoadPrefs() + if err != nil || prefs.RenderConcurrency != 2 { + t.Fatalf("AC51 load: cap %v, err %v", prefs.RenderConcurrency, err) + } + err = ValidateConfig() + if err == nil || !strings.Contains(err.Error(), "terminal.render_concurrency") || !strings.Contains(err.Error(), "wrong type") { + t.Fatalf("AC51 doctor: %v", err) + } +} diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index 3ab8ff9..eaf7e6d 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -21,10 +21,16 @@ func ValidateConfig() error { if _, err := toml.Decode(string(raw), &doc); err != nil { return err } - return validateKnownValues(doc) + return validateKnownValues(doc, true) } -func validateKnownValues(doc map[string]any) error { +// lenientKeys are checked only when strict (zn doctor): LoadPrefs falls back +// to their defaults, so a bad value never stops zn from starting. +var lenientKeys = map[string]map[string]bool{ + "terminal": {"math": true, "render_timeout": true, "render_concurrency": true}, +} + +func validateKnownValues(doc map[string]any, strict bool) error { for section, defaults := range ValueTables(DefaultPrefs()) { raw, exists := doc[section] if !exists { @@ -36,7 +42,7 @@ func validateKnownValues(doc map[string]any) error { } for key, want := range defaults { got, exists := table[key] - if !exists { + if !exists || (!strict && lenientKeys[section][key]) { continue } valid := false @@ -61,13 +67,13 @@ func validateKnownValues(doc map[string]any) error { "editor": {"default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative", "hybrid"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}}, "view": {"tasks_view_mode": {"list", "kanban", "board", "calendar"}, "calendar_week_start": {"monday", "sunday"}, "note_sort_order": {"none", "name-asc", "name-desc", "updated-desc", "updated-asc", "created-desc", "created-asc"}}, "appearance": {"theme_mode": {"dark", "light", "system", "auto"}}, - "terminal": {"images": {"auto", "kitty", "off"}}, + "terminal": {"images": {"auto", "kitty", "off"}, "math": {"typst", "latex"}}, } for section, keys := range enums { table, _ := doc[section].(map[string]any) for key, choices := range keys { raw, exists := table[key] - if !exists { + if !exists || (!strict && lenientKeys[section][key]) { continue } value, _ := raw.(string) diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index e33ea1c..a183052 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -13,7 +13,7 @@ func ValueTables(p Prefs) map[string]map[string]any { "editor": {"tab_size": p.EditorTabSize, "scroll_off": p.EditorScrollOff, "word_wrap": p.WordWrap, "default_view_mode": p.DefaultPaneMode, "line_number_mode": p.LineNumberMode, "completed_task_style": p.CompletedTaskStyle, "time_format": p.TimeFormat, "auto_pairs": p.AutoPairs, "auto_pair_quotes_in_prose": p.AutoPairQuotesInProse, "tabs_enabled": p.TabsEnabled, "wrap_tabs": p.WrapTabs, "keep_view_mode_across_notes": p.RetainViewMode, "markdown_snippets": p.MarkdownSnippets, "hide_builtin_templates": p.HideBuiltinTemplates, "text_replacements_enabled": p.TextReplacementsEnabled, "sync_title_heading_on_rename": p.SyncTitleHeadingOnRename}, "appearance": {"theme_family": p.ThemeFamily, "theme_mode": p.ThemeMode, "theme_id": p.ThemeID, "unified_sidebar": p.UnifiedSidebar}, "view": {"note_sort_order": p.NoteSortOrder, "group_by_kind": p.GroupByKind, "nested_tags": p.NestedTags, "quick_note_date_title": p.QuickNoteDateTitle, "quick_note_title_prefix": p.QuickNoteTitlePrefix, "calendar_week_start": p.CalendarWeekStart, "calendar_show_week_numbers": p.CalendarShowWeekNumbers, "tasks_view_mode": p.TasksViewMode, "show_archived_tasks": p.ShowArchivedTasks, "kanban_group_by": p.KanbanGroupBy, "kanban_folder_root": p.KanbanFolderRoot}, - "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme}, + "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme, "math": p.TerminalMath, "render_timeout": p.RenderTimeout, "render_concurrency": p.RenderConcurrency}, } } diff --git a/internal/config/prefs_write.go b/internal/config/prefs_write.go index 53fad84..8793aec 100644 --- a/internal/config/prefs_write.go +++ b/internal/config/prefs_write.go @@ -61,7 +61,7 @@ func UpdateConfig(edit func(map[string]any) error) error { if err := edit(doc); err != nil { return err } - if err := validateKnownValues(doc); err != nil { + if err := validateKnownValues(doc, true); err != nil { return err } var out bytes.Buffer diff --git a/internal/config/prefs_write_test.go b/internal/config/prefs_write_test.go index 33e6fa2..a1527d0 100644 --- a/internal/config/prefs_write_test.go +++ b/internal/config/prefs_write_test.go @@ -73,3 +73,21 @@ func TestSetValueRemovesEntryAndRejectsBrokenConfig(t *testing.T) { t.Fatal("failed update modified config") } } + +// Writes stay strict for the keys LoadPrefs is lenient about: SetValue +// refuses a bad engine or a non-integer timeout instead of saving it. +func TestSetValueRejectsBadLatexKeys(t *testing.T) { + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + if err := SetValue("terminal", "math", "foo"); err == nil { + t.Fatal("math = \"foo\" was written") + } + if err := SetValue("terminal", "render_timeout", "ten"); err == nil { + t.Fatal("render_timeout = \"ten\" was written") + } + if err := SetValue("terminal", "math", "latex"); err != nil { + t.Fatal(err) + } + if p, _, err := LoadPrefs(); err != nil || p.TerminalMath != "latex" { + t.Fatalf("math after set: %q, err %v", p.TerminalMath, err) + } +} diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 2fcf843..68a00fb 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -113,7 +113,7 @@ func (v *settingsView) handleKey(a *App, k vim.Key) bool { } var settingChoices = map[string][]string{ - "which_key_hint_mode": {"timed", "sticky"}, "wrapped_line_motions": {"display", "logical"}, "default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}, "theme_mode": {"dark", "light", "system"}, "note_sort_order": {"modified-desc", "created-desc", "title-asc", "title-desc"}, "calendar_week_start": {"monday", "sunday"}, "tasks_view_mode": {"list", "kanban", "calendar"}, "preview_style": {"auto", "dark", "light", "ascii", "notty"}, "images": {"auto", "kitty", "off"}, + "which_key_hint_mode": {"timed", "sticky"}, "wrapped_line_motions": {"display", "logical"}, "default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}, "theme_mode": {"dark", "light", "system"}, "note_sort_order": {"modified-desc", "created-desc", "title-asc", "title-desc"}, "calendar_week_start": {"monday", "sunday"}, "tasks_view_mode": {"list", "kanban", "calendar"}, "preview_style": {"auto", "dark", "light", "ascii", "notty"}, "images": {"auto", "kitty", "off"}, "math": {"typst", "latex"}, } func (v *settingsView) edit(a *App, row settingRow) { From 3f627f3e0748e935326d69aec25fbd8b7f265992 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Mon, 26 Oct 2026 14:25:34 -0400 Subject: [PATCH 02/10] Feat(tui): render display math through LaTeX with math = "latex" With [terminal] math = "latex", display math renders through the user's own pdftex instead of typst, so full LaTeX works: amsmath environments, mhchem and TikZ. $$ blocks, \[ \] (one or many lines) and top-level equation, align, gather, multline, alignat, flalign, eqnarray (starred too) and tikzpicture blocks are detected; inside code fences, $$, lists, quotes and callouts they stay text, and under typst nothing changes. Each block is a standalone document over preamble.tex in the config dir. zn writes the default (amsmath, amssymb, mhchem, tikz with arrows.meta and calc) on the first render and never rewrites it. The preamble is dumped once into a pdftex format named by its hash, so a block only pays for itself; a changed preamble builds a new format. pdftex runs with -no-shell-escape and openin_any=p/openout_any=p, so a note cannot run commands or write outside the work dir. pdftocairo turns the PDF into a transparent PNG, cropped to the formula so its size does not shrink with the pane, coloured with the theme's text colour, and cached on disk under zn/tex. Every external render (mmdc, typst, pdftex, pdftocairo) now runs through one runner that kills it after render_timeout, and through one pool of render_concurrency slots. A TeX error shows its first line above the source and is remembered; a timeout is not, so the block renders again when its note is opened, without looping on every redraw. The editor highlights math bodies with chroma's tex or typst lexer to match. Co-Authored-By: Claude Opus 5.5 --- internal/tui/codehl.go | 43 +- internal/tui/editorview.go | 6 + internal/tui/embeds.go | 171 +++- internal/tui/embeds_test.go | 1196 ++++++++++++++++++++++++++- internal/tui/glamour_test.go | 2 +- internal/tui/latex.go | 371 +++++++++ internal/tui/latex_test.go | 1251 +++++++++++++++++++++++++++++ internal/tui/latex_tex_test.go | 373 +++++++++ internal/tui/noteops.go | 1 + internal/tui/panes.go | 1 + internal/tui/previewglamour.go | 46 +- internal/tui/theme_render_test.go | 23 + 12 files changed, 3430 insertions(+), 54 deletions(-) create mode 100644 internal/tui/latex.go create mode 100644 internal/tui/latex_test.go create mode 100644 internal/tui/latex_tex_test.go diff --git a/internal/tui/codehl.go b/internal/tui/codehl.go index 4366123..c9eab46 100644 --- a/internal/tui/codehl.go +++ b/internal/tui/codehl.go @@ -23,6 +23,7 @@ type codeSpan struct { type codeHighlights struct { version int style string + engine string lines map[int][]codeSpan } @@ -77,19 +78,23 @@ func registerChromaStyle(t Theme) string { return name } -// codeSpansFor computes (or reuses) the token spans of every fenced block. +// codeSpansFor computes (or reuses) the token spans of every fenced and +// display-math block. func (a *App) codeSpansFor(buf *noteBuffer, lines []string) map[int][]codeSpan { - style := a.theme.ChromaStyle - if buf.codeHL != nil && buf.codeHL.version == buf.ed.Version() && buf.codeHL.style == style { + style, engine := a.theme.ChromaStyle, a.prefs.TerminalMath + if buf.codeHL != nil && buf.codeHL.version == buf.ed.Version() && buf.codeHL.style == style && buf.codeHL.engine == engine { return buf.codeHL.lines } - spans := highlightFences(lines, style) - buf.codeHL = &codeHighlights{version: buf.ed.Version(), style: style, lines: spans} + spans := highlightFences(lines, style, engine) + buf.codeHL = &codeHighlights{version: buf.ed.Version(), style: style, engine: engine, lines: spans} return spans } -// highlightFences finds ```lang blocks and tokenizes their bodies. -func highlightFences(lines []string, styleName string) map[int][]codeSpan { +// highlightFences finds ```lang blocks and display-math blocks and tokenizes +// their bodies. engine is prefs.TerminalMath: under "latex" $$…$$ bodies, +// block-level \[…\] and the AC7 environments are lexed as tex; otherwise +// $$…$$ bodies are lexed as typst. +func highlightFences(lines []string, styleName, engine string) map[int][]codeSpan { out := map[int][]codeSpan{} style := styles.Get(styleName) if style == nil { @@ -114,6 +119,30 @@ func highlightFences(lines []string, styleName string) map[int][]codeSpan { } i = j + 1 } + latex := engine == "latex" + mathLang := "typst" + if latex { + mathLang = "tex" + } + for _, b := range splitBlocks(lines, latex) { + lang := mathLang + switch b.kind { + case "math": + case "tex": + lang = "tex" + default: + continue + } + // The opener is line b.start; the closer, when there is one, is b.end-1. + // A one-line block (`$$ x $$`, `\[ x \]`) has no body and stays plain. + end := b.end + if end-b.start > 1 && (b.kind == "tex" || strings.HasSuffix(strings.TrimSpace(lines[end-1]), "$$")) { + end-- + } + if end > b.start+1 { + highlightBlock(out, lines[b.start+1:end], b.start+1, lang, style) + } + } return out } diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index 753a7e1..e143cb1 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -324,6 +324,12 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { text := lines[line] runes := []rune(text) classes := lineClasses(text, blocks.fence[line], blocks.front[line]) + if len(codeSpans[line]) > 0 { + // A math body has lexer spans but no fence: paint it as code. + for i := range classes { + classes[i] = stFence + } + } if !strikeDone && !dimDone { for i, c := range classes { if c == stDone { diff --git a/internal/tui/embeds.go b/internal/tui/embeds.go index e6577ac..b3d4811 100644 --- a/internal/tui/embeds.go +++ b/internal/tui/embeds.go @@ -1,8 +1,10 @@ package tui import ( + "context" "crypto/sha1" "encoding/hex" + "errors" "fmt" "net/url" "os" @@ -11,6 +13,7 @@ import ( "regexp" "strings" "sync" + "time" "github.com/charmbracelet/lipgloss" "github.com/yashikota/mermaigo/pkg/mermaid" @@ -163,7 +166,7 @@ func (a *App) embedBlockLines(b mdBlock, lines []string, width int, fromPath str return a.mermaidLines(strings.Join(lines[b.start+1:b.end-1], "\n"), b.start, width), true } return nil, false - case "math": + case "math", "tex": body := strings.Join(lines[b.start:b.end], "\n") return a.mathLines(body, b.start, width), true case "para", "list": @@ -353,11 +356,21 @@ type diagramCache struct { text map[string][]string png map[string]*termImage inFly map[string]bool + // pool caps the external render processes running at once; mermaid, + // typst and latex share it. Sized from render_concurrency at first use. + pool chan struct{} + // latex single-flights pdftex format builds for this session. + latex latexFormats } +// diagramsMu guards the lazy init: render goroutines may be the first to ask. +var diagramsMu sync.Mutex + func (a *App) diagrams() *diagramCache { + diagramsMu.Lock() + defer diagramsMu.Unlock() if a.diagramCache == nil { - a.diagramCache = &diagramCache{text: map[string][]string{}, png: map[string]*termImage{}, inFly: map[string]bool{}} + a.diagramCache = &diagramCache{text: map[string][]string{}, png: map[string]*termImage{}, inFly: map[string]bool{}, pool: make(chan struct{}, max(1, a.prefs.RenderConcurrency))} } return a.diagramCache } @@ -374,7 +387,7 @@ func (a *App) mermaidLines(src string, srcLine, width int) []previewLine { th := a.theme key := contentKey("mermaid", src) if a.imageMode() == imagesKitty { - if img := a.externalImage(key, width-2, "mermaid", src); img != nil { + if img := a.externalImage(key, width-2, "mermaid", src); img != nil && img.err == nil { return a.pictureLines(img, srcLine, width, "mermaid") } } @@ -420,19 +433,32 @@ func (a *App) pictureLines(img *termImage, srcLine, width int, label string) []p // --- math --- -// mathLines renders a `$$` block: as a picture through typst when it is -// installed and the terminal shows pictures, else as the source in a -// quiet block. +// mathLines renders a `$$` block: as a picture through typst, or pdftex +// and pdftocairo under math = "latex", when the tools are installed and +// the terminal shows pictures, else as the source in a quiet block. A +// latex error is shown in a row above the source. func (a *App) mathLines(body string, srcLine, width int) []previewLine { th := a.theme inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(body), "$$"), "$$")) + out := []previewLine{} if a.imageMode() == imagesKitty { - key := contentKey("math", inner) - if img := a.externalImage(key, width-2, "math", inner); img != nil { + var img *termImage + if a.prefs.TerminalMath == "latex" { + src := strings.TrimSpace(body) + img = a.externalImage(latexKey(string(th.Fg), currentLatexFormat(), src), width-2, "latex", src) + if img != nil && img.err != nil && !errors.Is(img.err, exec.ErrNotFound) { + // Wrapped, not truncated: the cause sits at the end of long paths. + for _, row := range wrapCommentText(firstLine(img.err.Error()), width-2) { + out = append(out, previewLine{text: " " + th.StatusError.Render(row), srcLine: srcLine}) + } + } + } else { + img = a.externalImage(contentKey("math", fmt.Sprintf("typst\x00%t\x00%s", th.Dark, inner)), width-2, "math", inner) + } + if img != nil && img.err == nil { return a.pictureLines(img, srcLine, width, "math") } } - out := []previewLine{} for _, l := range strings.Split(inner, "\n") { out = append(out, previewLine{text: " " + th.BorderFocus.Render("▎") + " " + th.Code.Render(truncateCells(l, width-4)), srcLine: srcLine}) } @@ -441,44 +467,37 @@ func (a *App) mathLines(body string, srcLine, width int) []previewLine { // externalImage returns a rendered picture for a key once a background // render finished; it starts the render on first call and returns nil -// until then. Renders need the tool on PATH: mmdc for mermaid, typst for -// math. +// until then; a failed render is returned with its err set. Renders need +// the tools on PATH: mmdc for mermaid, typst for math, pdftex and +// pdftocairo for latex. func (a *App) externalImage(key string, maxCols int, kind, src string) *termImage { d := a.diagrams() d.mu.Lock() if img, ok := d.png[key]; ok { d.mu.Unlock() - if img.err != nil { - return nil - } return img } if d.inFly[key] || a.program == nil { d.mu.Unlock() return nil } - tool := "mmdc" - if kind == "math" { - tool = "typst" - } - if _, err := exec.LookPath(tool); err != nil { - d.png[key] = &termImage{err: err} - d.mu.Unlock() - return nil + tools := map[string][]string{"mermaid": {"mmdc"}, "math": {"typst"}, "latex": {"pdftex", "pdftocairo"}}[kind] + for _, tool := range tools { + if _, err := exec.LookPath(tool); err != nil { + img := &termImage{err: err} + d.png[key] = img + d.mu.Unlock() + return img + } } d.inFly[key] = true d.mu.Unlock() - dark := a.theme.Dark - store := a.images() + req := renderReq{kind: kind, src: src, dark: a.theme.Dark, fg: string(a.theme.Fg), maxCols: maxCols, timeout: time.Duration(a.prefs.RenderTimeout) * time.Second} go func() { - data, err := renderExternal(kind, src, dark) - var img *termImage - if err != nil { - img = &termImage{err: err} - } else { - img = store.imageFor(key, maxCols, 40, func() ([]byte, error) { return data, nil }) - } + img := a.renderEmbed(key, req) d.mu.Lock() + // A timeout is stored like any failure, so a redraw does not loop; + // opening a note drops it via forgetEmbedTimeouts (AC83). d.png[key] = img delete(d.inFly, key) d.mu.Unlock() @@ -487,12 +506,90 @@ func (a *App) externalImage(key string, maxCols int, kind, src string) *termImag return nil } +// forgetEmbedTimeouts drops every timed-out render, so the next show of +// its block renders again. Called when a note is opened (AC83). +func (a *App) forgetEmbedTimeouts() { + d := a.diagrams() + d.mu.Lock() + defer d.mu.Unlock() + for k, img := range d.png { + if errors.Is(img.err, context.DeadlineExceeded) { + delete(d.png, k) + } + } +} + +// renderReq is one external render: what to draw and its limits. +type renderReq struct { + kind, src string + dark bool + fg string // theme text colour (#rrggbb) for latex; empty = the App's theme + maxCols int + timeout time.Duration +} + +// renderEmbed runs one render synchronously. It holds a pool slot for the +// whole render, and the timeout starts once it has the slot. +func (a *App) renderEmbed(key string, req renderReq) *termImage { + d := a.diagrams() + d.pool <- struct{}{} + defer func() { <-d.pool }() + if req.timeout <= 0 { + req.timeout = 10 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), req.timeout) + defer cancel() + var data []byte + var err error + if req.kind == "latex" { + if req.fg == "" { + req.fg = string(a.theme.Fg) + } + data, err = latexPNG(ctx, &d.latex, req.src, req.fg) + } else { + data, err = renderExternal(ctx, req.kind, req.src, req.dark) + } + if err != nil { + return &termImage{err: err} + } + return a.images().imageFor(key, req.maxCols, 40, func() ([]byte, error) { return data, nil }) +} + +// runTool runs an external tool in dir with extra env and returns its +// combined output. The process is killed when ctx ends; WaitDelay stops +// children that still hold the pipes (Chromium under mmdc) from hanging +// the call. Tests swap it for a fake. +var runTool = func(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + cmd.WaitDelay = time.Second + start := time.Now() + out, err := cmd.CombinedOutput() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + limit := time.Since(start) + if dl, ok := ctx.Deadline(); ok { + limit = dl.Sub(start) + } + return out, timeoutError{fmt.Sprintf("timed out after %s", limit.Round(100*time.Millisecond))} + } + return out, err +} + +// timeoutError reads "timed out after Ns" and matches +// errors.Is(err, context.DeadlineExceeded), so a timeout, which is worth +// retrying, can be told from a tool error, which is not. +type timeoutError struct{ msg string } + +func (e timeoutError) Error() string { return e.msg } +func (e timeoutError) Unwrap() error { return context.DeadlineExceeded } + // embedRenderedMsg says a background render finished; previews redraw. type embedRenderedMsg struct{ key string } // renderExternal runs mermaid-cli or typst on a temp file and returns PNG // bytes. -func renderExternal(kind, src string, dark bool) ([]byte, error) { +func renderExternal(ctx context.Context, kind, src string, dark bool) ([]byte, error) { dir, err := os.MkdirTemp("", "zn-embed") if err != nil { return nil, err @@ -509,9 +606,8 @@ func renderExternal(kind, src string, dark bool) ([]byte, error) { if dark { theme = "dark" } - cmd := exec.Command("mmdc", "-i", in, "-o", out, "-b", "transparent", "-t", theme, "-s", "2", "-q") - if msg, err := cmd.CombinedOutput(); err != nil { - return nil, fmt.Errorf("mmdc: %s", firstLine(strings.TrimSpace(string(msg))+" "+err.Error())) + if msg, err := runTool(ctx, dir, nil, "mmdc", "-i", in, "-o", out, "-b", "transparent", "-t", theme, "-s", "2", "-q"); err != nil { + return nil, fmt.Errorf("mmdc: %s", firstLine(strings.TrimSpace(string(msg)+" "+err.Error()))) } return os.ReadFile(out) case "math": @@ -525,9 +621,8 @@ func renderExternal(kind, src string, dark bool) ([]byte, error) { if err := os.WriteFile(in, []byte(doc), 0o644); err != nil { return nil, err } - cmd := exec.Command("typst", "compile", "--format", "png", "--ppi", "144", in, out) - if msg, err := cmd.CombinedOutput(); err != nil { - return nil, fmt.Errorf("typst: %s", firstLine(strings.TrimSpace(string(msg))+" "+err.Error())) + if msg, err := runTool(ctx, dir, nil, "typst", "compile", "--format", "png", "--ppi", "144", in, out); err != nil { + return nil, fmt.Errorf("typst: %s", firstLine(strings.TrimSpace(string(msg)+" "+err.Error()))) } return os.ReadFile(out) } diff --git a/internal/tui/embeds_test.go b/internal/tui/embeds_test.go index 84b7530..b5ddcd3 100644 --- a/internal/tui/embeds_test.go +++ b/internal/tui/embeds_test.go @@ -2,16 +2,31 @@ package tui import ( "bytes" + "context" + "errors" + "fmt" "image" "image/color" "image/jpeg" "image/png" + "io" "os" + "os/exec" "path/filepath" + "runtime" "strings" + "sync" "testing" + "time" + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + tea "github.com/charmbracelet/bubbletea" + + "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/vault" + "github.com/ZenNotes/tui/internal/vim" ) func TestPlaceholderRowsEncodeIdRowAndColumn(t *testing.T) { @@ -80,7 +95,7 @@ func TestToPNGKeepsPNGAndConvertsJPEG(t *testing.T) { func TestHighlightFencesColorsGoCode(t *testing.T) { lines := []string{"# Title", "```go", "func main() { fmt.Println(\"hi\") }", "```", "plain"} - spans := highlightFences(lines, "gruvbox") + spans := highlightFences(lines, "gruvbox", "typst") if len(spans[2]) == 0 { t.Fatal("the code line gets spans") } @@ -91,11 +106,141 @@ func TestHighlightFencesColorsGoCode(t *testing.T) { if !ok || first.fg == "" { t.Fatalf("func keyword colored: %+v", first) } - if len(highlightFences([]string{"```", "x", "```"}, "gruvbox")) != 0 { + if len(highlightFences([]string{"```", "x", "```"}, "gruvbox", "typst")) != 0 { t.Fatal("a fence without a language stays plain") } } +// chromaColumns tokenises text with chroma's own lexer and returns, per line, +// the style of every column ("" where the style sets nothing), the way the +// editor paints code. +func chromaColumns(t *testing.T, lang, text string) [][]string { + t.Helper() + lexer := lexers.Get(lang) + if lexer == nil { + t.Fatalf("chroma has no %s lexer", lang) + } + iter, err := chroma.Coalesce(lexer).Tokenise(nil, text+"\n") + if err != nil { + t.Fatal(err) + } + style := styles.Get("gruvbox") + out := [][]string{nil} + for tok := iter(); tok != chroma.EOF; tok = iter() { + e := style.Get(tok.Type) + key := "" + if e.Colour.IsSet() || e.Bold == chroma.Yes || e.Italic == chroma.Yes { + key = fmt.Sprintf("%s/%v/%v", e.Colour, e.Bold == chroma.Yes, e.Italic == chroma.Yes) + if !e.Colour.IsSet() { + key = fmt.Sprintf("/%v/%v", e.Bold == chroma.Yes, e.Italic == chroma.Yes) + } + } + for _, r := range tok.Value { + if r == '\n' { + out = append(out, nil) + continue + } + out[len(out)-1] = append(out[len(out)-1], key) + } + } + return out +} + +// spanColumns is the style of every column of a line as the editor paints it. +func spanColumns(spans []codeSpan, line string) []string { + var out []string + for col := range []rune(line) { + key := "" + if s, ok := codeSpanAt(spans, col); ok { + key = fmt.Sprintf("%s/%v/%v", s.fg, s.bold, s.italic) + } + out = append(out, key) + } + return out +} + +// assertMathBodyLexed checks that the body lines of the block at +// lines[open..close] are painted exactly as chroma's lang lexer paints the +// body, lexed alone or together with its delimiter lines. It also checks +// that the other engine's lexer paints this body differently, so the test +// tells the two engines apart. +func assertMathBodyLexed(t *testing.T, spans map[int][]codeSpan, lines []string, open, close int, lang, other string) { + t.Helper() + body := lines[open+1 : close] + block := lines[open : close+1] + alone := chromaColumns(t, lang, strings.Join(body, "\n")) + whole := chromaColumns(t, lang, strings.Join(block, "\n"))[1:] + otherAlone := chromaColumns(t, other, strings.Join(body, "\n")) + otherWhole := chromaColumns(t, other, strings.Join(block, "\n"))[1:] + matches := func(want [][]string) bool { + for k, line := range body { + if fmt.Sprint(spanColumns(spans[open+1+k], line)) != fmt.Sprint(want[k]) { + return false + } + } + return true + } + if matches(otherAlone) || matches(otherWhole) { + t.Fatalf("the body is painted as the %s lexer paints it, want %s", other, lang) + } + if !matches(alone) && !matches(whole) { + var got []string + for k, line := range body { + got = append(got, fmt.Sprint(spanColumns(spans[open+1+k], line))) + } + t.Fatalf("the body is not painted by chroma's %s lexer\n got: %v\nwant: %v\n or: %v", lang, got, alone[:len(body)], whole[:len(body)]) + } +} + +func TestHighlightDollarBodyWithTexUnderLatex(t *testing.T) { // AC56 + lines := []string{"Some prose", "", "$$", `\frac{a}{b} + x_1 = 2`, "$$", "", "after"} + spans := highlightFences(lines, "gruvbox", "latex") + assertMathBodyLexed(t, spans, lines, 2, 4, "tex", "typst") + if _, ok := spans[0]; ok { + t.Fatal("prose is untouched") + } +} + +func TestHighlightBracketBlockWithTex(t *testing.T) { // AC57 + lines := []string{"Some prose", "", `\[`, `\alpha^2 + \beta = 3`, `\]`, "", "after"} + spans := highlightFences(lines, "gruvbox", "latex") + assertMathBodyLexed(t, spans, lines, 2, 4, "tex", "typst") + if _, ok := spans[0]; ok { + t.Fatal("prose is untouched") + } +} + +func TestHighlightEnvironmentWithTex(t *testing.T) { // AC58 + lines := []string{"Some prose", "", `\begin{align}`, `a &= \sqrt{b} \\`, `c &= \frac{d}{e}`, `\end{align}`, "", "after"} + spans := highlightFences(lines, "gruvbox", "latex") + assertMathBodyLexed(t, spans, lines, 2, 5, "tex", "typst") + if _, ok := spans[0]; ok { + t.Fatal("prose is untouched") + } +} + +func TestHighlightDollarBodyWithTypstUnderTypst(t *testing.T) { // AC59 + lines := []string{"Some prose", "", "$$", `sum_(i=1)^n x_i = "hi" + 2`, "$$", "", "after"} + spans := highlightFences(lines, "gruvbox", "typst") + assertMathBodyLexed(t, spans, lines, 2, 4, "typst", "tex") + if _, ok := spans[0]; ok { + t.Fatal("prose is untouched") + } +} + +func TestCodeSpansForRehighlightsWhenEngineChanges(t *testing.T) { + a := &App{theme: buildTheme(true), prefs: prefsView{Prefs: config.Prefs{TerminalMath: "typst"}}} + text := "$$\n\\frac{a}{b}\n$$" + buf := ¬eBuffer{ed: vim.New(text, vim.Options{}, vim.Hooks{})} + lines := strings.Split(text, "\n") + typst := fmt.Sprint(a.codeSpansFor(buf, lines)) + a.prefs.TerminalMath = "latex" + latex := fmt.Sprint(a.codeSpansFor(buf, lines)) + if latex != fmt.Sprint(highlightFences(lines, a.theme.ChromaStyle, "latex")) || latex == typst { + t.Fatalf("switching the engine keeps the cached spans\ntypst: %s\nlatex: %s", typst, latex) + } +} + func TestAssetKindAndProviders(t *testing.T) { cases := map[string]string{"pic.PNG": "image", "deck.pdf": "pdf", "song.mp3": "audio", "clip.mov": "video", "sketch.excalidraw": "drawing", "Some Note": "note", "data.csv": "file"} for in, want := range cases { @@ -115,7 +260,7 @@ func TestAssetKindAndProviders(t *testing.T) { } func TestSplitBlocksFindsMath(t *testing.T) { - blocks := splitBlocks([]string{"text", "$$", "x^2", "$$", "$$ y $$", "more"}) + blocks := splitBlocks([]string{"text", "$$", "x^2", "$$", "$$ y $$", "more"}, false) kinds := []string{} for _, b := range blocks { kinds = append(kinds, b.kind) @@ -172,3 +317,1048 @@ func TestPreviewRendersEmbedsAsCardsAndDiagrams(t *testing.T) { t.Fatalf("cards carry links: file %v url %v", fileLink, urlLink) } } + +// --- Step 3: shared runner with timeout and the shared render pool --- + +// TestHelperSleep is the child process for TestRunToolKillsProcessAfterTimeout. +// With ZN_HELPER_SLEEP=1 it starts a grandchild that holds its output pipe +// (as Chromium does under mmdc) and then hangs; with 2 it only sleeps. +func TestHelperSleep(t *testing.T) { + switch os.Getenv("ZN_HELPER_SLEEP") { + case "1": + child := exec.Command(os.Args[0], "-test.run=^TestHelperSleep$") + child.Env = append(os.Environ(), "ZN_HELPER_SLEEP=2") + child.Stdout, child.Stderr = os.Stdout, os.Stderr + _ = child.Start() + time.Sleep(time.Minute) + os.Exit(0) + case "2": + time.Sleep(10 * time.Second) + os.Exit(0) + } +} + +func TestRunToolKillsProcessAfterTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + start := time.Now() + // os.TempDir, not t.TempDir: Windows does not kill the helper's child, + // which keeps its working directory busy until it exits. + _, err := runTool(ctx, os.TempDir(), []string{"ZN_HELPER_SLEEP=1"}, os.Args[0], "-test.run=^TestHelperSleep$") + if elapsed := time.Since(start); elapsed > 3*time.Second { + t.Fatalf("runTool returned after %v, want < 3s", elapsed) + } + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("want a timed out error, got %v", err) + } + // AC82, AC83: a timeout must be told apart from a tool error. + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout error %v does not match context.DeadlineExceeded", err) + } +} + +type toolCall struct { + name string + args []string + deadline time.Duration // time left on ctx when the call started; 0 when none + input string // content of the .typ input, when any +} + +// fakeTools swaps runTool for fn, which may block; every call is recorded. +// Any argument ending in .png is written as a small PNG, as the real tools do. +func fakeTools(t *testing.T, fn func(ctx context.Context) ([]byte, error)) func() []toolCall { + t.Helper() + var mu sync.Mutex + calls := []toolCall{} + inDir := func(dir, p string) string { + if filepath.IsAbs(p) { + return p + } + return filepath.Join(dir, p) + } + prev := runTool + runTool = func(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) { + c := toolCall{name: filepath.Base(name), args: append([]string(nil), args...)} + if dl, ok := ctx.Deadline(); ok { + c.deadline = time.Until(dl) + } + for _, arg := range args { + if strings.HasSuffix(arg, ".typ") { + b, _ := os.ReadFile(inDir(dir, arg)) + c.input = string(b) + } + } + mu.Lock() + calls = append(calls, c) + mu.Unlock() + out, err := fn(ctx) + if err != nil { + return out, err + } + for _, arg := range args { + if strings.HasSuffix(arg, ".png") { + if err := os.WriteFile(inDir(dir, arg), tinyPNG(t), 0o644); err != nil { + return nil, err + } + } + } + return out, nil + } + t.Cleanup(func() { runTool = prev }) + return func() []toolCall { + mu.Lock() + defer mu.Unlock() + return append([]toolCall(nil), calls...) + } +} + +func tinyPNG(t *testing.T) []byte { + var buf bytes.Buffer + if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 40, 20))); err != nil { + t.Error(err) + } + return buf.Bytes() +} + +// renderWatch is a program model that reports each finished background render. +type renderWatch struct{ done chan string } + +func (m renderWatch) Init() tea.Cmd { return nil } +func (m renderWatch) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if r, ok := msg.(embedRenderedMsg); ok { + m.done <- r.key + } + return m, nil +} +func (m renderWatch) View() string { return "" } + +// newRenderTestApp is an App with Kitty pictures on, the given prefs, the +// named tools present on PATH as stubs, and a program that reports renders. +func newRenderTestApp(t *testing.T, prefs config.Prefs, tools ...string) (*App, chan string) { + t.Helper() + t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) + bin := t.TempDir() + for _, tool := range tools { + name := tool + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + prefs.TerminalImages = "kitty" + a := &App{ + theme: buildTheme(true), + prefs: prefsView{Prefs: prefs}, + imageStore: &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard}, + } + return a, watchRenders(t, a) +} + +// watchRenders gives a a program that reports each finished background +// render on the returned channel. +func watchRenders(t *testing.T, a *App) chan string { + t.Helper() + done := make(chan string, 64) + p := tea.NewProgram(renderWatch{done: done}, tea.WithInput(nil), tea.WithOutput(io.Discard), tea.WithoutRenderer(), tea.WithoutSignalHandler()) + go func() { _, _ = p.Run() }() + t.Cleanup(p.Kill) + a.program = p + return done +} + +func waitRenders(t *testing.T, done chan string, n int, within time.Duration) { + t.Helper() + deadline := time.After(within) + for i := 0; i < n; i++ { + select { + case <-done: + case <-deadline: + t.Fatalf("%d of %d renders finished within %v", i, n, within) + } + } +} + +func joinPreview(lines []previewLine) string { + plain := []string{} + for _, l := range lines { + plain = append(plain, stripAnsi(l.text)) + } + return strings.Join(plain, "\n") +} + +func isPicture(lines []previewLine) bool { + return strings.Contains(joinPreview(lines), "\U0010EEEE") +} + +func TestTypstRenderUsesUnchangedArgs(t *testing.T) { // AC2 + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + a, done := newRenderTestApp(t, prefs, "typst") + calls := fakeTools(t, func(context.Context) ([]byte, error) { return nil, nil }) + a.mathLines("$$\nx^2\n$$", 0, 80) + waitRenders(t, done, 1, 5*time.Second) + got := calls() + if len(got) != 1 || got[0].name != "typst" { + t.Fatalf("want one typst call, got %+v", got) + } + args := got[0].args + if len(args) != 7 || strings.Join(args[:5], " ") != "compile --format png --ppi 144" || !strings.HasSuffix(args[5], ".typ") || !strings.HasSuffix(args[6], ".png") { + t.Fatalf("typst args changed: %q", args) + } + want := "#set page(width: auto, height: auto, margin: 8pt, fill: none)\n#set text(size: 18pt, fill: white)\n$ x^2 $\n" + if got[0].input != want { + t.Fatalf("typst document changed:\n%q\nwant\n%q", got[0].input, want) + } + if lines := a.mathLines("$$\nx^2\n$$", 0, 80); !isPicture(lines) { + t.Fatalf("rendered block is a picture: %s", joinPreview(lines)) + } +} + +func TestTypstTimeoutFallsBackToRawSource(t *testing.T) { // AC35 + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + prefs.RenderTimeout = 1 + a, done := newRenderTestApp(t, prefs, "typst") + calls := fakeTools(t, func(ctx context.Context) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }) + a.mathLines("$$\nx^2 + y^2\n$$", 0, 80) + waitRenders(t, done, 1, 5*time.Second) + got := calls() + if len(got) != 1 { + t.Fatalf("want one typst call, got %+v", got) + } + if d := got[0].deadline; d <= 500*time.Millisecond || d > 1500*time.Millisecond { + t.Fatalf("ctx deadline %v, want about render_timeout = 1s", d) + } + lines := a.mathLines("$$\nx^2 + y^2\n$$", 0, 80) + if isPicture(lines) || !strings.Contains(joinPreview(lines), "x^2 + y^2") { + t.Fatalf("timed out block shows raw source: %s", joinPreview(lines)) + } +} + +func TestMermaidTimeoutFallsBackToText(t *testing.T) { // AC36 + prefs := config.DefaultPrefs() + prefs.RenderTimeout = 1 + a, done := newRenderTestApp(t, prefs, "mmdc") + calls := fakeTools(t, func(ctx context.Context) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }) + src := "graph LR\n Alpha --> Beta" + a.mermaidLines(src, 0, 80) + waitRenders(t, done, 1, 5*time.Second) + got := calls() + if len(got) != 1 || got[0].name != "mmdc" { + t.Fatalf("want one mmdc call, got %+v", got) + } + if d := got[0].deadline; d <= 500*time.Millisecond || d > 1500*time.Millisecond { + t.Fatalf("ctx deadline %v, want about render_timeout = 1s", d) + } + lines := a.mermaidLines(src, 0, 80) + all := joinPreview(lines) + if isPicture(lines) || !strings.Contains(all, "Alpha") || !strings.Contains(all, "Beta") { + t.Fatalf("timed out mermaid shows the text diagram: %s", all) + } +} + +func TestTypstErrorShowsRawSourceOnly(t *testing.T) { // AC55 + src := "$$\nx^2 + \\foo\n$$" + t.Run("compile error", func(t *testing.T) { + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + a, done := newRenderTestApp(t, prefs, "typst") + fakeTools(t, func(context.Context) ([]byte, error) { + return []byte("error: unknown variable: foo"), errors.New("exit status 1") + }) + a.mathLines(src, 0, 80) + waitRenders(t, done, 1, 5*time.Second) + lines := a.mathLines(src, 0, 80) + all := joinPreview(lines) + if isPicture(lines) || len(lines) != 1 || !strings.Contains(all, "x^2 + \\foo") || strings.Contains(all, "typst") || strings.Contains(all, "error") { + t.Fatalf("failed block shows only its raw source: %q", all) + } + }) + t.Run("typst not on PATH", func(t *testing.T) { + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + a, _ := newRenderTestApp(t, prefs) + calls := fakeTools(t, func(context.Context) ([]byte, error) { return nil, nil }) + for i := 0; i < 2; i++ { + lines := a.mathLines(src, 0, 80) + all := joinPreview(lines) + if isPicture(lines) || len(lines) != 1 || !strings.Contains(all, "x^2 + \\foo") || strings.Contains(all, "typst") { + t.Fatalf("block without typst shows only its raw source: %q", all) + } + } + if n := len(calls()); n != 0 { + t.Fatalf("no tool runs without typst on PATH: %d calls", n) + } + }) +} + +// inFlight is a fake tool body that holds each call for a while and +// records the most calls running at once. +func inFlight() (func(context.Context) ([]byte, error), func() int) { + var mu sync.Mutex + cur, peak := 0, 0 + body := func(context.Context) ([]byte, error) { + mu.Lock() + cur++ + peak = max(peak, cur) + mu.Unlock() + time.Sleep(150 * time.Millisecond) + mu.Lock() + cur-- + mu.Unlock() + return nil, nil + } + return body, func() int { + mu.Lock() + defer mu.Unlock() + return peak + } +} + +func TestRenderPoolCapsConcurrency(t *testing.T) { // AC47, AC48 + for _, tc := range []struct { + name string + cap int // 0 keeps the default + want int + }{{"default 2 (AC47)", 0, 2}, {"configured 4 (AC48)", 4, 4}} { + t.Run(tc.name, func(t *testing.T) { + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + if tc.cap > 0 { + prefs.RenderConcurrency = tc.cap + } + a, done := newRenderTestApp(t, prefs, "typst") + body, peak := inFlight() + fakeTools(t, body) + for i := 0; i < 8; i++ { + a.mathLines(fmt.Sprintf("$$\nx^%d\n$$", i), 0, 80) + } + waitRenders(t, done, 8, 10*time.Second) + if got := peak(); got != tc.want { + t.Fatalf("at most %d renders run at once, saw %d", tc.want, got) + } + }) + } +} + +func TestRenderPoolSharedByMermaidAndMath(t *testing.T) { // AC49 + prefs := config.DefaultPrefs() + prefs.TerminalMath = "typst" + prefs.RenderConcurrency = 1 + a, done := newRenderTestApp(t, prefs, "typst", "mmdc") + body, peak := inFlight() + calls := fakeTools(t, body) + a.mermaidLines("graph LR\n A --> B", 0, 80) + a.mathLines("$$\nx^2\n$$", 0, 80) + waitRenders(t, done, 2, 10*time.Second) + if n := len(calls()); n != 2 { + t.Fatalf("want a mermaid and a math render, got %+v", calls()) + } + if got := peak(); got != 1 { + t.Fatalf("mermaid and math share one pool of 1, saw %d at once", got) + } +} + +// --- Step 6: the math engine wired into the reading view --- +// +// These drive `$$` blocks through mathLines (and whole notes through the +// preview renderers), with pdftex and pdftocairo faked by fakeTeX and typst +// by fakeTypst. Block detection for \[…\] and environments is Step 7. + +// fakeTypst wraps the current runTool: typst runs succeed and write their +// PNG, every other tool goes to the wrapped fake. It returns the typst run +// count. +func fakeTypst(t *testing.T) func() int { + t.Helper() + var mu sync.Mutex + n := 0 + prev := runTool + runTool = func(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) { + if strings.TrimSuffix(filepath.Base(name), ".exe") != "typst" { + return prev(ctx, dir, env, name, args...) + } + mu.Lock() + n++ + mu.Unlock() + for _, arg := range args { + if strings.HasSuffix(arg, ".png") { + p := arg + if !filepath.IsAbs(p) { + p = filepath.Join(dir, p) + } + if err := os.WriteFile(p, tinyPNG(t), 0o644); err != nil { + return nil, err + } + } + } + return nil, nil + } + t.Cleanup(func() { runTool = prev }) + return func() int { + mu.Lock() + defer mu.Unlock() + return n + } +} + +// toolPath is a stub executable's path in the single PATH dir latexEnv set. +func toolPath(name string) string { + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(os.Getenv("PATH"), name) +} + +func addTool(t *testing.T, name string) { + t.Helper() + if err := os.WriteFile(toolPath(name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } +} + +func removeTool(t *testing.T, name string) { + t.Helper() + if err := os.Remove(toolPath(name)); err != nil { + t.Fatal(err) + } +} + +// newMathApp is an App with the given prefs, a dark theme and a program +// that reports finished renders. +func newMathApp(t *testing.T, prefs config.Prefs) (*App, chan string) { + t.Helper() + a := &App{ + theme: buildTheme(true), + prefs: prefsView{Prefs: prefs}, + imageStore: &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard}, + } + return a, watchRenders(t, a) +} + +func mathPrefs(engine, images string) config.Prefs { + p := config.DefaultPrefs() + p.TerminalMath = engine + p.TerminalImages = images + return p +} + +// showMath shows a block, waits up to wait for a background render, and +// shows it again: what the reader sees once the render is done. +func showMath(a *App, done chan string, body string, wait time.Duration) []previewLine { + a.mathLines(body, 0, 80) + nextRender(done, wait) + return a.mathLines(body, 0, 80) +} + +func rowIndex(lines []previewLine, sub string) int { + for i, l := range lines { + if strings.Contains(stripAnsi(l.text), sub) { + return i + } + } + return -1 +} + +func preambleExists() bool { + _, err := os.Stat(config.PreamblePath()) + return err == nil +} + +func TestMathEngineRouting(t *testing.T) { // AC1, AC2, AC3, AC4 + for _, tc := range []struct { + name, terminal string + latex bool + }{ + {"no math key (AC1)", "images = \"kitty\"\n", false}, + {"typst (AC2)", "math = \"typst\"\nimages = \"kitty\"\n", false}, + {"latex (AC3)", "math = \"latex\"\nimages = \"kitty\"\n", true}, + {"unknown foo (AC4)", "math = \"foo\"\nimages = \"kitty\"\n", false}, + } { + t.Run(tc.name, func(t *testing.T) { + latexEnv(t) + addTool(t, "typst") + if err := os.WriteFile(config.ConfigTomlPath(), []byte("[terminal]\n"+tc.terminal), 0o644); err != nil { + t.Fatal(err) + } + prefs, _, err := config.LoadPrefs() + if err != nil { + t.Fatalf("LoadPrefs: %v", err) + } + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newMathApp(t, prefs) + body := "$$\nx^2 + y^2\n$$" + a.mathLines(body, 0, 80) + if !nextRender(done, 5*time.Second) { + t.Fatalf("no background render finished; typst runs %d, tex calls %+v", typst(), calls()) + } + lines := a.mathLines(body, 0, 80) + if tc.latex { + if n := typst(); n != 0 { + t.Fatalf("math = latex ran typst %d times, want 0", n) + } + if n := len(renderCalls(calls())); n != 1 { + t.Fatalf("math = latex: %d pdftex renders, want 1; calls %+v", n, calls()) + } + } else { + if n := typst(); n != 1 { + t.Fatalf("typst runs %d, want 1", n) + } + if got := calls(); len(got) != 0 { + t.Fatalf("typst engine ran pdftex or pdftocairo: %+v", got) + } + if strings.Contains(joinPreview(lines), "latex") { + t.Fatalf("typst engine shows a latex message: %s", joinPreview(lines)) + } + } + if !isPicture(lines) { + t.Fatalf("rendered block is not a picture: %s", joinPreview(lines)) + } + }) + } +} + +func TestLatexImagesOffShowsSourceWithoutPdftex(t *testing.T) { // AC14 + for _, images := range []string{"off", "auto"} { + t.Run(images, func(t *testing.T) { + latexEnv(t) + t.Setenv("TERM_PROGRAM", "") + t.Setenv("KITTY_WINDOW_ID", "") + t.Setenv("GHOSTTY_RESOURCES_DIR", "") + t.Setenv("TERM", "xterm-256color") + calls := fakeTeX(t, texFake{}) + a, done := newMathApp(t, mathPrefs("latex", images)) + lines := showMath(a, done, "$$\nx^2 + y^2\n$$", 300*time.Millisecond) + if got := calls(); len(got) != 0 { + t.Fatalf("pdftex ran with Kitty graphics off: %+v", got) + } + if isPicture(lines) || !strings.Contains(joinPreview(lines), "x^2 + y^2") { + t.Fatalf("block is not shown as its raw source: %s", joinPreview(lines)) + } + if preambleExists() { + t.Fatalf("preamble.tex created with Kitty graphics off") + } + }) + } +} + +func TestNoPreambleWithoutDisplayMath(t *testing.T) { // AC17 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, _ := newDatabaseTestApp(t) + a.prefs = prefsView{Prefs: mathPrefs("latex", "kitty")} + a.imageStore = &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard} + done := watchRenders(t, a) + body := "# Doc\n\nInline $x^2$ only.\n\n- a list item\n\n```go\nfunc main() {}\n```\n" + if _, err := a.renderGlamourLinesFrom(body, 80, "Doc.md"); err != nil { + t.Fatal(err) + } + nextRender(done, 300*time.Millisecond) + if preambleExists() { + t.Fatalf("preamble.tex created for a note with no display math") + } + if got := calls(); len(got) != 0 { + t.Fatalf("tools ran for a note with no display math: %+v", got) + } +} + +func TestNoPreambleUnderTypst(t *testing.T) { // AC18 + latexEnv(t) + addTool(t, "typst") + fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newMathApp(t, mathPrefs("typst", "kitty")) + lines := showMath(a, done, "$$\nx^2\n$$", 5*time.Second) + if typst() != 1 || !isPicture(lines) { + t.Fatalf("typst did not render the block: %d runs, %s", typst(), joinPreview(lines)) + } + if preambleExists() { + t.Fatalf("preamble.tex created under math = typst") + } +} + +func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { // AC40, AC41, AC42 + body := "$$\nx^2\n$$" + t.Run("engine (AC40)", func(t *testing.T) { + latexEnv(t) + addTool(t, "typst") + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newMathApp(t, mathPrefs("typst", "kitty")) + if lines := showMath(a, done, body, 5*time.Second); typst() != 1 || !isPicture(lines) { + t.Fatalf("typst did not render the block: %d runs", typst()) + } + a.prefs.TerminalMath = "latex" + showMath(a, done, body, 5*time.Second) + if n := len(renderCalls(calls())); n != 1 { + t.Fatalf("the same block under latex after typst: %d pdftex renders, want 1 (typst entry reused)", n) + } + }) + t.Run("theme (AC41)", func(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + if lines := showMath(a, done, body, 5*time.Second); !isPicture(lines) { + t.Fatalf("first show is not a picture; tex calls %+v", calls()) + } + light := buildTheme(false) + if strings.EqualFold(string(light.Fg), string(a.theme.Fg)) { + t.Fatalf("light and dark themes share fg %s", light.Fg) + } + a.theme = light + showMath(a, done, body, 5*time.Second) + r := renderCalls(calls()) + if len(r) != 2 { + t.Fatalf("after a theme change: %d pdftex renders in all, want 2", len(r)) + } + hex := strings.ToLower(strings.TrimPrefix(string(light.Fg), "#")) + if !strings.Contains(strings.ToLower(r[1].allTeX()), hex) { + t.Fatalf("second render does not use the new theme colour %s:\n%s", hex, r[1].allTeX()) + } + }) + t.Run("preamble (AC42)", func(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + if lines := showMath(a, done, body, 5*time.Second); !isPicture(lines) { + t.Fatalf("first show is not a picture; tex calls %+v", calls()) + } + pre, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatalf("preamble.tex after the first render: %v", err) + } + if err := os.WriteFile(config.PreamblePath(), append(pre, []byte("\n\\usepackage{xspace}\n")...), 0o644); err != nil { + t.Fatal(err) + } + showMath(a, done, body, 5*time.Second) + if n := len(renderCalls(calls())); n != 2 { + t.Fatalf("after preamble.tex changed: %d pdftex renders in all, want 2", n) + } + }) +} + +func TestTypstWritesNothingUnderTexCache(t *testing.T) { // AC45 + _, texDir := latexEnv(t) + addTool(t, "typst") + fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newMathApp(t, mathPrefs("typst", "kitty")) + if lines := showMath(a, done, "$$\nx^2\n$$", 5*time.Second); typst() != 1 || !isPicture(lines) { + t.Fatalf("typst did not render the block: %d runs", typst()) + } + written := []string{} + _ = filepath.Walk(texDir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + written = append(written, p) + } + return nil + }) + if len(written) != 0 { + t.Fatalf("typst render wrote under %s: %v", texDir, written) + } +} + +func TestLatexMissingToolsFallBack(t *testing.T) { // AC52, AC53 + for _, missing := range []string{"pdftex", "pdftocairo"} { + t.Run("no "+missing, func(t *testing.T) { + latexEnv(t) + removeTool(t, missing) + fakeTeX(t, texFake{}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + for i := 0; i < 2; i++ { + lines := showMath(a, done, "$$\nx^2 + y^2\n$$", 500*time.Millisecond) + all := joinPreview(lines) + if isPicture(lines) || !strings.Contains(all, "x^2 + y^2") || strings.Contains(all, "latex:") { + t.Fatalf("without %s the block shows only its raw source: %q", missing, all) + } + } + }) + } +} + +func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 + // wantRow checks that the block is not a picture and that a row holding + // msg sits above the row holding the block's source. + wantRow := func(t *testing.T, lines []previewLine, msg, src string) { + t.Helper() + all := joinPreview(lines) + if isPicture(lines) { + t.Fatalf("failed block is a picture: %s", all) + } + e, s := rowIndex(lines, msg), rowIndex(lines, src) + if e < 0 || s < 0 || e >= s { + t.Fatalf("want a %q row above the source %q, got:\n%s", msg, src, all) + } + } + t.Run("LaTeX error (AC54)", func(t *testing.T) { + latexEnv(t) + log := "This is pdfTeX, Version 3.141592653\n! Undefined control sequence.\nl.3 \\nosuchmacro\n! Emergency stop.\n" + fakeTeX(t, texFake{render: func(_ context.Context, base string) ([]byte, error) { + _ = os.WriteFile(base+".log", []byte(log), 0o644) + return []byte(log), errors.New("exit status 1") + }}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + lines := showMath(a, done, "$$\n\\nosuchmacro\n$$", 5*time.Second) + wantRow(t, lines, "latex: ! Undefined control sequence.", "\\nosuchmacro") + if strings.Contains(joinPreview(lines), "Emergency stop") { + t.Fatalf("error row is not only the first TeX error line: %s", joinPreview(lines)) + } + }) + t.Run("broken preamble (AC27)", func(t *testing.T) { + latexEnv(t) + if err := os.WriteFile(config.PreamblePath(), []byte("\\usepackage{amsmath}\n\\badmacro\n"), 0o644); err != nil { + t.Fatal(err) + } + log := "This is pdfTeX, Version 3.141592653\n! Undefined control sequence.\nl.2 \\badmacro\n! Emergency stop.\n" + fakeTeX(t, texFake{build: func(context.Context) ([]byte, error) { + return []byte(log), errors.New("exit status 1") + }}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + for _, b := range []struct{ body, src string }{ + {"$$\nalpha_1\n$$", "alpha_1"}, + {"$$\nbeta_2\n$$", "beta_2"}, + {"$$ gamma_3 $$", "gamma_3"}, + } { + wantRow(t, showMath(a, done, b.body, 5*time.Second), "! Undefined control sequence.", b.src) + } + }) + t.Run("unwritable config dir (AC20)", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("read-only directories via chmod are not enforced on windows") + } + if os.Geteuid() == 0 { + t.Skip("root writes into read-only directories") + } + cfg, _ := latexEnv(t) + fakeTeX(t, texFake{}) + if err := os.Chmod(cfg, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(cfg, 0o755) }) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + wantRow(t, showMath(a, done, "$$\nx^2 + y^2\n$$", 5*time.Second), "permission denied", "x^2 + y^2") + }) +} + +func TestZenStyleLeavesDollarBlockUnrendered(t *testing.T) { // AC81 + body := "# Doc\n\nBefore.\n\n$$\nx^2 + y^2\n$$\n\nAfter.\n" + for _, engine := range []string{"typst", "latex"} { + t.Run(engine, func(t *testing.T) { + latexEnv(t) + addTool(t, "typst") + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, _ := newDatabaseTestApp(t) + prefs := mathPrefs(engine, "kitty") + prefs.PreviewStyle = "zen" + a.prefs = prefsView{Prefs: prefs} + a.imageStore = &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard} + done := watchRenders(t, a) + a.renderPreviewLinesFrom(body, 80, "Doc.md") + nextRender(done, 300*time.Millisecond) + got := a.renderPreviewLinesFrom(body, 80, "Doc.md") + if n := typst(); n != 0 || len(calls()) != 0 { + t.Fatalf("zen style ran tools: typst %d, tex %+v", n, calls()) + } + // Before this change: zen, typst, no pictures. + b, _ := newDatabaseTestApp(t) + base := mathPrefs("typst", "off") + base.PreviewStyle = "zen" + b.prefs = prefsView{Prefs: base} + want := b.renderPreviewLines(body, 80) + if len(got) != len(want) { + t.Fatalf("zen output changed:\n%s\nwant\n%s", joinPreview(got), joinPreview(want)) + } + for i := range got { + if got[i].text != want[i].text { + t.Fatalf("zen line %d changed: %q, want %q", i, got[i].text, want[i].text) + } + } + if !strings.Contains(joinPreview(got), "x^2 + y^2") { + t.Fatalf("zen output lacks the raw block: %s", joinPreview(got)) + } + }) + } +} + +// --- Step 7: block detection for \[…\] and the AC7 environments --- + +// blockKinds is splitBlocks' result as "kind[start,end)" entries. +func blockKinds(src string, latex bool) []string { + out := []string{} + for _, b := range splitBlocks(strings.Split(src, "\n"), latex) { + out = append(out, fmt.Sprintf("%s[%d,%d)", b.kind, b.start, b.end)) + } + return out +} + +func hasTexBlock(src string, latex bool) bool { + for _, b := range splitBlocks(strings.Split(src, "\n"), latex) { + if b.kind == "tex" { + return true + } + } + return false +} + +// newNoteApp is a glamour-preview App with the given engine and images +// mode and a program that reports finished renders. Call latexEnv first. +func newNoteApp(t *testing.T, engine, images string) (*App, chan string) { + t.Helper() + a, _ := newDatabaseTestApp(t) + a.prefs = prefsView{Prefs: mathPrefs(engine, images)} + a.imageStore = &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard} + return a, watchRenders(t, a) +} + +// showNote shows a note, waits up to wait for one background render, and +// shows it again: what the reader sees once the render is done. +func showNote(a *App, done chan string, body string, wait time.Duration) []previewLine { + a.renderPreviewLinesFrom(body, 80, "Doc.md") + nextRender(done, wait) + return a.renderPreviewLinesFrom(body, 80, "Doc.md") +} + +// beforeChange is the note as shown before this change: typst, no pictures. +func beforeChange(t *testing.T, body string) []previewLine { + t.Helper() + b, _ := newDatabaseTestApp(t) + b.prefs = prefsView{Prefs: mathPrefs("typst", "off")} + return b.renderPreviewLinesFrom(body, 80, "Doc.md") +} + +func sameLines(t *testing.T, got, want []previewLine) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("output changed:\n%s\nwant\n%s", joinPreview(got), joinPreview(want)) + } + for i := range got { + if got[i].text != want[i].text { + t.Fatalf("line %d changed: %q, want %q", i, got[i].text, want[i].text) + } + } +} + +// latexDisplaySources is one block-level source per AC6/AC7 form. +func latexDisplaySources() map[string]string { + srcs := map[string]string{"\\[ \\] (AC6)": "\\[\nx^2 + y^2\n\\]"} + for _, env := range []string{"equation", "align", "gather", "multline", "alignat", "flalign", "eqnarray"} { + for _, name := range []string{env, env + "*"} { + begin := "\\begin{" + name + "}" + if env == "alignat" { + begin += "{2}" + } + srcs[name+" (AC7)"] = begin + "\na &= b + c\n\\end{" + name + "}" + } + } + srcs["tikzpicture (AC7)"] = "\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}" + return srcs +} + +func TestSplitBlocksLatexDisplay(t *testing.T) { // AC6, AC7 + for name, src := range latexDisplaySources() { + t.Run(name, func(t *testing.T) { + n := strings.Count(src, "\n") + 1 + note := "Before.\n\n" + src + "\n\nAfter." + want := fmt.Sprintf("para[0,1) blank[1,2) tex[2,%d) blank[%d,%d) para[%d,%d)", 2+n, 2+n, 3+n, 3+n, 4+n) + if got := strings.Join(blockKinds(note, true), " "); got != want { + t.Fatalf("blocks %q, want %q", got, want) + } + // A paragraph also breaks before the block. + tight := "Before.\n" + src + "\n\nAfter." + wantTight := fmt.Sprintf("para[0,1) tex[1,%d) blank[%d,%d) para[%d,%d)", 1+n, 1+n, 2+n, 2+n, 3+n) + if got := strings.Join(blockKinds(tight, true), " "); got != wantTight { + t.Fatalf("after a paragraph line: blocks %q, want %q", got, wantTight) + } + + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + lines := showNote(a, done, note, 5*time.Second) + if !isPicture(lines) { + t.Fatalf("block is not rendered as a picture: %s; calls %+v", joinPreview(lines), calls()) + } + if r := renderCalls(calls()); len(r) != 1 { + t.Fatalf("%d pdftex renders, want 1; calls %+v", len(r), calls()) + } + if in := renderInput(t, calls()); !strings.Contains(in, src) { + t.Fatalf("pdftex input lacks the block source verbatim:\n%s", in) + } + }) + } +} + +func TestSplitBlocksLatexInsideFenceIsCode(t *testing.T) { // AC8 + for name, src := range latexDisplaySources() { + t.Run(name, func(t *testing.T) { + note := "Before.\n\n```latex\n" + src + "\n```\n\nAfter." + if hasTexBlock(note, true) { + t.Fatalf("source inside a code fence detected as a tex block: %v", blockKinds(note, true)) + } + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if got := calls(); len(got) != 0 { + t.Fatalf("code fence content was rendered: %+v", got) + } + sameLines(t, lines, beforeChange(t, note)) + }) + } +} + +func TestSplitBlocksDollarBlockOwnsEnvironment(t *testing.T) { // AC9 + for _, env := range []string{"align", "equation*", "tikzpicture"} { + t.Run(env, func(t *testing.T) { + note := "$$\n\\begin{" + env + "}\na &= b\n\\end{" + env + "}\n$$" + if got, want := strings.Join(blockKinds(note, true), " "), "math[0,5)"; got != want { + t.Fatalf("blocks %q, want %q", got, want) + } + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + showNote(a, done, note, 5*time.Second) + nextRender(done, 300*time.Millisecond) + if r := renderCalls(calls()); len(r) != 1 { + t.Fatalf("%d pdftex renders for one $$ block, want 1; calls %+v", len(r), calls()) + } + }) + } +} + +func TestSplitBlocksLatexNestedInListQuoteCallout(t *testing.T) { // AC10 + nest := map[string]func(src string) string{ + "list": func(src string) string { + return "- item\n " + strings.ReplaceAll(src, "\n", "\n ") + }, + "quote": func(src string) string { + return "> " + strings.ReplaceAll(src, "\n", "\n> ") + }, + "callout": func(src string) string { + return "> [!note] Title\n> " + strings.ReplaceAll(src, "\n", "\n> ") + }, + } + srcs := latexDisplaySources() + for _, name := range []string{"\\[ \\] (AC6)", "align* (AC7)", "tikzpicture (AC7)"} { + for where, wrap := range nest { + t.Run(where+" "+name, func(t *testing.T) { + note := "Before.\n\n" + wrap(srcs[name]) + "\n\nAfter." + if hasTexBlock(note, true) { + t.Fatalf("nested source detected as a tex block: %v", blockKinds(note, true)) + } + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if got := calls(); len(got) != 0 { + t.Fatalf("nested source was rendered: %+v", got) + } + sameLines(t, lines, beforeChange(t, note)) + }) + } + } +} + +func TestSplitBlocksSingleLineBracket(t *testing.T) { // AC86 + for _, tc := range []struct{ note, want string }{ + {"text\n\\[ x^2 \\]\nmore", "para[0,1) tex[1,2) para[2,3)"}, + {"text\n\n\\[ x^2 \\]\n\nmore", "para[0,1) blank[1,2) tex[2,3) blank[3,4) para[4,5)"}, + {"\\[ x^2 \\]", "tex[0,1)"}, + } { + if got := strings.Join(blockKinds(tc.note, true), " "); got != tc.want { + t.Fatalf("latex blocks for %q: %q, want %q", tc.note, got, tc.want) + } + if hasTexBlock(tc.note, false) { + t.Fatalf("typst detects a tex block in %q: %v", tc.note, blockKinds(tc.note, false)) + } + } + for name, note := range map[string]string{ + "fence": "text\n\n```latex\n\\[ x^2 \\]\n```\n\nmore", + "list item": "- item\n \\[ x^2 \\]\n\nmore", + "list line": "- \\[ x^2 \\]\n\nmore", + } { + if hasTexBlock(note, true) { + t.Fatalf("%s: one-line \\[ \\] detected as a tex block: %v", name, blockKinds(note, true)) + } + } +} + +func TestSplitBlocksTypstLeavesLatexAsText(t *testing.T) { // AC11 + // Block kinds as splitBlocks returned them before this change. + for _, tc := range []struct{ note, want string }{ + {"Before.\n\n\\[\nx^2\n\\]\n\nAfter.", "para[0,1) blank[1,2) para[2,5) blank[5,6) para[6,7)"}, + {"Before.\n\\[\nx^2\n\\]\nAfter.", "para[0,5)"}, + {"Before.\n\n\\begin{align*}\na &= b\n\\end{align*}\n\nAfter.", "para[0,1) blank[1,2) para[2,5) blank[5,6) para[6,7)"}, + {"\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}", "para[0,3)"}, + } { + if got := strings.Join(blockKinds(tc.note, false), " "); got != tc.want { + t.Fatalf("typst blocks for %q: %q, want %q", tc.note, got, tc.want) + } + } + for name, src := range latexDisplaySources() { + t.Run(name, func(t *testing.T) { + note := "Before.\n\n" + src + "\n\nAfter." + if hasTexBlock(note, false) { + t.Fatalf("typst detects a tex block: %v", blockKinds(note, false)) + } + latexEnv(t) + addTool(t, "typst") + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newNoteApp(t, "typst", "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if n := typst(); n != 0 || len(calls()) != 0 { + t.Fatalf("typst engine rendered latex source: typst %d, tex %+v", n, calls()) + } + sameLines(t, lines, beforeChange(t, note)) + }) + } +} + +func TestTikzFenceStaysCodeBlock(t *testing.T) { // AC12 + note := "Before.\n\n```tikz\n\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}\n```\n\nAfter." + for _, engine := range []string{"typst", "latex"} { + t.Run(engine, func(t *testing.T) { + if got, want := strings.Join(blockKinds(note, engine == "latex"), " "), "para[0,1) blank[1,2) fence[2,7) blank[7,8) para[8,9)"; got != want { + t.Fatalf("blocks %q, want %q", got, want) + } + latexEnv(t) + addTool(t, "typst") + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newNoteApp(t, engine, "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if n := typst(); n != 0 || len(calls()) != 0 { + t.Fatalf("tikz fence was rendered: typst %d, tex %+v", n, calls()) + } + if isPicture(lines) || !strings.Contains(joinPreview(lines), "\\draw (0,0) -- (1,1);") { + t.Fatalf("tikz fence is not shown as a code block: %s", joinPreview(lines)) + } + sameLines(t, lines, beforeChange(t, note)) + }) + } +} + +func TestInlineMathUnchangedUnderLatex(t *testing.T) { // AC13 + note := "Inline $x^2$ and $\\alpha + \\beta$ in a line.\n\nAnd \\(y\\) too.\n" + if hasTexBlock(note, true) { + t.Fatalf("inline math detected as a tex block: %v", blockKinds(note, true)) + } + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if got := calls(); len(got) != 0 { + t.Fatalf("inline math was rendered: %+v", got) + } + sameLines(t, lines, beforeChange(t, note)) +} diff --git a/internal/tui/glamour_test.go b/internal/tui/glamour_test.go index 88a7eab..c6ad974 100644 --- a/internal/tui/glamour_test.go +++ b/internal/tui/glamour_test.go @@ -14,7 +14,7 @@ import ( func TestSplitBlocks(t *testing.T) { src := "---\ntitle: x\n---\n# Heading\n\npara one\npara two\n\n- [ ] a\n- [x] b\n - child\n\n```go\ncode\n```\n> quote\n| a | b |\n| - | - |\n" - blocks := splitBlocks(strings.Split(src, "\n")) + blocks := splitBlocks(strings.Split(src, "\n"), false) kinds := []string{} for _, b := range blocks { kinds = append(kinds, b.kind) diff --git a/internal/tui/latex.go b/internal/tui/latex.go new file mode 100644 index 0000000..0577601 --- /dev/null +++ b/internal/tui/latex.go @@ -0,0 +1,371 @@ +package tui + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + "image/png" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + + "github.com/ZenNotes/tui/internal/config" +) + +// defaultPreamble is written to preamble.tex on the first latex render when +// the file is missing. The user owns the file after that. +const defaultPreamble = `% zn LaTeX preamble: loaded before every display-math block when +% [terminal] math = "latex". Edit freely; zn never rewrites this file. +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage[version=4]{mhchem} +\usepackage{tikz} +\usetikzlibrary{arrows.meta,calc} +` + +// formatWrapper goes around preamble.tex in the precompiled format. The +// theme colour is set per render, so it stays out of the format. +const formatWrapper = "\\documentclass[border=2pt,varwidth]{standalone}\n\\RequirePackage{xcolor}\n" + +// preambleMu serialises ensurePreamble: on Windows a read or a rename +// racing another render's rename of preamble.tex fails with Access is +// denied. +var preambleMu sync.Mutex + +// ensurePreamble returns preamble.tex, writing the default when it is +// missing. An existing file is never rewritten. +func ensurePreamble() ([]byte, error) { + preambleMu.Lock() + defer preambleMu.Unlock() + path := config.PreamblePath() + b, err := os.ReadFile(path) + if err == nil { + return b, nil + } + if !errors.Is(err, os.ErrNotExist) { + return nil, preambleError(err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, preambleError(err) + } + // Write a temp file and rename it into place: parallel first renders + // read the file meanwhile and must never see it empty or half written, + // or they would build a format from a truncated preamble. Racing + // writers all write the same default bytes. + tmp, err := os.CreateTemp(filepath.Dir(path), ".preamble-*.tex") + if err != nil { + return nil, preambleError(err) + } + _, werr := tmp.WriteString(defaultPreamble) + cerr := tmp.Close() + if err := errors.Join(werr, cerr, os.Chmod(tmp.Name(), 0o644)); err != nil { + os.Remove(tmp.Name()) + return nil, preambleError(err) + } + if err := os.Rename(tmp.Name(), path); err != nil { + os.Remove(tmp.Name()) + // Windows refuses to rename onto a file another zn has open; that + // zn already put the preamble in place. + if b, rerr := os.ReadFile(path); rerr == nil { + return b, nil + } + return nil, preambleError(err) + } + return []byte(defaultPreamble), nil +} + +// preambleError drops the (temp) file path, so the preview's error row is +// short enough to show the cause (AC20): "preamble.tex: open: permission +// denied". +func preambleError(err error) error { + var pe *fs.PathError + if errors.As(err, &pe) { + return fmt.Errorf("preamble.tex: %s: %w", pe.Op, pe.Err) + } + return fmt.Errorf("preamble.tex: %w", err) +} + +var hexColour = regexp.MustCompile(`^#?([0-9a-fA-F]{6})$`) + +// latexDocument is the per-render source: the body of a document whose +// preamble lives in the format. $$ blocks become \[ … \]; \[…\] and +// environment blocks pass through verbatim. +func latexDocument(src, fg string) string { + body := strings.TrimSpace(src) + if len(body) >= 4 && strings.HasPrefix(body, "$$") && strings.HasSuffix(body, "$$") { + body = "\\[\n" + strings.TrimSpace(body[2:len(body)-2]) + "\n\\]" + } + colour := "" + if m := hexColour.FindStringSubmatch(fg); m != nil { + colour = "\\color[HTML]{" + strings.ToUpper(m[1]) + "}" + } + return "\\begin{document}" + colour + "\n" + body + "\n\\end{document}\n" +} + +// latexDir is /zn/tex: formats and, later, PNGs. +func latexDir() (string, error) { + c, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(c, "zn", "tex"), nil +} + +// texEnv sandboxes pdftex: no reads or writes outside the working dir +// (AC29), and formats found in the cache dir before the TeX defaults. +func texEnv(texDir string) []string { + return []string{"openin_any=p", "openout_any=p", "TEXFORMATS=" + texDir + string(os.PathListSeparator)} +} + +// texSandbox is passed to every pdftex run (AC28). +var texSandbox = []string{"-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error"} + +// texError is the first "! " line of a TeX log, else the output's first line. +// A timeout stays recognisable with errors.Is(err, context.DeadlineExceeded). +func texError(out []byte, logPath string, err error) error { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("latex: pdftex: %w", err) + } + logs := [][]byte{out} + if b, rerr := os.ReadFile(logPath); rerr == nil { + logs = append(logs, b) + } + for _, l := range logs { + for _, line := range strings.Split(string(l), "\n") { + if strings.HasPrefix(line, "! ") { + return fmt.Errorf("latex: %s", strings.TrimSpace(line)) + } + } + } + return fmt.Errorf("latex: %s", firstLine(strings.TrimSpace(string(out)+" "+err.Error()))) +} + +// latexFormats single-flights format builds per App and memoises TeX +// failures per format name, so every block shows the same error until the +// preamble changes. A timed-out build is not remembered: the next block +// tries again (AC82). +type latexFormats struct { + mu sync.Mutex + builds map[string]*fmtBuild +} + +type fmtBuild struct { + done chan struct{} + err error +} + +// ensure makes sure /.fmt exists, building it from src at +// most once at a time. A format deleted from disk is rebuilt. +func (l *latexFormats) ensure(ctx context.Context, texDir, name, src string) error { + path := filepath.Join(texDir, name+".fmt") + l.mu.Lock() + if l.builds == nil { + l.builds = map[string]*fmtBuild{} + } + b := l.builds[name] + // isClosed first: b.err is only safe to read once done is closed. + if b != nil && isClosed(b.done) && b.err == nil && !fileExists(path) { + b = nil + } + if b == nil { + b = &fmtBuild{done: make(chan struct{})} + l.builds[name] = b + l.mu.Unlock() + if !fileExists(path) { + b.err = buildFormat(ctx, texDir, name, src) + } + if errors.Is(b.err, context.DeadlineExceeded) { + l.mu.Lock() + if l.builds[name] == b { + delete(l.builds, name) + } + l.mu.Unlock() + } + close(b.done) + } else { + l.mu.Unlock() + } + select { + case <-b.done: + return b.err + case <-ctx.Done(): + return fmt.Errorf("latex: format build: %w", ctx.Err()) + } +} + +func isClosed(c chan struct{}) bool { + select { + case <-c: + return true + default: + return false + } +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +// buildFormat dumps the wrapper and preamble into a format in a temp dir +// inside texDir, then renames it into place so readers never see half a file. +func buildFormat(ctx context.Context, texDir, name, src string) error { + if err := os.MkdirAll(texDir, 0o755); err != nil { + return fmt.Errorf("latex: %w", err) + } + dir, err := os.MkdirTemp(texDir, "build-") + if err != nil { + return fmt.Errorf("latex: %w", err) + } + defer os.RemoveAll(dir) + if err := os.WriteFile(filepath.Join(dir, "fmt.tex"), []byte(src+"\n\\dump\n"), 0o644); err != nil { + return fmt.Errorf("latex: %w", err) + } + args := append([]string{"-ini"}, texSandbox...) + args = append(args, "-jobname="+name, "&pdflatex", "fmt.tex") + if out, err := runTool(ctx, dir, texEnv(texDir), "pdftex", args...); err != nil { + return texError(out, filepath.Join(dir, name+".log"), err) + } + if err := os.Rename(filepath.Join(dir, name+".fmt"), filepath.Join(texDir, name+".fmt")); err != nil { + // On Windows the rename fails while another zn has the same format + // open; that format is the one this build made. + if _, serr := os.Stat(filepath.Join(texDir, name+".fmt")); serr != nil { + return fmt.Errorf("latex: %w", err) + } + } + return nil +} + +// latexDPI rasterises 10pt TeX at about the size of typst's 18pt at 144 ppi. +const latexDPI = "260" + +// latexCropBorder is the transparent margin, in px, kept around the ink +// when a rendered PNG is cropped. standalone sets display math at full line +// width, so without the crop a short formula is a wide mostly empty picture. +const latexCropBorder = 8 + +// cropToInk crops a PNG to the bounding box of its pixels with alpha > 0, +// grown by border px on each side and clamped to the image. A fully +// transparent image, or one whose box is the whole image, comes back as is. +func cropToInk(data []byte, border int) ([]byte, error) { + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + return nil, err + } + b := img.Bounds() + box := image.Rectangle{} + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if _, _, _, a := img.At(x, y).RGBA(); a > 0 { + box = box.Union(image.Rect(x, y, x+1, y+1)) + } + } + } + if box.Empty() { + return data, nil + } + if box = box.Inset(-border).Intersect(b); box == b { + return data, nil + } + sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }) + if !ok { + return data, nil + } + var buf bytes.Buffer + if err := png.Encode(&buf, sub.SubImage(box)); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// latexPNG renders one block to PNG bytes with the format built from +// preamble.tex. The PNG is cached on disk in /zn/tex, named +// by colour, format and source, so a later session runs no tools for it. +func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byte, error) { + preamble, err := ensurePreamble() + if err != nil { + return nil, err + } + texDir, err := latexDir() + if err != nil { + return nil, fmt.Errorf("latex: %w", err) + } + fmtSrc := formatWrapper + string(preamble) + name := latexFormatName(preamble) + cached := filepath.Join(texDir, strings.TrimPrefix(latexKey(fg, name, src), "math:")+".png") + if b, err := os.ReadFile(cached); err == nil { + return b, nil + } + if err := formats.ensure(ctx, texDir, name, fmtSrc); err != nil { + return nil, err + } + dir, err := os.MkdirTemp("", "zn-latex") + if err != nil { + return nil, err + } + defer os.RemoveAll(dir) + if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(latexDocument(src, fg)), 0o644); err != nil { + return nil, err + } + args := append([]string{"-fmt=" + name}, texSandbox...) + args = append(args, "m.tex") + if out, err := runTool(ctx, dir, texEnv(texDir), "pdftex", args...); err != nil { + return nil, texError(out, filepath.Join(dir, "m.log"), err) + } + if out, err := runTool(ctx, dir, nil, "pdftocairo", "-png", "-transp", "-singlefile", "-r", latexDPI, "m.pdf", "m"); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return nil, fmt.Errorf("latex: pdftocairo: %w", err) + } + return nil, fmt.Errorf("latex: pdftocairo: %s", firstLine(strings.TrimSpace(string(out)+" "+err.Error()))) + } + png, err := os.ReadFile(filepath.Join(dir, "m.png")) + if err != nil { + return nil, fmt.Errorf("latex: %w", err) + } + if png, err = cropToInk(png, latexCropBorder); err != nil { + return nil, fmt.Errorf("latex: crop: %w", err) + } + // The disk cache is best effort: a failed write only costs a re-render + // next session, and the picture is still shown now. + if tmp, err := os.CreateTemp(texDir, "png-"); err == nil { + _, werr := tmp.Write(png) + cerr := tmp.Close() + if werr != nil || cerr != nil || os.Rename(tmp.Name(), cached) != nil { + os.Remove(tmp.Name()) + } + } + return png, nil +} + +// latexFormatName is zn-: the format's name +// and the preamble part of every latex key. +func latexFormatName(preamble []byte) string { + sum := sha256.Sum256([]byte(formatWrapper + string(preamble))) + return "zn-" + hex.EncodeToString(sum[:])[:16] +} + +// currentLatexFormat is the format name for preamble.tex as it is now. It +// only reads: a missing file hashes as the default the render will write. +func currentLatexFormat() string { + b, err := os.ReadFile(config.PreamblePath()) + if err != nil { + b = []byte(defaultPreamble) + } + return latexFormatName(b) +} + +// latexKey names a latex block in memory and, without the "math:" prefix, +// its PNG on disk: theme colour, format (wrapper + preamble hash) and source. +// crop1 versions the key so uncropped PNGs from before cropToInk are not read. +func latexKey(fg, format, src string) string { + return contentKey("math", "latex\x00crop1\x00"+fg+"\x00"+format+"\x00"+src) +} diff --git a/internal/tui/latex_test.go b/internal/tui/latex_test.go new file mode 100644 index 0000000..a18c9df --- /dev/null +++ b/internal/tui/latex_test.go @@ -0,0 +1,1251 @@ +package tui + +import ( + "bytes" + "context" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "io" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/config" +) + +// --- Step 4: LaTeX pipeline, part 1: preamble, document, format, sandbox --- +// +// The pipeline is driven through the synchronous seam renderEmbed with a +// renderReq of kind "latex" whose src is the block's whole source, $$ +// delimiters included. External tools are faked through runTool. + +// texCall is one recorded pdftex or pdftocairo run. +type texCall struct { + name string + args []string + env []string + tex map[string]string // every .tex file in the working dir when the call started + deadline time.Duration // time left on ctx when the call started; 0 when none +} + +func (c texCall) isBuild() bool { + if c.name != "pdftex" { + return false + } + for _, a := range c.args { + if a == "-ini" || a == "--ini" { + return true + } + } + return false +} + +func (c texCall) isRender() bool { return c.name == "pdftex" && !c.isBuild() } + +// allTeX is the content of every .tex file the call saw, in name order. +func (c texCall) allTeX() string { + names := make([]string, 0, len(c.tex)) + for n := range c.tex { + names = append(names, n) + } + sort.Strings(names) + var b strings.Builder + for _, n := range names { + b.WriteString(c.tex[n]) + b.WriteString("\n") + } + return b.String() +} + +// flagValue returns the value of -flag=value or -flag value. +func (c texCall) flagValue(flag string) (string, bool) { + for i, a := range c.args { + for _, p := range []string{"-" + flag, "--" + flag} { + if strings.HasPrefix(a, p+"=") { + return strings.TrimPrefix(a, p+"="), true + } + if a == p && i+1 < len(c.args) { + return c.args[i+1], true + } + } + } + return "", false +} + +func (c texCall) envValue(key string) (string, bool) { + for _, kv := range c.env { + if k, v, ok := strings.Cut(kv, "="); ok && k == key { + return v, true + } + } + return "", false +} + +// texFake configures the fake tools. +type texFake struct { + // build, when set, replaces a successful format build. + build func(ctx context.Context) ([]byte, error) + // buildDelay holds each successful build for a while. + buildDelay time.Duration + // render, when set, replaces a successful pdftex render. base is the + // render's .tex path in its working dir, without the extension. + render func(ctx context.Context, base string) ([]byte, error) + // cairo, when set, replaces a successful pdftocairo run. + cairo func(ctx context.Context) ([]byte, error) +} + +// fakeTeX swaps runTool for fake pdftex and pdftocairo. A format build +// writes .fmt, a render writes .pdf and .log, and +// pdftocairo writes .png, each in the call's working dir. +func fakeTeX(t *testing.T, f texFake) func() []texCall { + t.Helper() + var mu sync.Mutex + calls := []texCall{} + inDir := func(dir, p string) string { + if filepath.IsAbs(p) { + return p + } + return filepath.Join(dir, p) + } + prev := runTool + runTool = func(ctx context.Context, dir string, env []string, name string, args ...string) ([]byte, error) { + c := texCall{ + name: strings.TrimSuffix(filepath.Base(name), ".exe"), + args: append([]string(nil), args...), + env: append([]string(nil), env...), + tex: map[string]string{}, + } + if dl, ok := ctx.Deadline(); ok { + c.deadline = time.Until(dl) + } + texArgs := []string{} + for _, a := range args { + if strings.HasSuffix(a, ".tex") { + texArgs = append(texArgs, a) + } + } + if entries, err := os.ReadDir(dir); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tex") { + b, _ := os.ReadFile(filepath.Join(dir, e.Name())) + c.tex[e.Name()] = string(b) + } + } + } + for _, a := range texArgs { + if b, err := os.ReadFile(inDir(dir, a)); err == nil { + c.tex[filepath.Base(a)] = string(b) + } + } + mu.Lock() + calls = append(calls, c) + mu.Unlock() + + switch { + case c.isBuild(): + if f.build != nil { + return f.build(ctx) + } + if f.buildDelay > 0 { + select { + case <-time.After(f.buildDelay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + job, ok := c.flagValue("jobname") + if !ok && len(texArgs) > 0 { + job = strings.TrimSuffix(filepath.Base(texArgs[len(texArgs)-1]), ".tex") + } + if err := os.WriteFile(inDir(dir, job+".fmt"), []byte("fake format"), 0o644); err != nil { + return nil, err + } + return []byte("Beginning to dump on file " + job + ".fmt\n"), nil + case c.isRender(): + if len(texArgs) == 0 { + return []byte("! Emergency stop."), errors.New("exit status 1") + } + base := strings.TrimSuffix(inDir(dir, texArgs[len(texArgs)-1]), ".tex") + if f.render != nil { + return f.render(ctx, base) + } + if err := os.WriteFile(base+".pdf", []byte("%PDF-1.5 fake"), 0o644); err != nil { + return nil, err + } + _ = os.WriteFile(base+".log", []byte("This is pdfTeX\nOutput written on m.pdf\n"), 0o644) + return []byte("Output written on m.pdf (1 page)."), nil + case c.name == "pdftocairo": + if f.cairo != nil { + return f.cairo(ctx) + } + wrote := false + for _, a := range args { + if strings.HasSuffix(a, ".png") { + if err := os.WriteFile(inDir(dir, a), tinyPNG(t), 0o644); err != nil { + return nil, err + } + wrote = true + } + } + if last := args[len(args)-1]; !wrote && !strings.HasPrefix(last, "-") && !strings.HasSuffix(last, ".pdf") { + if err := os.WriteFile(inDir(dir, last)+".png", tinyPNG(t), 0o644); err != nil { + return nil, err + } + } + return nil, nil + } + return nil, fmt.Errorf("unexpected tool %s", c.name) + } + t.Cleanup(func() { runTool = prev }) + return func() []texCall { + mu.Lock() + defer mu.Unlock() + return append([]texCall(nil), calls...) + } +} + +// latexEnv points the config dir and the user cache dir at temp dirs, puts +// stub pdftex and pdftocairo on PATH, and returns the config dir and the +// expected /zn/tex. +func latexEnv(t *testing.T) (cfgDir, texDir string) { + t.Helper() + cfgDir = t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", cfgDir) + cache := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cache) + t.Setenv("HOME", cache) + t.Setenv("LocalAppData", cache) + ucd, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + bin := t.TempDir() + for _, tool := range []string{"pdftex", "pdftocairo"} { + name := tool + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", bin) + return cfgDir, filepath.Join(ucd, "zn", "tex") +} + +// newLatexApp is a fresh App (a new zn session) under math = "latex" with +// Kitty pictures on and a dark theme. It reuses the current env. +func newLatexApp(t *testing.T, concurrency int) *App { + t.Helper() + prefs := config.DefaultPrefs() + prefs.TerminalMath = "latex" + prefs.TerminalImages = "kitty" + if concurrency > 0 { + prefs.RenderConcurrency = concurrency + } + return &App{ + theme: buildTheme(true), + prefs: prefsView{Prefs: prefs}, + imageStore: &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard}, + } +} + +func renderLatex(a *App, src string, timeout time.Duration) *termImage { + return a.renderEmbed(contentKey("math", "latex\x00"+src), renderReq{kind: "latex", src: src, dark: true, maxCols: 80, timeout: timeout}) +} + +func renderCalls(calls []texCall) []texCall { + out := []texCall{} + for _, c := range calls { + if c.isRender() { + out = append(out, c) + } + } + return out +} + +func buildCalls(calls []texCall) []texCall { + out := []texCall{} + for _, c := range calls { + if c.isBuild() { + out = append(out, c) + } + } + return out +} + +// renderInput is the document pdftex rendered, or fails the test. +func renderInput(t *testing.T, calls []texCall) string { + t.Helper() + r := renderCalls(calls) + if len(r) == 0 { + t.Fatalf("pdftex rendered nothing; calls: %+v", calls) + } + return r[len(r)-1].allTeX() +} + +func fmtFiles(t *testing.T, texDir string) []string { + t.Helper() + m, _ := filepath.Glob(filepath.Join(texDir, "*.fmt")) + return m +} + +func failed(img *termImage) bool { return img == nil || img.err != nil } + +func imgErr(img *termImage) error { + if img == nil { + return errors.New("") + } + return img.err +} + +func TestLatexDocumentIsStandaloneWithBody(t *testing.T) { // AC15 + for _, src := range []string{ + "\\[ a + b = c \\]", + "\\begin{align}\n a &= b \\\\\n c &= d\n\\end{align}", + "\\begin{tikzpicture}\n \\draw (0,0) -- (1,1);\n\\end{tikzpicture}", + } { + t.Run(firstLine(src), func(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + renderLatex(newLatexApp(t, 0), src, 10*time.Second) + got := calls() + doc := renderInput(t, got) + for _, b := range buildCalls(got) { + doc = b.allTeX() + "\n" + doc + } + if !regexp.MustCompile(`\\documentclass(\[[^\]]*\])?\{standalone\}`).MatchString(doc) { + t.Fatalf("document is not standalone-class:\n%s", doc) + } + body := renderInput(t, got) + begin, end := strings.Index(body, `\begin{document}`), strings.LastIndex(body, `\end{document}`) + at := strings.Index(body, src) + if begin < 0 || end < 0 || at < begin || at > end { + t.Fatalf("body is not the verbatim block source inside the document:\n%s", body) + } + }) + } +} + +func TestLatexDollarBlockWrappedAsDisplay(t *testing.T) { // AC73 + for _, tc := range []struct{ name, src, want string }{ + {"one line", "$$ x^2 $$", `\\\[\s*x\^2\s*\\\]`}, + {"multi line", "$$\na^2\n+ b^2\n$$", `\\\[\s*a\^2\n\+ b\^2\s*\\\]`}, + } { + t.Run(tc.name, func(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + renderLatex(newLatexApp(t, 0), tc.src, 10*time.Second) + body := renderInput(t, calls()) + if strings.Contains(body, "$$") { + t.Fatalf("$$ reached pdftex:\n%s", body) + } + if !regexp.MustCompile(tc.want).MatchString(body) { + t.Fatalf("inner source not wrapped as \\[ … \\]:\n%s", body) + } + }) + } +} + +func TestLatexThemeColourAfterPreambleNotInFile(t *testing.T) { // AC22 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a := newLatexApp(t, 0) + renderLatex(a, "\\[ x \\]", 10*time.Second) + got := calls() + hex := strings.ToLower(strings.TrimPrefix(string(a.theme.Fg), "#")) + if len(hex) != 6 { + t.Fatalf("theme fg %q is not a hex colour", a.theme.Fg) + } + body := strings.ToLower(renderInput(t, got)) + at := strings.Index(body, hex) + if !strings.Contains(body, `\color`) || at < 0 { + t.Fatalf("render document sets no \\color for theme fg %s:\n%s", hex, body) + } + if b := strings.Index(body, `\begin{document}`); b < 0 || at < b { + t.Fatalf("theme colour is not set after the preamble:\n%s", body) + } + pre, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatalf("preamble.tex: %v", err) + } + if strings.Contains(string(pre), `\color`) || strings.Contains(strings.ToLower(string(pre)), hex) { + t.Fatalf("preamble.tex holds the theme colour:\n%s", pre) + } +} + +func TestEnsurePreambleWritesDefaultPackages(t *testing.T) { // AC16 + latexEnv(t) + fakeTeX(t, texFake{}) + if _, err := os.Stat(config.PreamblePath()); !os.IsNotExist(err) { + t.Fatalf("preamble.tex exists before the first render: %v", err) + } + renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + b, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatalf("first latex render creates preamble.tex: %v", err) + } + pre := string(b) + for _, pkg := range []string{"amsmath", "amssymb", "mhchem", "tikz"} { + re := regexp.MustCompile(`(?m)^[^%\n]*\\usepackage(\[[^\]]*\])?\{[^}]*\b` + pkg + `\b[^}]*\}`) + if !re.MatchString(pre) { + t.Errorf("preamble.tex does not load %s:\n%s", pkg, pre) + } + } + if !strings.Contains(pre, `\usetikzlibrary{arrows.meta,calc}`) { + t.Errorf("preamble.tex lacks \\usetikzlibrary{arrows.meta,calc}:\n%s", pre) + } +} + +func TestEnsurePreambleKeepsEditedBytes(t *testing.T) { // AC19 + latexEnv(t) + fakeTeX(t, texFake{}) + mine := []byte("% my preamble\r\n\\usepackage{amsmath}\n\\newcommand{\\R}{\\mathbb{R}}") + if err := os.WriteFile(config.PreamblePath(), mine, 0o600); err != nil { + t.Fatal(err) + } + a := newLatexApp(t, 0) + renderLatex(a, "\\[ x \\]", 10*time.Second) + renderLatex(a, "$$ y $$", 10*time.Second) + got, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatal(err) + } + if string(got) != string(mine) { + t.Fatalf("edited preamble.tex changed:\n%q\nwant\n%q", got, mine) + } +} + +// Parallel first renders must never read a half-written preamble.tex, or +// they build a second format from a truncated preamble. +func TestEnsurePreambleParallelFirstReadsSeeWholeFile(t *testing.T) { // risk: single-flight + for round := 0; round < 20; round++ { + latexEnv(t) + var wg sync.WaitGroup + got := make([]string, 8) + for i := range got { + wg.Add(1) + go func(i int) { + defer wg.Done() + b, err := ensurePreamble() + if err != nil { + got[i] = "error: " + err.Error() + return + } + got[i] = string(b) + }(i) + } + wg.Wait() + for i, g := range got { + if g != defaultPreamble { + t.Fatalf("round %d, reader %d got %q, want the default preamble", round, i, g) + } + } + } +} + +func TestEnsurePreambleReportsWriteError(t *testing.T) { // AC20 + if runtime.GOOS == "windows" { + t.Skip("read-only directories via chmod are not enforced on windows") + } + if os.Geteuid() == 0 { + t.Skip("root writes into read-only directories") + } + cfg, _ := latexEnv(t) + fakeTeX(t, texFake{}) + if err := os.Chmod(cfg, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(cfg, 0o755) }) + img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + if !failed(img) { + t.Fatalf("render without a writable preamble.tex is not a picture") + } + if img == nil || img.err == nil || !strings.Contains(img.err.Error(), "permission denied") { + t.Fatalf("render error carries the preamble write error, got %v", imgErr(img)) + } +} + +func TestFormatBuiltIntoCacheAndUsed(t *testing.T) { // AC24 + _, texDir := latexEnv(t) + calls := fakeTeX(t, texFake{}) + renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + got := calls() + if n := len(buildCalls(got)); n != 1 { + t.Fatalf("want one format build, got %d; calls %+v", n, got) + } + fmts := fmtFiles(t, texDir) + if len(fmts) != 1 { + t.Fatalf("want one .fmt in %s, got %v", texDir, fmts) + } + name := strings.TrimSuffix(filepath.Base(fmts[0]), ".fmt") + r := renderCalls(got) + if len(r) != 1 { + t.Fatalf("want one pdftex render, got %+v", got) + } + f, ok := r[0].flagValue("fmt") + if !ok || strings.TrimSuffix(filepath.Base(f), ".fmt") != name { + t.Fatalf("render does not use format %s: args %q", name, r[0].args) + } + if !filepath.IsAbs(f) { + v, _ := r[0].envValue("TEXFORMATS") + found := false + for _, p := range filepath.SplitList(v) { + if filepath.Clean(p) == filepath.Clean(texDir) { + found = true + } + } + if !found { + t.Fatalf("render cannot find the format: TEXFORMATS=%q, want %s on it", v, texDir) + } + } +} + +func TestFormatNotRebuiltAcrossSessions(t *testing.T) { // AC25 + _, texDir := latexEnv(t) + calls := fakeTeX(t, texFake{}) + first := newLatexApp(t, 0) + renderLatex(first, "\\[ x \\]", 10*time.Second) + renderLatex(first, "\\[ y \\]", 10*time.Second) + if n := len(buildCalls(calls())); n != 1 { + t.Fatalf("one session builds the format once, got %d builds", n) + } + before := fmtFiles(t, texDir) + renderLatex(newLatexApp(t, 0), "\\[ z \\]", 10*time.Second) + got := calls() + if n := len(buildCalls(got)); n != 1 { + t.Fatalf("a later session rebuilt the format: %d builds in total", n) + } + if len(renderCalls(got)) != 3 { + t.Fatalf("want three renders, got %+v", got) + } + if after := fmtFiles(t, texDir); strings.Join(after, ",") != strings.Join(before, ",") { + t.Fatalf("format files changed: %v -> %v", before, after) + } +} + +func TestFormatRebuiltWhenPreambleChanges(t *testing.T) { // AC26 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a := newLatexApp(t, 0) + renderLatex(a, "\\[ x \\]", 10*time.Second) + pre, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.PreamblePath(), append(pre, []byte("\n\\usepackage{bm}\n")...), 0o644); err != nil { + t.Fatal(err) + } + renderLatex(a, "\\[ \\bm{y} \\]", 10*time.Second) + got := calls() + builds := buildCalls(got) + if len(builds) != 2 { + t.Fatalf("changed preamble rebuilds the format: %d builds", len(builds)) + } + if !strings.Contains(builds[1].allTeX(), `\usepackage{bm}`) { + t.Fatalf("rebuild does not use the new preamble:\n%s", builds[1].allTeX()) + } + r := renderCalls(got) + if len(r) != 2 { + t.Fatalf("want two renders, got %+v", got) + } + f0, _ := r[0].flagValue("fmt") + f1, _ := r[1].flagValue("fmt") + if f0 == f1 { + t.Fatalf("render after the change still uses format %q", f1) + } + // The rebuild comes before the render that needs it. + bi, ri := -1, -1 + for i, c := range got { + if c.isBuild() { + bi = i + } + if c.isRender() { + ri = i + } + } + if bi > ri { + t.Fatalf("format rebuilt after the render: calls %+v", got) + } +} + +func TestFormatBuiltOnceForParallelRenders(t *testing.T) { // risk: single-flight + _, texDir := latexEnv(t) + calls := fakeTeX(t, texFake{buildDelay: 300 * time.Millisecond}) + a := newLatexApp(t, 4) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + renderLatex(a, fmt.Sprintf("\\[ x^%d \\]", i), 10*time.Second) + }(i) + } + wg.Wait() + got := calls() + if n := len(buildCalls(got)); n != 1 { + t.Fatalf("parallel first renders build the format once, got %d builds", n) + } + if n := len(renderCalls(got)); n != 4 { + t.Fatalf("want four renders, got %d", n) + } + if fmts := fmtFiles(t, texDir); len(fmts) != 1 { + t.Fatalf("want one .fmt in the cache, got %v", fmts) + } +} + +func TestFormatErrorFailsEveryBlockWithFirstLine(t *testing.T) { // AC27 + latexEnv(t) + if err := os.WriteFile(config.PreamblePath(), []byte("\\usepackage{amsmath}\n\\badmacro\n"), 0o644); err != nil { + t.Fatal(err) + } + log := "This is pdfTeX, Version 3.141592653\n" + + "! Undefined control sequence.\n" + + "l.2 \\badmacro\n" + + "! Emergency stop.\n" + fakeTeX(t, texFake{build: func(context.Context) ([]byte, error) { + return []byte(log), errors.New("exit status 1") + }}) + a := newLatexApp(t, 0) + for _, src := range []string{"\\[ x \\]", "$$ y $$", "\\begin{align} a &= b \\end{align}"} { + img := renderLatex(a, src, 10*time.Second) + if !failed(img) { + t.Fatalf("%q renders as a picture with a broken preamble", src) + } + if img == nil || img.err == nil || !strings.Contains(img.err.Error(), "! Undefined control sequence.") { + t.Fatalf("%q: error names the first TeX error line, got %v", src, imgErr(img)) + } + if strings.Contains(img.err.Error(), "Emergency stop") { + t.Fatalf("%q: error is not only the first line: %v", src, img.err) + } + } +} + +func TestFormatBuildTimeoutFailsBlocks(t *testing.T) { // AC33 + latexEnv(t) + calls := fakeTeX(t, texFake{build: func(ctx context.Context) ([]byte, error) { + <-ctx.Done() + return nil, ctx.Err() + }}) + a := newLatexApp(t, 0) + start := time.Now() + for _, src := range []string{"\\[ x \\]", "\\[ y \\]"} { + if img := renderLatex(a, src, time.Second); !failed(img) { + t.Fatalf("%q renders as a picture after the format build timed out", src) + } + } + if d := time.Since(start); d > 5*time.Second { + t.Fatalf("timed out builds took %v", d) + } + builds := buildCalls(calls()) + if len(builds) == 0 { + t.Fatalf("no format build ran") + } + if d := builds[0].deadline; d <= 0 || d > 1500*time.Millisecond { + t.Fatalf("format build ctx deadline %v, want about render_timeout = 1s", d) + } +} + +func TestPdftexRunsWithNoShellEscapeAndParanoidIO(t *testing.T) { // AC28, AC29 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + renderLatex(newLatexApp(t, 0), "\\[ \\immediate\\write18{touch pwned} x \\]", 10*time.Second) + got := calls() + if len(buildCalls(got)) == 0 || len(renderCalls(got)) == 0 { + t.Fatalf("want a format build and a render, got %+v", got) + } + for _, c := range got { + if c.name != "pdftex" { + continue + } + kind := "render" + if c.isBuild() { + kind = "build" + } + args := " " + strings.Join(c.args, " ") + " " + if !strings.Contains(args, " -no-shell-escape ") { + t.Errorf("%s: pdftex args lack -no-shell-escape: %q", kind, c.args) + } + for _, bad := range []string{"-shell-escape", "--shell-escape", "-enable-write18", "--enable-write18", "-shell-restricted", "--shell-restricted"} { + if strings.Contains(args, " "+bad+" ") { + t.Errorf("%s: pdftex args allow shell escape (%s): %q", kind, bad, c.args) + } + } + for _, key := range []string{"openin_any", "openout_any"} { + if v, ok := c.envValue(key); !ok || v != "p" { + t.Errorf("%s: env %s=%q, want p (env %q)", kind, key, v, c.env) + } + } + } +} + +// --- Step 5: LaTeX pipeline, part 2: rasterise, errors, timeouts, disk cache --- + +func cairoCalls(calls []texCall) []texCall { + out := []texCall{} + for _, c := range calls { + if c.name == "pdftocairo" { + out = append(out, c) + } + } + return out +} + +func pngFiles(texDir string) []string { + m, _ := filepath.Glob(filepath.Join(texDir, "*.png")) + return m +} + +// mustPicture fails the test unless img is a picture with no error. +func mustPicture(t *testing.T, img *termImage) { + t.Helper() + if failed(img) { + t.Fatalf("latex render is not a picture: %v", imgErr(img)) + } +} + +// blockOnCtx is a fake tool run that never finishes on its own: it returns +// only once ctx is done, as a killed process would. +func blockOnCtx(ctx context.Context) ([]byte, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(30 * time.Second): + return nil, errors.New("fake tool was never killed") + } +} + +func TestPdftocairoTransparentPNG(t *testing.T) { // AC39 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + got := calls() + c := cairoCalls(got) + if len(c) != 1 { + t.Fatalf("want one pdftocairo run after pdftex, got calls %+v", got) + } + args := " " + strings.Join(c[0].args, " ") + " " + for _, flag := range []string{"-png", "-transp"} { + if !strings.Contains(args, " "+flag+" ") { + t.Errorf("pdftocairo args lack %s: %q", flag, c[0].args) + } + } + pdf := false + for _, a := range c[0].args { + if strings.HasSuffix(a, ".pdf") { + pdf = true + } + } + if !pdf { + t.Errorf("pdftocairo is not given the PDF pdftex wrote: %q", c[0].args) + } + mustPicture(t, img) +} + +func TestLatexErrorFirstLogLine(t *testing.T) { // AC54 + latexEnv(t) + log := "This is pdfTeX, Version 3.141592653\n" + + "! Undefined control sequence.\n" + + "l.3 \\[ \\nosuchmacro\n" + + "! Emergency stop.\n" + calls := fakeTeX(t, texFake{render: func(_ context.Context, base string) ([]byte, error) { + _ = os.WriteFile(base+".log", []byte(log), 0o644) + return []byte(log), errors.New("exit status 1") + }}) + img := renderLatex(newLatexApp(t, 0), "\\[ \\nosuchmacro \\]", 10*time.Second) + if !failed(img) { + t.Fatalf("a block with a LaTeX error renders as a picture") + } + if got, want := img.err.Error(), "latex: ! Undefined control sequence."; got != want { + t.Fatalf("error = %q, want %q", got, want) + } + if n := len(cairoCalls(calls())); n != 0 { + t.Fatalf("pdftocairo ran after pdftex failed: %d runs", n) + } +} + +func TestLatexRenderTimeoutDefault10s(t *testing.T) { // AC31 + latexEnv(t) + timeout := time.Duration(config.DefaultPrefs().RenderTimeout) * time.Second + calls := fakeTeX(t, texFake{render: func(ctx context.Context, _ string) ([]byte, error) { + // Blocking for 10s would slow the suite: the deadline is checked + // below, and the run ends as a kill would. + if _, ok := ctx.Deadline(); !ok { + return blockOnCtx(ctx) + } + return nil, context.DeadlineExceeded + }}) + img := renderLatex(newLatexApp(t, 0), "\\def\\x{\\x}\\x", timeout) + if !failed(img) { + t.Fatalf("a render that never ends is a picture") + } + r := renderCalls(calls()) + if len(r) != 1 { + t.Fatalf("want one pdftex render, got %d", len(r)) + } + if d := r[0].deadline; d < 9*time.Second || d > 10*time.Second { + t.Fatalf("pdftex render ctx deadline %v, want about the default 10s", d) + } +} + +func TestLatexRenderTimeoutConfigured(t *testing.T) { // AC32 + latexEnv(t) + calls := fakeTeX(t, texFake{render: func(ctx context.Context, _ string) ([]byte, error) { + return blockOnCtx(ctx) + }}) + start := time.Now() + img := renderLatex(newLatexApp(t, 0), "\\def\\x{\\x}\\x", 2*time.Second) + took := time.Since(start) + if !failed(img) { + t.Fatalf("a render that never ends is a picture") + } + if took > 4*time.Second { + t.Fatalf("render_timeout = 2 killed pdftex only after %v", took) + } + r := renderCalls(calls()) + if len(r) != 1 { + t.Fatalf("want one pdftex render, got %d", len(r)) + } + if d := r[0].deadline; d <= 0 || d > 2*time.Second { + t.Fatalf("pdftex render ctx deadline %v, want about render_timeout = 2s", d) + } +} + +func TestPdftocairoTimeoutFallsBack(t *testing.T) { // AC34 + latexEnv(t) + calls := fakeTeX(t, texFake{cairo: blockOnCtx}) + start := time.Now() + img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", time.Second) + took := time.Since(start) + c := cairoCalls(calls()) + if len(c) != 1 { + t.Fatalf("want one pdftocairo run, got %d", len(c)) + } + if !failed(img) { + t.Fatalf("a pdftocairo run that never ends gives a picture") + } + if took > 3*time.Second { + t.Fatalf("render_timeout = 1 killed pdftocairo only after %v", took) + } + if d := c[0].deadline; d <= 0 || d > time.Second { + t.Fatalf("pdftocairo ctx deadline %v, want at most render_timeout = 1s", d) + } +} + +func TestLatexPNGWrittenToDiskCache(t *testing.T) { // AC43 + _, texDir := latexEnv(t) + fakeTeX(t, texFake{}) + mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) + if p := pngFiles(texDir); len(p) != 1 { + t.Fatalf("want one PNG under %s after a render, got %v", texDir, p) + } +} + +func TestLatexDiskCacheHitSkipsPdftex(t *testing.T) { // AC44 + _, texDir := latexEnv(t) + fakeTeX(t, texFake{}) + mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) + if p := pngFiles(texDir); len(p) == 0 { + t.Fatalf("no PNG under %s after the first session", texDir) + } + calls := fakeTeX(t, texFake{}) // a restart: fresh App, fresh call log + img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + if got := calls(); len(got) != 0 { + t.Fatalf("a cached block ran tools after a restart: %+v", got) + } + mustPicture(t, img) +} + +func TestLatexRebuildsAfterCacheDeleted(t *testing.T) { // AC46 + _, texDir := latexEnv(t) + fakeTeX(t, texFake{}) + mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) + if err := os.RemoveAll(texDir); err != nil { + t.Fatal(err) + } + calls := fakeTeX(t, texFake{}) + img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) + got := calls() + if len(buildCalls(got)) != 1 || len(renderCalls(got)) != 1 || len(cairoCalls(got)) != 1 { + t.Fatalf("want a format build, a render and a pdftocairo run, got %+v", got) + } + mustPicture(t, img) + if len(fmtFiles(t, texDir)) != 1 { + t.Fatalf("format not rebuilt into %s", texDir) + } + if len(pngFiles(texDir)) != 1 { + t.Fatalf("PNG not rewritten into %s", texDir) + } +} + +// --- Step 5: failure memo — a timeout is retried, a TeX error is reused --- + +// showLatex is one show of a latex block through the in-memory picture +// cache: nil while the background render runs, then its result. +func showLatex(a *App, src string) *termImage { + return a.externalImage(contentKey("math", "latex\x00"+src), 80, "latex", src) +} + +// nextRender waits for one background render to finish; false if none +// finishes within d. +func nextRender(done chan string, d time.Duration) bool { + select { + case <-done: + return true + case <-time.After(d): + return false + } +} + +func TestFormatTimeoutIsRetried(t *testing.T) { // AC82 + latexEnv(t) + calls := fakeTeX(t, texFake{build: blockOnCtx}) + a := newLatexApp(t, 0) + for _, src := range []string{"\\[ x \\]", "\\[ y \\]"} { + if img := renderLatex(a, src, 300*time.Millisecond); !failed(img) { + t.Fatalf("%q renders as a picture after the format build timed out", src) + } + } + if n := len(buildCalls(calls())); n != 2 { + t.Fatalf("a later block after a timed out format build: %d build calls, want 2", n) + } +} + +// timeoutCases are the two tools whose own render a timeout can kill. +func timeoutCases() []struct { + tool string + fake texFake + runs func([]texCall) []texCall +} { + return []struct { + tool string + fake texFake + runs func([]texCall) []texCall + }{ + {"pdftex", texFake{render: func(ctx context.Context, _ string) ([]byte, error) { return blockOnCtx(ctx) }}, renderCalls}, + {"pdftocairo", texFake{cairo: blockOnCtx}, cairoCalls}, + } +} + +// timedOutNote is a note app whose one latex block, in Doc.md, has been +// shown once and timed out, then redrawn; it fails the test unless the +// redraw kept showing the raw source without rendering again. +func timedOutNote(t *testing.T, tool string, fake texFake, runs func([]texCall) []texCall, inTab bool) (*App, chan string, func() []texCall, string) { + t.Helper() + latexEnv(t) + calls := fakeTeX(t, fake) + a, done := newNoteApp(t, "latex", "kitty") + a.prefs.RenderTimeout = 1 + src := "\\[ x \\]" + if _, err := a.backend.WriteNote(context.Background(), "Doc.md", "Before.\n\n"+src+"\n\nAfter.\n"); err != nil { + t.Fatal(err) + } + if inTab { + a.openNote("Doc.md", true) + } + showLatex(a, src) + if !nextRender(done, 5*time.Second) { + t.Fatalf("the first show started no background render that finished; tool calls: %d", len(calls())) + } + if n := len(runs(calls())); n != 1 { + t.Fatalf("first show: %d %s runs, want 1", n, tool) + } + // A redraw, as after another block finishes rendering. + if img := showLatex(a, src); !failed(img) { + t.Fatalf("redraw after a %s timeout shows a picture, want the raw source", tool) + } + nextRender(done, 300*time.Millisecond) + if n := len(runs(calls())); n != 1 { + t.Fatalf("redraw after a %s timeout: %d %s runs, want 1 (a redraw alone does not render again)", tool, n, tool) + } + return a, done, calls, src +} + +func TestLatexRenderTimeoutIsRetried(t *testing.T) { // AC83 + for _, tc := range timeoutCases() { + t.Run(tc.tool, func(t *testing.T) { + a, done, calls, src := timedOutNote(t, tc.tool, tc.fake, tc.runs, false) + a.openNote("Doc.md", false) // the note is opened again + showLatex(a, src) + nextRender(done, 5*time.Second) + if n := len(tc.runs(calls())); n != 2 { + t.Fatalf("show after reopening the note: %d %s runs, want 2 (rendered again)", n, tc.tool) + } + }) + } +} + +func TestLatexTimeoutForgottenByOpenNoteQuiet(t *testing.T) { // AC83 + for _, tc := range timeoutCases() { + t.Run(tc.tool, func(t *testing.T) { + a, done, calls, src := timedOutNote(t, tc.tool, tc.fake, tc.runs, true) + a.openNoteQuiet("Doc.md") // already in a tab + showLatex(a, src) + nextRender(done, 5*time.Second) + if n := len(tc.runs(calls())); n != 2 { + t.Fatalf("show after reopening the note in its tab: %d %s runs, want 2 (rendered again)", n, tc.tool) + } + }) + } +} + +func TestSingleLineBracketRendersThroughPdftex(t *testing.T) { // AC86 + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newNoteApp(t, "latex", "kitty") + src := "\\[ x^2 \\]" + lines := showNote(a, done, "Before.\n\n"+src+"\n\nAfter.", 5*time.Second) + got := calls() + if r := renderCalls(got); len(r) != 1 { + t.Fatalf("%d pdftex renders for a one-line \\[ \\] block, want 1; calls %+v", len(r), got) + } + body := renderInput(t, got) + // The body starts after the \begin{document} line, which also carries + // the theme's \color (AC23). + begin, end := strings.Index(body, `\begin{document}`), strings.LastIndex(body, `\end{document}`) + nl := -1 + if begin >= 0 { + nl = strings.Index(body[begin:], "\n") + } + if begin < 0 || end < 0 || nl < 0 || begin+nl > end || strings.TrimSpace(body[begin+nl:end]) != src { + t.Fatalf("document body is not the verbatim %q:\n%s", src, body) + } + if !isPicture(lines) { + t.Fatalf("one-line block is not rendered as a picture: %s", joinPreview(lines)) + } +} + +func TestFormatTexErrorIsReused(t *testing.T) { // AC84 + latexEnv(t) + log := "This is pdfTeX, Version 3.141592653\n" + + "! LaTeX Error: File `nosuch.sty' not found.\n" + + "! Emergency stop.\n" + calls := fakeTeX(t, texFake{build: func(context.Context) ([]byte, error) { + return []byte(log), errors.New("exit status 1") + }}) + a := newLatexApp(t, 0) + first := renderLatex(a, "\\[ x \\]", 10*time.Second) + if !failed(first) || first == nil { + t.Fatalf("a block renders as a picture with a broken preamble") + } + n := len(buildCalls(calls())) + second := renderLatex(a, "\\[ y \\]", 10*time.Second) + if !failed(second) || second == nil { + t.Fatalf("a second block renders as a picture with a broken preamble") + } + if more := len(buildCalls(calls())) - n; more != 0 { + t.Fatalf("a second block under the same preamble ran %d more format builds, want 0", more) + } + if first.err.Error() != second.err.Error() || !strings.Contains(second.err.Error(), "! LaTeX Error") { + t.Fatalf("second block error %q, want the remembered %q", second.err, first.err) + } +} + +func TestLatexRenderTexErrorIsReused(t *testing.T) { // AC85 + latexEnv(t) + log := "This is pdfTeX, Version 3.141592653\n" + + "! Undefined control sequence.\n" + + "! Emergency stop.\n" + calls := fakeTeX(t, texFake{render: func(_ context.Context, base string) ([]byte, error) { + _ = os.WriteFile(base+".log", []byte(log), 0o644) + return []byte(log), errors.New("exit status 1") + }}) + a := newLatexApp(t, 0) + done := watchRenders(t, a) + src := "\\[ \\nosuchmacro \\]" + showLatex(a, src) + if !nextRender(done, 5*time.Second) { + t.Fatalf("the first show started no background render that finished; tool calls: %d", len(calls())) + } + const want = "latex: ! Undefined control sequence." + for i := 0; i < 2; i++ { // the note is reopened, then the preview redraws + if img := showLatex(a, src); img == nil || img.err == nil || img.err.Error() != want { + t.Fatalf("show %d: want the remembered %q, got %v", i+2, want, imgErr(img)) + } + } + nextRender(done, 300*time.Millisecond) + if n := len(renderCalls(calls())); n != 1 { + t.Fatalf("showing a failed block again ran pdftex %d times in all, want 1", n) + } +} + +// --- Step 8a: crop display PNGs to their ink --- + +// inkPNG is a w×h transparent PNG with each rect filled opaque white. +func inkPNG(t *testing.T, w, h int, ink ...image.Rectangle) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for _, r := range ink { + for y := r.Min.Y; y < r.Max.Y; y++ { + for x := r.Min.X; x < r.Max.X; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + } + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func decodePNG(t *testing.T, data []byte) image.Image { + t.Helper() + img, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode PNG: %v", err) + } + return img +} + +// inkBox is the bounding box of the pixels with alpha > 0, relative to +// the image's top-left corner. +func inkBox(img image.Image) image.Rectangle { + b := img.Bounds() + box := image.Rectangle{} + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if _, _, _, a := img.At(x, y).RGBA(); a > 0 { + box = box.Union(image.Rect(x-b.Min.X, y-b.Min.Y, x-b.Min.X+1, y-b.Min.Y+1)) + } + } + } + return box +} + +func TestCropToInk(t *testing.T) { // AC88 + const border = 8 + cases := []struct { + name string + w, h int + ink []image.Rectangle + wantW, wantH int + wantInk image.Rectangle // ink box in the result + }{ + { + name: "transparent_margins_removed", + w: 200, h: 100, + ink: []image.Rectangle{image.Rect(90, 45, 110, 55)}, + wantW: 20 + 2*border, wantH: 10 + 2*border, + wantInk: image.Rect(border, border, border+20, border+10), + }, + { + name: "border_kept_and_clamped", + w: 200, h: 100, + ink: []image.Rectangle{image.Rect(3, 45, 23, 55)}, + wantW: 3 + 20 + border, wantH: 10 + 2*border, + wantInk: image.Rect(3, border, 23, border+10), + }, + { + name: "fully_transparent_unchanged", + w: 200, h: 100, + wantW: 200, wantH: 100, + }, + { + name: "opaque_edges_untouched", + w: 60, h: 40, + ink: []image.Rectangle{ + image.Rect(0, 0, 60, 1), image.Rect(0, 39, 60, 40), + image.Rect(0, 0, 1, 40), image.Rect(59, 0, 60, 40), + image.Rect(20, 15, 30, 25), + }, + wantW: 60, wantH: 40, + wantInk: image.Rect(0, 0, 60, 40), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := inkPNG(t, tc.w, tc.h, tc.ink...) + out, err := cropToInk(in, border) + if err != nil { + t.Fatalf("cropToInk: %v", err) + } + got := decodePNG(t, out) + if b := got.Bounds(); b.Dx() != tc.wantW || b.Dy() != tc.wantH { + t.Fatalf("cropped to %dx%d, want %dx%d", b.Dx(), b.Dy(), tc.wantW, tc.wantH) + } + if box := inkBox(got); box != tc.wantInk { + t.Fatalf("ink at %v in the result, want %v", box, tc.wantInk) + } + if tc.name == "opaque_edges_untouched" { + orig := decodePNG(t, in) + ob, gb := orig.Bounds(), got.Bounds() + for y := 0; y < tc.h; y++ { + for x := 0; x < tc.w; x++ { + a := color.NRGBAModel.Convert(orig.At(ob.Min.X+x, ob.Min.Y+y)) + b := color.NRGBAModel.Convert(got.At(gb.Min.X+x, gb.Min.Y+y)) + if a != b { + t.Fatalf("pixel (%d,%d) = %v, want %v", x, y, b, a) + } + } + } + } + }) + } +} + +func TestLatexKeyVersionedForCrop(t *testing.T) { // AC88 + _, texDir := latexEnv(t) + if _, err := ensurePreamble(); err != nil { + t.Fatal(err) + } + const src, fg = "\\[ x \\]", "#cdd6f4" + format := currentLatexFormat() + // An uncropped PNG left by an earlier run under the pre-crop name. + planted := inkPNG(t, 400, 100, image.Rect(190, 45, 210, 55)) + old := strings.TrimPrefix(contentKey("math", "latex\x00"+fg+"\x00"+format+"\x00"+src), "math:") + if err := os.MkdirAll(texDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(texDir, old+".png"), planted, 0o644); err != nil { + t.Fatal(err) + } + // pdftocairo's m.png is the same wide picture, written by the fake + // render beside the PDF. + calls := fakeTeX(t, texFake{ + render: func(_ context.Context, base string) ([]byte, error) { + if err := os.WriteFile(base+".pdf", []byte("%PDF-1.5 fake"), 0o644); err != nil { + return nil, err + } + _ = os.WriteFile(base+".log", []byte("This is pdfTeX\nOutput written on m.pdf\n"), 0o644) + if err := os.WriteFile(filepath.Join(filepath.Dir(base), "m.png"), planted, 0o644); err != nil { + return nil, err + } + return []byte("Output written on m.pdf (1 page)."), nil + }, + cairo: func(context.Context) ([]byte, error) { return nil, nil }, + }) + a := newLatexApp(t, 0) + img := a.renderEmbed(latexKey(fg, format, src), renderReq{kind: "latex", src: src, dark: true, fg: fg, maxCols: 80, timeout: 10 * time.Second}) + mustPicture(t, img) + if n := len(renderCalls(calls())); n != 1 { + t.Fatalf("the pre-crop cached PNG was served: %d pdftex renders, want 1", n) + } + wantW, wantH := 20+2*latexCropBorder, 10+2*latexCropBorder + if img.width != wantW || img.height != wantH { + t.Fatalf("picture is %dx%d px, want cropped %dx%d", img.width, img.height, wantW, wantH) + } + for _, p := range pngFiles(texDir) { + if strings.TrimSuffix(filepath.Base(p), ".png") == old { + continue + } + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + if got := decodePNG(t, b).Bounds(); got.Dx() != wantW || got.Dy() != wantH { + t.Fatalf("disk cache %s is %dx%d, want cropped %dx%d", p, got.Dx(), got.Dy(), wantW, wantH) + } + return + } + t.Fatalf("no new PNG beside the pre-crop one under %s: %v", texDir, pngFiles(texDir)) +} diff --git a/internal/tui/latex_tex_test.go b/internal/tui/latex_tex_test.go new file mode 100644 index 0000000..2af8b9b --- /dev/null +++ b/internal/tui/latex_tex_test.go @@ -0,0 +1,373 @@ +package tui + +import ( + "image" + "image/color" + "image/png" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/mcp" +) + +// --- Step 8: real pdftex and pdftocairo --- +// +// These run the real tools found on PATH. Each test skips when either tool +// is missing (CI has no TeX) or on windows. runTool is not swapped. + +// requireRealTeX skips t unless both real tools are on PATH. +func requireRealTeX(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("real TeX tests do not run on windows") + } + for _, tool := range []string{"pdftex", "pdftocairo"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("%s not on PATH", tool) + } + } +} + +// realTeXEnv points the config dir and the user cache dir at temp dirs and +// keeps the real PATH. It returns the config dir and /zn/tex. +func realTeXEnv(t *testing.T) (cfgDir, texDir string) { + t.Helper() + requireRealTeX(t) + cfgDir = t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", cfgDir) + cache := t.TempDir() + t.Setenv("XDG_CACHE_HOME", cache) + t.Setenv("HOME", cache) + t.Setenv("LocalAppData", cache) + ucd, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + return cfgDir, filepath.Join(ucd, "zn", "tex") +} + +// realTimeout leaves room for a first format build that loads tikz. +const realTimeout = 60 * time.Second + +func renderReal(a *App, src string, timeout time.Duration) *termImage { + return a.renderEmbed(contentKey("math", "latex\x00"+src), renderReq{kind: "latex", src: src, dark: a.theme.Dark, maxCols: 80, timeout: timeout}) +} + +// onlyPNG decodes the single PNG in the tex cache dir. +func onlyPNG(t *testing.T, texDir string) image.Image { + t.Helper() + p := pngFiles(texDir) + if len(p) != 1 { + t.Fatalf("want one PNG under %s, got %v", texDir, p) + } + f, err := os.Open(p[0]) + if err != nil { + t.Fatal(err) + } + defer f.Close() + img, err := png.Decode(f) + if err != nil { + t.Fatalf("decode %s: %v", p[0], err) + } + return img +} + +func hexRGB(t *testing.T, hex string) color.NRGBA { + t.Helper() + v, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 32) + if err != nil || len(strings.TrimPrefix(hex, "#")) != 6 { + t.Fatalf("theme colour %q is not #rrggbb", hex) + } + return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 255} +} + +// dominantOpaque is the most frequent colour among fully opaque pixels. +func dominantOpaque(img image.Image) (color.NRGBA, int) { + counts := map[color.NRGBA]int{} + b := img.Bounds() + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + c := color.NRGBAModel.Convert(img.At(x, y)).(color.NRGBA) + if c.A == 255 { + counts[c]++ + } + } + } + var best color.NRGBA + n := 0 + for c, k := range counts { + if k > n { + best, n = c, k + } + } + return best, n +} + +func near(a, b color.NRGBA, tol int) bool { + d := func(x, y uint8) int { + if x > y { + return int(x - y) + } + return int(y - x) + } + return d(a.R, b.R) <= tol && d(a.G, b.G) <= tol && d(a.B, b.B) <= tol +} + +// showBlock shows a note's block through embedBlockLines, waits for the +// background render, and shows it again. +func showBlock(a *App, done chan string, lines []string, b mdBlock) []previewLine { + a.embedBlockLines(b, lines, 80, "") + nextRender(done, realTimeout) + out, _ := a.embedBlockLines(b, lines, 80, "") + return out +} + +func realMathApp(t *testing.T) (*App, chan string) { + t.Helper() + p := mathPrefs("latex", "kitty") + p.RenderTimeout = int(realTimeout / time.Second) + return newMathApp(t, p) +} + +func TestRealDefaultPreambleRendersAmsMhchemTikz(t *testing.T) { // AC16 + realTeXEnv(t) + a := newLatexApp(t, 0) + align := "\\begin{align*}\na &= \\ce{H2O} \\\\\nb &\\leq \\mathbb{R}\n\\end{align*}" + if img := renderReal(a, align, realTimeout); failed(img) { + t.Fatalf("align with \\ce under the default preamble: %v", imgErr(img)) + } + if !preambleExists() { + t.Fatalf("no preamble.tex at %s after the first render", config.PreamblePath()) + } + tikz := "\\begin{tikzpicture}\n\\draw[-{Stealth}] (0,0) -- ($(1,0)+(0,1)$);\n\\end{tikzpicture}" + if img := renderReal(a, tikz, realTimeout); failed(img) { + t.Fatalf("tikzpicture under the default preamble: %v", imgErr(img)) + } +} + +func TestRealUserPackageInPreamble(t *testing.T) { // AC21 + realTeXEnv(t) + src := "\\[ \\bm{x} \\]" + if img := renderReal(newLatexApp(t, 0), src, realTimeout); !failed(img) { + t.Fatalf("\\bm renders without bm in the preamble; the test proves nothing") + } + pre, err := os.ReadFile(config.PreamblePath()) + if err != nil { + t.Fatalf("default preamble not written: %v", err) + } + if err := os.WriteFile(config.PreamblePath(), append(pre, []byte("\n\\usepackage{bm}\n")...), 0o644); err != nil { + t.Fatal(err) + } + if img := renderReal(newLatexApp(t, 0), src, realTimeout); failed(img) { + t.Fatalf("\\bm{x} with \\usepackage{bm} added to preamble.tex: %v", imgErr(img)) + } +} + +func TestRealTikzStrokeFollowsTheme(t *testing.T) { // AC23 + requireRealTeX(t) + src := "\\begin{tikzpicture}\n\\draw[line width=4pt] (0,0) rectangle (2,1);\n\\fill (3,0.5) circle (0.5);\n\\end{tikzpicture}" + for _, dark := range []bool{true, false} { + t.Run(map[bool]string{true: "dark", false: "light"}[dark], func(t *testing.T) { + _, texDir := realTeXEnv(t) + a := newLatexApp(t, 0) + a.theme = buildTheme(dark) + if img := renderReal(a, src, realTimeout); failed(img) { + t.Fatalf("tikzpicture: %v", imgErr(img)) + } + want := hexRGB(t, string(a.theme.Fg)) + got, n := dominantOpaque(onlyPNG(t, texDir)) + if n == 0 { + t.Fatalf("picture has no opaque pixel") + } + if !near(got, want, 24) { + t.Fatalf("dominant stroke colour %v, want near theme.Fg %s (%v)", got, a.theme.Fg, want) + } + }) + } +} + +func TestRealFormatReusedAcrossApps(t *testing.T) { // AC25 + _, texDir := realTeXEnv(t) + if img := renderReal(newLatexApp(t, 0), "\\[ x \\]", realTimeout); failed(img) { + t.Fatalf("first render: %v", imgErr(img)) + } + fmts := fmtFiles(t, texDir) + if len(fmts) != 1 { + t.Fatalf("want one format under %s, got %v", texDir, fmts) + } + before, err := os.Stat(fmts[0]) + if err != nil { + t.Fatal(err) + } + time.Sleep(20 * time.Millisecond) + a := newLatexApp(t, 0) // a later session + for _, src := range []string{"\\[ y^2 \\]", "\\[ \\frac{a}{b} \\]"} { + if img := renderReal(a, src, realTimeout); failed(img) { + t.Fatalf("render %q: %v", src, imgErr(img)) + } + } + after, err := os.Stat(fmts[0]) + if err != nil { + t.Fatalf("format gone after later renders: %v", err) + } + if !after.ModTime().Equal(before.ModTime()) || !os.SameFile(before, after) { + t.Fatalf("format rebuilt: mtime %v -> %v", before.ModTime(), after.ModTime()) + } + if got := fmtFiles(t, texDir); len(got) != 1 { + t.Fatalf("want still one format, got %v", got) + } +} + +func TestRealWrite18Blocked(t *testing.T) { // AC28 + realTeXEnv(t) + target := filepath.Join(t.TempDir(), "pwned") + src := "\\immediate\\write18{touch " + target + "}\n\\[ x \\]" + img := renderReal(newLatexApp(t, 0), src, realTimeout) + if _, err := os.Stat(target); err == nil { + t.Fatalf("\\write18 ran a shell command: %s exists", target) + } + mustPicture(t, img) // the render ran, so the check above is not vacuous +} + +func TestRealSandboxBlocksWritesOutsideWorkDir(t *testing.T) { // AC30 + _, texDir := realTeXEnv(t) + target := filepath.Join(t.TempDir(), "leak.txt") + src := "\\newwrite\\znout\n\\immediate\\openout\\znout=" + target + "\n" + + "\\immediate\\write\\znout{zn}\n\\immediate\\closeout\\znout\n\\[ x \\]" + img := renderReal(newLatexApp(t, 0), src, realTimeout) + if _, err := os.Stat(target); err == nil { + t.Fatalf("\\openout wrote outside the work dir: %s exists", target) + } + // Real pdftex under openout_any=p stops on the refused \openout, so the + // block cannot render. The refusal error proves pdftex ran and reached + // the write, so the check above is not vacuous. + if !failed(img) { + t.Fatalf("a refused \\openout still gave a picture") + } + if err := imgErr(img); !strings.Contains(err.Error(), "can't write on file") { + t.Fatalf("render did not fail on the refused write: %v", err) + } + + // Guard: reading by absolute path is accepted TeX behaviour (rev 7). If + // TeX starts refusing it, revisit the README warning (AC87). + t.Run("absolute_read_still_happens", func(t *testing.T) { + in := filepath.Join(t.TempDir(), "box.tex") + if err := os.WriteFile(in, []byte("\\rule{4cm}{4cm}\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(texDir); err != nil { + t.Fatal(err) + } + if img := renderReal(newLatexApp(t, 0), "\\input{"+in+"}", realTimeout); failed(img) { + t.Fatalf("\\input of an absolute path no longer renders: %v", imgErr(img)) + } + if w := onlyPNG(t, texDir).Bounds().Dx(); w < 300 { + t.Fatalf("PNG is %dpx wide; the 4cm rule from %s did not reach it", w, in) + } + }) +} + +func TestRealLoopKilledByTimeout(t *testing.T) { // AC32 + realTeXEnv(t) + a := newLatexApp(t, 0) + // Build the format first, so the 2s budget below is spent on the loop. + if img := renderReal(a, "\\[ x \\]", realTimeout); failed(img) { + t.Fatalf("warm-up render: %v", imgErr(img)) + } + start := time.Now() + img := renderReal(a, "\\def\\x{\\x}\\x", 2*time.Second) + if took := time.Since(start); took > 5*time.Second { + t.Fatalf("render_timeout = 2 returned only after %v", took) + } + if !failed(img) { + t.Fatalf("a TeX loop gave a picture") + } +} + +func TestRealPNGHasTransparentBackground(t *testing.T) { // AC39 + _, texDir := realTeXEnv(t) + if img := renderReal(newLatexApp(t, 0), "\\[ x^2 + y^2 \\]", realTimeout); failed(img) { + t.Fatalf("render: %v", imgErr(img)) + } + img := onlyPNG(t, texDir) + b := img.Bounds() + for _, p := range []image.Point{b.Min, {b.Max.X - 1, b.Min.Y}, {b.Min.X, b.Max.Y - 1}, {b.Max.X - 1, b.Max.Y - 1}} { + if _, _, _, a := img.At(p.X, p.Y).RGBA(); a != 0 { + t.Fatalf("corner %v alpha %d, want 0 (transparent)", p, a) + } + } +} + +// instructionsTikz cuts the first bare tikzpicture out of the MCP +// instructions. +func instructionsTikz(t *testing.T) []string { + t.Helper() + out := []string{} + for _, l := range strings.Split(mcp.DefaultInstructions, "\n") { + if len(out) == 0 && !strings.HasPrefix(l, "\\begin{tikzpicture}") { + continue + } + out = append(out, l) + if strings.HasPrefix(l, "\\end{tikzpicture}") { + return out + } + } + t.Fatalf("no \\begin{tikzpicture} … \\end{tikzpicture} lines in mcp.DefaultInstructions") + return nil +} + +func TestRealInstructionsTikzExampleRenders(t *testing.T) { // AC74 + realTeXEnv(t) + tikz := instructionsTikz(t) + for _, l := range tikz { + if strings.Contains(l, "usetikzlibrary") { + t.Fatalf("cut lines load a library themselves: %q", l) + } + } + lines := append(append([]string{"A vector:", ""}, tikz...), "", "done.") + var blk *mdBlock + for _, b := range splitBlocks(lines, true) { + if b.kind == "tex" { + b := b + blk = &b + } + } + if blk == nil { + t.Fatalf("the bare tikzpicture is not a tex block: %+v", splitBlocks(lines, true)) + } + a, done := realMathApp(t) + out := showBlock(a, done, lines, *blk) + if rowIndex(out, "latex:") >= 0 { + t.Fatalf("error row under the default preamble:\n%s", joinPreview(out)) + } + if !isPicture(out) { + t.Fatalf("the MCP TikZ example is not a picture:\n%s", joinPreview(out)) + } +} + +func TestRealDisplayPNGCroppedToInk(t *testing.T) { // AC88 + _, texDir := realTeXEnv(t) + const src = "\\[ \\frac{a}{b} \\]" + render := func(cols int) *termImage { + img := newLatexApp(t, 0).renderEmbed(contentKey("math", "latex\x00"+src), renderReq{kind: "latex", src: src, dark: true, maxCols: cols, timeout: realTimeout}) + if failed(img) { + t.Fatalf("render at %d cols: %v", cols, imgErr(img)) + } + return img + } + narrow := render(80) + if w := onlyPNG(t, texDir).Bounds().Dx(); w >= 400 { + t.Errorf("\\frac{a}{b} PNG is %d px wide, want < 400 (cropped to the formula)", w) + } + wide := render(120) + if narrow.rows != wide.rows { + t.Errorf("displayed height %d rows at 80 cols, %d at 120: it depends on the pane width", narrow.rows, wide.rows) + } +} diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index f6fd4f2..5deb6da 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -901,6 +901,7 @@ func (a *App) openTextSearch(initial string) { // openNoteQuiet opens a note without recording a jump. func (a *App) openNoteQuiet(path string) { + a.forgetEmbedTimeouts() pane := a.activePane if idx := pane.findTab(path); idx >= 0 { pane.active = idx diff --git a/internal/tui/panes.go b/internal/tui/panes.go index 7434565..fbf071a 100644 --- a/internal/tui/panes.go +++ b/internal/tui/panes.go @@ -217,6 +217,7 @@ func abs(n int) int { // openNote shows a note in the active pane, reusing its tab when open. func (a *App) openNote(path string, focus bool) { + a.forgetEmbedTimeouts() pane := a.activePane if pane == nil { return diff --git a/internal/tui/previewglamour.go b/internal/tui/previewglamour.go index 9e6495b..46f239d 100644 --- a/internal/tui/previewglamour.go +++ b/internal/tui/previewglamour.go @@ -85,7 +85,7 @@ func (a *App) glamourRenderer(width int) (*glamour.TermRenderer, error) { // mdBlock is one renderable slice of a note. type mdBlock struct { - kind string // front, fence, table, list, quote, heading, hr, para, blank + kind string // front, fence, math, tex, table, list, quote, heading, hr, para, blank start int // first source line end int // one past the last source line } @@ -100,10 +100,42 @@ var ( taskStateRe = regexp.MustCompile(`(?m)^(\s*)([-*+]|\d+[.)])\s+\[([/>\-])\]\s?(.*)$`) calloutLineRe = regexp.MustCompile(`(?m)^\s{0,3}>\s*\[!(\w+)\]\s*(.*)$`) itemMarkerRe = regexp.MustCompile(`^\s*(?:[•◦▪\-*+]|\d+\.|\[[ xX✓✔]\]|☐|☑)\s`) + texOpenRe = regexp.MustCompile(`^ {0,3}\\\[\s*$`) + texLineRe = regexp.MustCompile(`^ {0,3}\\\[.*\\\]\s*$`) + texEnvRe = regexp.MustCompile(`^ {0,3}\\begin\{((?:equation|align|gather|multline|alignat|flalign|eqnarray)\*?|tikzpicture)\}`) ) -// splitBlocks walks the source lines and groups them into blocks. -func splitBlocks(lines []string) []mdBlock { +// texBlockEnd returns one past the last line of a block-level \[…\] or AC7 +// environment that starts at line i, or -1 when none starts there or it is +// never closed, so an unclosed opener stays text. +func texBlockEnd(lines []string, i int) int { + if texLineRe.MatchString(lines[i]) { + return i + 1 + } + if texOpenRe.MatchString(lines[i]) { + for j := i + 1; j < len(lines); j++ { + if strings.HasSuffix(strings.TrimSpace(lines[j]), `\]`) { + return j + 1 + } + } + return -1 + } + m := texEnvRe.FindStringSubmatch(lines[i]) + if m == nil { + return -1 + } + end := `\end{` + m[1] + `}` + for j := i; j < len(lines); j++ { + if strings.Contains(lines[j], end) { + return j + 1 + } + } + return -1 +} + +// splitBlocks walks the source lines and groups them into blocks. With +// latex, block-level \[…\] and the AC7 environments become "tex" blocks. +func splitBlocks(lines []string, latex bool) []mdBlock { blocks := []mdBlock{} i := 0 if len(lines) > 0 && lines[0] == "---" { @@ -149,6 +181,10 @@ func splitBlocks(lines []string) []mdBlock { } blocks = append(blocks, mdBlock{kind: "math", start: i, end: j}) i = j + case latex && texBlockEnd(lines, i) > 0: + j := texBlockEnd(lines, i) + blocks = append(blocks, mdBlock{kind: "tex", start: i, end: j}) + i = j case tableRowRe.MatchString(line): j := i for j < len(lines) && tableRowRe.MatchString(lines[j]) { @@ -185,7 +221,7 @@ func splitBlocks(lines []string) []mdBlock { j := i + 1 for j < len(lines) { next := lines[j] - if blankRe.MatchString(next) || fenceRe.MatchString(next) || headingRe.MatchString(next) || tableRowRe.MatchString(next) || quoteRe.MatchString(next) || listLineRe.MatchString(next) || hrRe.MatchString(next) || mathFenceRe.MatchString(next) || mathInlineOpen.MatchString(next) { + if blankRe.MatchString(next) || fenceRe.MatchString(next) || headingRe.MatchString(next) || tableRowRe.MatchString(next) || quoteRe.MatchString(next) || listLineRe.MatchString(next) || hrRe.MatchString(next) || mathFenceRe.MatchString(next) || mathInlineOpen.MatchString(next) || latex && texBlockEnd(lines, j) > 0 { break } j++ @@ -366,7 +402,7 @@ func (a *App) renderGlamourLinesFrom(text string, width int, fromPath string) ([ th := a.theme lines := strings.Split(text, "\n") out := []previewLine{} - for _, b := range splitBlocks(lines) { + for _, b := range splitBlocks(lines, a.prefs.TerminalMath == "latex") { if embedded, ok := a.embedBlockLines(b, lines, width, fromPath); ok { out = append(out, embedded...) continue diff --git a/internal/tui/theme_render_test.go b/internal/tui/theme_render_test.go index 0e4133e..148831f 100644 --- a/internal/tui/theme_render_test.go +++ b/internal/tui/theme_render_test.go @@ -95,3 +95,26 @@ func TestThemePaintPreservesNestedColorsAndHonorsNoColor(t *testing.T) { t.Fatalf("no-color output contains styling: %q", got) } } + +// AC56–AC58 in the editor itself: a math body is painted with the same +// lexer colors as the identical body in a ```tex fence, not as prose. +func TestEditorPaintsMathBodyLikeTexFence(t *testing.T) { + profile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + row := func(note string) string { + a, _ := newFeatureTestApp(t) + a.prefs.TerminalMath = "latex" + a.openNote("note.md", true) + buf := a.activeBuffer() + buf.ed.ReplaceText(note) + return strings.Split(a.renderEditor(buf, 60, 6, true), "\n")[1] + } + body := `\frac{a}{b} + \alpha_1` + want := row("```tex\n" + body + "\n```") + for _, open := range [][2]string{{"$$", "$$"}, {`\[`, `\]`}, {`\begin{align}`, `\end{align}`}} { + if got := row(open[0] + "\n" + body + "\n" + open[1]); got != want { + t.Errorf("%s body painted as\n%q\nwant the ```tex fence's\n%q", open[0], got, want) + } + } +} From 478182869a529d8a4beefb448dd5379e038f82d8 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Mon, 26 Oct 2026 14:26:03 -0400 Subject: [PATCH 03/10] =?UTF-8?q?Feat(tui):=20Enter=20closes=20\[=20and=20?= =?UTF-8?q?\begin{=E2=80=A6}=20under=20math=20=3D=20"latex"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The editor already closes $$ and code fences on Enter. Under latex, \[ now gets \] and \begin{name} gets \end{name} for any environment name, with the cursor on the empty line between, and no closer is added when one already follows. Under typst these lines get a plain Enter. Co-Authored-By: Claude Opus 5.5 --- internal/tui/authoring.go | 24 +++++++-- internal/tui/authoring_test.go | 96 ++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/internal/tui/authoring.go b/internal/tui/authoring.go index 78a8d56..d05c891 100644 --- a/internal/tui/authoring.go +++ b/internal/tui/authoring.go @@ -29,7 +29,7 @@ func inMarkdownFence(lines []string, line int) bool { return fence != "" } -var blockSnippetRe = regexp.MustCompile("^([\\t ]*(?:(?:[-+*]|[0-9]+[.)])[\\t ]+|>[\\t ]?)*)((?:`{3,}|~{3,}).*|\\$\\$)$") +var blockSnippetRe = regexp.MustCompile("^([\\t ]*(?:(?:[-+*]|[0-9]+[.)])[\\t ]+|>[\\t ]?)*)((?:`{3,}|~{3,}).*|\\$\\$|\\\\\\[|\\\\begin\\{[^{}]+\\})$") func (a *App) markdownSnippet(buf *noteBuffer, k vim.Key) bool { if !a.prefs.MarkdownSnippets || buf.ed.Mode() != vim.ModeInsert { @@ -45,11 +45,21 @@ func (a *App) markdownSnippet(buf *noteBuffer, k vim.Key) bool { if inMarkdownFence(buf.ed.Lines(), cur.Line) { return false } - if k.Is("enter") && cur.Col == len(r) { - m := blockSnippetRe.FindStringSubmatch(line) + // Typing \[ auto-pairs to \[] with the cursor before the ]; under latex + // that is still the opener, and the stray ] goes. + paired := a.prefs.TerminalMath == "latex" && after == "]" && strings.HasSuffix(before, `\[`) + if k.Is("enter") && (cur.Col == len(r) || paired) { + m := blockSnippetRe.FindStringSubmatch(before) + if m != nil && strings.HasPrefix(m[2], "\\") && a.prefs.TerminalMath != "latex" { + m = nil + } if m != nil { token := "$$" - if strings.HasPrefix(m[2], "`") { + if m[2] == `\[` { + token = `\]` + } else if strings.HasPrefix(m[2], `\begin{`) { + token = `\end` + strings.TrimPrefix(m[2], `\begin`) + } else if strings.HasPrefix(m[2], "`") { token = strings.Repeat("`", len(m[2])-len(strings.TrimLeft(m[2], "`"))) } else if strings.HasPrefix(m[2], "~") { token = strings.Repeat("~", len(m[2])-len(strings.TrimLeft(m[2], "~"))) @@ -63,7 +73,11 @@ func (a *App) markdownSnippet(buf *noteBuffer, k vim.Key) bool { if cur.Line+1 < buf.ed.LineCount() && strings.TrimSpace(buf.ed.Line(cur.Line+1)) == token { return false } - buf.ed.InsertAtCursor("\n" + indent + "\n" + indent + token) + if paired { + buf.ed.ReplaceAtCursor(0, 1, "\n"+indent+"\n"+indent+token) + } else { + buf.ed.InsertAtCursor("\n" + indent + "\n" + indent + token) + } buf.ed.SetCursor(vim.Pos{Line: cur.Line + 1, Col: len([]rune(indent))}) return true } diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index 61ba9ad..d437107 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -93,3 +93,99 @@ func TestTypedBacktickFenceClosesOnEnter(t *testing.T) { t.Fatalf("cursor should sit inside the block: %v", got) } } + +// typeLineAndEnter puts line in the note, moves to its end in insert mode +// and presses Enter through the App, as a user typing it would. +func typeLineAndEnter(t *testing.T, math, line string) *noteBuffer { + t.Helper() + a, _ := newFeatureTestApp(t) + a.prefs.TerminalMath = math + a.openNote("note.md", true) + buf := a.activeBuffer() + buf.ed.ReplaceText(line) + buf.ed.HandleKey(vim.R('A')) + a.handleKey(vim.Key{Name: "enter"}) + return buf +} + +func assertSnippet(t *testing.T, buf *noteBuffer, want string) { + t.Helper() + if got := buf.ed.Text(); got != want { + t.Fatalf("text = %q, want %q", got, want) + } + if cur := buf.ed.Cursor(); cur.Line != 1 || cur.Col != 0 { + t.Fatalf("cursor = %+v, want line 1 col 0 (between opener and closer)", cur) + } +} + +// AC60 +func TestSnippetBracketClosesUnderLatex(t *testing.T) { + assertSnippet(t, typeLineAndEnter(t, "latex", `\[`), "\\[\n\n\\]") +} + +// AC60 as typed: auto-pairing leaves \[] with the cursor before the ], +// and Enter must still give \[ … \] instead of a stray ]. +func TestTypedBracketClosesOnEnterUnderLatex(t *testing.T) { + for _, typed := range []string{`\[`, `\begin{align}`} { + t.Run(typed, func(t *testing.T) { + a, buf := newTagTestApp(t, vimPrefs(), "# Draft") + a.prefs.TerminalMath = "latex" + feed(a, vim.R('o')) + typeKeys(a, typed) + feed(a, vim.KeyEnter) + closer := `\]` + if typed != `\[` { + closer = `\end{align}` + } + if got, want := buf.ed.Text(), "# Draft\n"+typed+"\n\n"+closer; got != want { + t.Fatalf("typed %q + Enter: %q, want %q", typed, got, want) + } + if got := buf.ed.Cursor(); got != (vim.Pos{Line: 2, Col: 0}) { + t.Fatalf("cursor should sit inside the block: %v", got) + } + }) + } +} + +// AC61 +func TestSnippetBeginClosesAnyEnvironment(t *testing.T) { + for _, env := range []string{"align", "foo*"} { + t.Run(env, func(t *testing.T) { + buf := typeLineAndEnter(t, "latex", `\begin{`+env+`}`) + assertSnippet(t, buf, `\begin{`+env+"}\n\n"+`\end{`+env+`}`) + }) + } +} + +// AC62 +func TestSnippetDollarUnchangedUnderLatex(t *testing.T) { + assertSnippet(t, typeLineAndEnter(t, "latex", "$$"), "$$\n\n$$") +} + +// AC63 +func TestSnippetTypstUnchangedAndNoBracket(t *testing.T) { + assertSnippet(t, typeLineAndEnter(t, "typst", "$$"), "$$\n\n$$") + for _, line := range []string{`\[`, `\begin{align}`} { + buf := typeLineAndEnter(t, "typst", line) + if got := buf.ed.Text(); strings.Contains(got, `\]`) || strings.Contains(got, `\end{`) { + t.Fatalf("typst %q + Enter inserted a closer: %q", line, got) + } + } +} + +// AC60/AC61: a closer that already follows is not added a second time. +func TestSnippetLatexSkipsExistingCloser(t *testing.T) { + for _, body := range []string{"\\[\n\\]", "\\begin{align}\n\\end{align}"} { + a, _ := newFeatureTestApp(t) + a.prefs.TerminalMath = "latex" + a.openNote("note.md", true) + buf := a.activeBuffer() + buf.ed.ReplaceText(body) + buf.ed.SetCursor(vim.Pos{Line: 0, Col: 0}) + buf.ed.HandleKey(vim.R('A')) + a.handleKey(vim.Key{Name: "enter"}) + if got := buf.ed.Text(); strings.Count(got, `\]`)+strings.Count(got, `\end{`) != 1 { + t.Fatalf("%q + Enter duplicated the closer: %q", body, got) + } + } +} From 4d6e3f505d937730066b271681c9afc1be7c0c72 Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Mon, 26 Oct 2026 14:26:03 -0400 Subject: [PATCH 04/10] Feat(mcp): ask for bare tikzpicture blocks when math = "latex" The note-writing instructions tell an AI to emit ```tikz fences, which the latex engine does not render; it renders a top-level tikzpicture and loads libraries from preamble.tex. Under math = "latex" the server now serves a copy of the instructions with the TikZ guidance rewritten for that: a bare \begin{tikzpicture} block, no \usetikzlibrary line. The copy comes from six exact replacements, and a test fails if any of them stops matching, so the typst text stays the single source. The typst text and a user's override file are unchanged, and the setting is read once when the server starts. Co-Authored-By: Claude Opus 5.5 --- internal/mcp/instructions.go | 43 +++++++++- internal/mcp/instructions_test.go | 136 ++++++++++++++++++++++++++++++ internal/mcp/run_test.go | 45 ++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 internal/mcp/instructions_test.go diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go index 12ab74c..09f4f75 100644 --- a/internal/mcp/instructions.go +++ b/internal/mcp/instructions.go @@ -9,16 +9,55 @@ import ( // ResolveInstructions is the effective system instruction: the user's // override from `/zennotes.mcp-instructions.md` (written by -// Settings → MCP in the desktop app) when present, the compiled default -// otherwise. +// Settings → MCP in the desktop app) when present, else the compiled +// default for the `[terminal] math` engine: latexInstructions under latex, +// DefaultInstructions otherwise (also when config.toml cannot be read). +// Run calls it once, so a config change takes effect on the next start. func ResolveInstructions() string { raw, err := os.ReadFile(config.MCPInstructionsPath()) if err == nil && strings.TrimSpace(string(raw)) != "" { return string(raw) } + if prefs, _, err := config.LoadPrefs(); err == nil && prefs.TerminalMath == "latex" { + return latexInstructions + } return DefaultInstructions } +// latexReplacements turns DefaultInstructions into the latex variant: the +// zn preview renders a bare tikzpicture block with the user's preamble.tex, +// so TikZ is asked for unfenced and without its own \usetikzlibrary line. +// Old/new pairs. TestLatexReplacementsAllApply fails when an old string no +// longer occurs exactly once in DefaultInstructions. +var latexReplacements = []string{ + "emit a ```tikz (or\n", + "emit a bare `tikzpicture` block (or a\n", + + "- ```tikz: vectors,", + "- bare `\\begin{tikzpicture}` block: vectors,", + + "- `tikz`: emit the TikZ content itself, usually a bare\n" + + " `\\begin{tikzpicture} \u2026 \\end{tikzpicture}` block. If you need\n" + + " extra libraries or packages, add `\\usetikzlibrary{\u2026}` /\n" + + " `\\usepackage{\u2026}` above it. Never emit `\\documentclass`.\n", + "- TikZ: no fence. Put a `\\begin{tikzpicture} \u2026 \\end{tikzpicture}`\n" + + " block on lines of its own. Libraries and packages come from the\n" + + " user's `preamble.tex`, whose default loads amsmath, amssymb, mhchem,\n" + + " tikz and the TikZ libraries arrows.meta,calc. Never emit\n" + + " `\\documentclass`, `\\usepackage` or `\\usetikzlibrary`.\n", + + "convert to ```tikz.\n", + "convert to a bare `tikzpicture` block.\n", + + "```tikz\n\\usetikzlibrary{arrows.meta,calc}\n\\begin{tikzpicture}", + "\\begin{tikzpicture}", + + "\\end{tikzpicture}\n```\n", + "\\end{tikzpicture}\n", +} + +var latexInstructions = strings.NewReplacer(latexReplacements...).Replace(DefaultInstructions) + // DefaultInstructions is a verbatim copy of the desktop MCP server's // instructions (apps/desktop/src/mcp/instructions.ts), so an agent behaves // the same whichever binary is serving the vault. diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go new file mode 100644 index 0000000..c4ed981 --- /dev/null +++ b/internal/mcp/instructions_test.go @@ -0,0 +1,136 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ZenNotes/tui/internal/config" +) + +const tikzFence = "```tikz" + +// useMath points the config dir at a fresh temp dir whose config.toml sets +// [terminal] math to engine ("" writes no config.toml at all). +func useMath(t *testing.T, engine string) string { + t.Helper() + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + if engine != "" { + writeMath(t, dir, engine) + } + return dir +} + +func writeMath(t *testing.T, dir, engine string) { + t.Helper() + body := "[terminal]\nmath = \"" + engine + "\"\n" + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +// katexParagraph is the "Math → KaTeX" paragraph of DefaultInstructions, +// up to the blank line that ends it. +func katexParagraph(t *testing.T) string { + t.Helper() + start := strings.Index(DefaultInstructions, "**Math → KaTeX.**") + if start < 0 { + t.Fatal("DefaultInstructions has no \"Math → KaTeX\" paragraph") + } + end := strings.Index(DefaultInstructions[start:], "\n\n") + if end < 0 { + t.Fatal("the KaTeX paragraph has no end") + } + return DefaultInstructions[start : start+end+2] +} + +func TestInstructionsLatexAsksForBareTikz(t *testing.T) { // AC64 + useMath(t, "latex") + got := ResolveInstructions() + if !strings.Contains(got, `\begin{tikzpicture}`) { + t.Fatalf("AC64: latex instructions do not ask for a \\begin{tikzpicture} block") + } + if n := strings.Count(got, tikzFence); n != 0 { + t.Fatalf("AC64: latex instructions still ask for %s fences (%d mentions)", tikzFence, n) + } + if strings.Contains(got, `\usetikzlibrary{arrows.meta,calc}`) { + t.Fatalf("AC64: the latex scaffold still loads its own library with \\usetikzlibrary") + } + if !strings.Contains(got, "preamble.tex") { + t.Fatalf("AC64: latex instructions do not say libraries and packages come from preamble.tex") + } + if !strings.Contains(got, "arrows.meta,calc") { + t.Fatalf("AC64: latex instructions do not say the default preamble loads arrows.meta,calc") + } + // The scaffold is a bare block: the line above its \begin{tikzpicture} + // is not a code fence of any language. + lines := strings.Split(got, "\n") + found := false + for i, l := range lines { + if !strings.HasPrefix(l, `\begin{tikzpicture}[>=Stealth`) { + continue + } + found = true + if i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "```") { + t.Fatalf("AC64: the TikZ scaffold is still fenced: %q", lines[i-1]) + } + } + if !found { + t.Fatalf("AC64: latex instructions lost the TikZ scaffold") + } +} + +func TestInstructionsLatexKeepsKatexParagraph(t *testing.T) { // AC65 + useMath(t, "latex") + if want := katexParagraph(t); !strings.Contains(ResolveInstructions(), want) { + t.Fatalf("AC65: the Math → KaTeX paragraph changed under latex; want byte-equal:\n%s", want) + } +} + +func TestInstructionsTypstIsDefault(t *testing.T) { // AC66 + for _, engine := range []string{"typst", ""} { + useMath(t, engine) + if got := ResolveInstructions(); got != DefaultInstructions { + t.Fatalf("AC66: math=%q instructions differ from DefaultInstructions", engine) + } + } +} + +func TestInstructionsOverrideWinsForBothEngines(t *testing.T) { // AC67 + const override = "My own rules.\n```tikz\nkeep me\n```\n" + for _, engine := range []string{"typst", "latex"} { + useMath(t, engine) + if err := os.WriteFile(config.MCPInstructionsPath(), []byte(override), 0o644); err != nil { + t.Fatal(err) + } + if got := ResolveInstructions(); got != override { + t.Fatalf("AC67: math=%s served %q, want the override unchanged", engine, got) + } + } +} + +// Risk guard: latexInstructions is DefaultInstructions through exact-substring +// replacements. If DefaultInstructions is edited so a replacement no longer +// matches, a fence or the scaffold's library line survives into latex. +func TestLatexReplacementsAllApply(t *testing.T) { + if !strings.Contains(DefaultInstructions, tikzFence) || + !strings.Contains(DefaultInstructions, `\usetikzlibrary{arrows.meta,calc}`) { + t.Fatal("DefaultInstructions no longer carries the tikz fences this guard checks") + } + for i := 0; i < len(latexReplacements); i += 2 { + if n := strings.Count(DefaultInstructions, latexReplacements[i]); n != 1 { + t.Errorf("latex replacement %d matches DefaultInstructions %d times, want 1: %q", i/2, n, latexReplacements[i]) + } + } + if latexInstructions == DefaultInstructions { + t.Fatal("latexInstructions is DefaultInstructions: no replacement applied") + } + if n := strings.Count(latexInstructions, tikzFence); n != 0 { + t.Fatalf("%d %s mention(s) survived into latexInstructions", n, tikzFence) + } + if strings.Contains(latexInstructions, `\usetikzlibrary{arrows.meta,calc}`) { + t.Fatal("the scaffold's \\usetikzlibrary line survived into latexInstructions") + } +} diff --git a/internal/mcp/run_test.go b/internal/mcp/run_test.go index 059f928..560e78f 100644 --- a/internal/mcp/run_test.go +++ b/internal/mcp/run_test.go @@ -213,3 +213,48 @@ func TestRunBlamesAStaleEnvironmentTokenOnA401(t *testing.T) { t.Fatalf("stale environment token hint: %s", text) } } + +// gatedTransport tells the test when Run starts serving, then waits for the +// test before connecting. +type gatedTransport struct { + sdk.Transport + serving chan struct{} + release chan struct{} +} + +func (g gatedTransport) Connect(ctx context.Context) (sdk.Connection, error) { + close(g.serving) + <-g.release + return g.Transport.Connect(ctx) +} + +func TestRunKeepsInstructionsFromStart(t *testing.T) { // AC68 + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + write := func(engine string) { + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("[terminal]\nmath = \""+engine+"\"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + write("typst") + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + serverT, clientT := sdk.NewInMemoryTransports() + gate := gatedTransport{Transport: serverT, serving: make(chan struct{}), release: make(chan struct{})} + done := make(chan error, 1) + go func() { done <- Run(ctx, Options{Transport: gate}) }() + <-gate.serving + write("latex") // the server is running; the client has not connected yet + close(gate.release) + client := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "0"}, nil) + cs, err := client.Connect(ctx, clientT, nil) + if err != nil { + t.Fatal(err) + } + got := cs.InitializeResult().Instructions + _ = cs.Close() + <-done + if !strings.Contains(got, "```tikz") { + t.Fatalf("AC68: after config.toml switched to latex mid-run, the served instructions lost the ```tikz fences:\n%s", got) + } +} From 40fea41a2ca9d63a5ce3605f814a00d4991056fd Mon Sep 17 00:00:00 2001 From: Spirit of Light Date: Mon, 26 Oct 2026 14:26:03 -0400 Subject: [PATCH 05/10] Chore(docs): document math = "latex", its tools and its limits The README's reading-view section and the in-app help describe the math engine choice, render_timeout and render_concurrency, where preamble.tex lives and what it loads. They also say what latex needs: pdftex from a basic TeX Live plus the packages zn uses, and pdftocairo from poppler. They warn that TeX can read any file by absolute path, so a note can show a readable file in the preview, and advise leaving latex off for untrusted vaults. The Homebrew formula gains caveats with the same install steps and no new dependency. Co-Authored-By: Claude Opus 5.5 --- README.md | 23 +++++++++++++--- internal/tui/helpdata.go | 4 ++- internal/tui/tui_test.go | 33 +++++++++++++++++++++++ packaging/homebrew/Formula/zn.rb | 11 ++++++++ packaging/homebrew/test_update_formula.py | 14 ++++++++++ 5 files changed, 81 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b8ea01b..385eaef 100644 --- a/README.md +++ b/README.md @@ -623,8 +623,25 @@ sheet of your own, or `zen` for the built-in renderer). cards; Enter opens them. - **`![[Note]]`** renders the note inline. - **Mermaid** blocks draw as text through a pure-Go renderer, or as - pictures when `mmdc` is installed. **`$$` math** renders through `typst` - when it is installed. + pictures when `mmdc` is installed. +- **`[terminal] math`** picks the engine for display math. `typst`, the + default, renders `$$` blocks when `typst` is installed. `latex` also renders + `\[ \]`, `tikzpicture` and the amsmath display environments (`equation`, + `align`, `gather`, `multline` and the rest, starred or not), and needs + `pdftex` and `pdftocairo` (poppler) on PATH; `zn doctor` names a missing + one. A basic TeX Live is enough: add the packages zn uses with + `tlmgr install standalone varwidth preview xcolor amsmath amsfonts mhchem pgf` + (or your distribution's TeX packages), and others later as your preamble + needs them. Packages come from `preamble.tex` next to + `config.toml`, written on the first render with `amsmath`, `amssymb`, + `mhchem` and `tikz` (`arrows.meta`, `calc`); zn never rewrites it. Pictures + are cached in the user cache directory under `zn/tex`, which zn does not + prune. TeX can read files by absolute path, so under `latex` a note can show + in the preview the contents of any file you can read. Leave latex off for + vaults you do not trust. +- **`render_timeout`** (seconds, default 10) bounds one diagram or math + render; **`render_concurrency`** (default 2) caps how many run at once and + takes effect after a restart. ### Vaults and servers inside the app @@ -719,7 +736,7 @@ layout choices do not change these terminal layouts. | `[view]` | `tasks_view_mode`, `kanban_group_by`, `kanban_statuses`, `note_sort_order` | | `[appearance]` | `theme_family`, `theme_mode`, `theme_id` | | `[tweaks]` | `accent = "#ff3b30"`: the desktop's Quick tweaks colors | -| `[terminal]` | `mouse`, `preview_style`, `images`, `theme` (terminal only) | +| `[terminal]` | `mouse`, `preview_style`, `images`, `theme`, `math`, `render_timeout`, `render_concurrency` (terminal only) | | `[keymaps]` | `"action.id" = "keys"`, or `""` to unbind; `:keymaps` lists the ids | | `[kanban_column_titles]` | `review = "In Review"` | | `[saved_filters]` | `Important = "!high"` | diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index bcaadc8..4abecb2 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -174,10 +174,12 @@ func helpLines(a *App, filter string) []string { "[terminal] theme\tA theme for zn alone, when the terminal should not match the desktop app", ":style \tReading view style: auto (zennotes, matches the palette), dark, light, dracula, tokyo-night, pink, ascii, notty, a Glamour JSON file, or the built-in zen renderer", "[terminal] preview_style\tThe same setting in config.toml, next to mouse = true|false", + "[terminal] math\tEngine for display math: typst (default) or latex, which also renders \\[ \\], align and tikzpicture blocks and needs pdftex (a basic TeX Live plus the packages the README lists) and pdftocairo (poppler); packages come from preamble.tex next to config.toml. Leave latex off for vaults you do not trust: a note can show any file you can read", + "[terminal] render_timeout\tSeconds one diagram or math render may take (default 10); render_concurrency caps how many run at once (default 2, after a restart)", "", "## Preview mode", "Esc / q / i / Ctrl+E\tBack to the editor (i lands on the line under the cursor); Space z e, :editmode and Alt+E do the same", - "Embeds\tPictures paint inline in Kitty and Ghostty ([terminal] images in config.toml; tmux needs allow-passthrough on); PDF, audio, video, drawings, files and YouTube or Vimeo links are cards, Enter opens them; ![[Note]] renders the note inline; mermaid blocks draw as text, or as pictures with mmdc installed; $$ math renders through typst when installed", + "Embeds\tPictures paint inline in Kitty and Ghostty ([terminal] images in config.toml; tmux needs allow-passthrough on); PDF, audio, video, drawings, files and YouTube or Vimeo links are cards, Enter opens them; ![[Note]] renders the note inline; mermaid blocks draw as text, or as pictures with mmdc installed; $$ math renders through typst when installed, or through latex with [terminal] math = \"latex\"", "j k gg G Ctrl+D Ctrl+U\tScroll", "Enter / gd\tFollow the link on the row", "x\tToggle the task on the row", diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 762212e..32df879 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -239,3 +239,36 @@ func TestHumanSize(t *testing.T) { t.Fatalf("%s %s %s", humanSize(512), humanSize(2048), humanSize(3<<20)) } } + +// AC71: the in-app help mentions the latex settings and the preamble file. +func TestHelpMentionsLatexSettings(t *testing.T) { + a := &App{} + a.keymap = newTestResolver() + help := strings.Join(helpLines(a, ""), "\n") + for _, want := range []string{"[terminal] math", "preamble.tex", "render_timeout", "render_concurrency"} { + if !contains(help, want) { + t.Errorf("help does not mention %q", want) + } + } +} + +// AC79: the help row keyed `[terminal] math` names the tools latex needs. +func TestHelpMathEntryNamesTools(t *testing.T) { + a := &App{} + a.keymap = newTestResolver() + var row string + for _, l := range helpLines(a, "") { + if key, _, ok := strings.Cut(l, "\t"); ok && key == "[terminal] math" { + row = l + break + } + } + if row == "" { + t.Fatal("no help row with the key [terminal] math") + } + for _, want := range []string{"pdftex", "pdftocairo"} { + if !contains(row, want) { + t.Errorf("[terminal] math row does not name %q: %q", want, row) + } + } +} diff --git a/packaging/homebrew/Formula/zn.rb b/packaging/homebrew/Formula/zn.rb index 7fc8d5a..1e9220b 100644 --- a/packaging/homebrew/Formula/zn.rb +++ b/packaging/homebrew/Formula/zn.rb @@ -29,6 +29,17 @@ def install bin.install "zn" end + def caveats + <<~EOS + zn renders display math with typst by default. With math = "latex" under + [terminal] in config.toml, it needs pdftex and pdftocairo on PATH. A + basic TeX Live is enough, plus poppler and the packages zn uses: + brew install poppler + tlmgr install standalone varwidth preview xcolor amsmath amsfonts mhchem pgf + Add other packages with tlmgr later, as your preamble.tex needs them. + EOS + end + test do ENV["ZENNOTES_CONFIG_DIR"] = (testpath/"config").to_s assert_match version.to_s, shell_output("#{bin}/zn --version") diff --git a/packaging/homebrew/test_update_formula.py b/packaging/homebrew/test_update_formula.py index f4831a0..34c528b 100644 --- a/packaging/homebrew/test_update_formula.py +++ b/packaging/homebrew/test_update_formula.py @@ -1,4 +1,5 @@ import copy +import re from pathlib import Path import unittest @@ -48,6 +49,19 @@ def test_rejects_formula_without_all_platforms(self): with self.assertRaises(ValueError): render_formula(formula, "1.2.3", self.release) + def test_formula_has_latex_caveats_without_dependencies(self): + # AC69: caveats name the latex tools; no depends_on is added for them. + caveats = re.search(r"^ def caveats\n(.*?)^ end\n", self.formula, re.M | re.S) + self.assertIsNotNone(caveats, "formula has no def caveats") + for want in ('math = "latex"', "pdftex", "pdftocairo"): + self.assertIn(want, caveats.group(1)) + for line in self.formula.splitlines(): + if "depends_on" in line: + for tool in ("pdftex", "pdftocairo", "poppler", "texlive", "mactex"): + self.assertNotIn(tool, line.lower()) + result = render_formula(self.formula, "1.2.3", self.release) + self.assertIn(caveats.group(0), result) + if __name__ == "__main__": unittest.main() From ab7dede863d7f803cce6747bad61f270071b9e26 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 09:46:55 -0500 Subject: [PATCH 06/10] Fix(tui): a LaTeX block wider than the page renders whole instead of clipped standalone's varwidth page is \linewidth wide, about 12 cm, and pdftex lets a longer display or a wider picture run past its right edge, where pdftocairo cuts it off. A 26-term sum lost its last half and a 20 cm TikZ figure stopped at 12 cm, with nothing in the preview to say so. TeX already reports the overflow ("Overfull \hbox (395pt too wide)"), so a block whose log says so renders once more with the page widened by that much through \standaloneconfig. A block that fits renders once, as before, at the usual width: widening every page instead would stretch multline and push equation numbers out to the far edge, and the crop would then keep a wide, mostly empty picture. The cache key's version token moves on so the clipped PNGs already on disk are not read again. --- internal/tui/latex.go | 42 ++++++++++++++++++++++++-- internal/tui/latex_test.go | 55 ++++++++++++++++++++++++++++++++++ internal/tui/latex_tex_test.go | 30 +++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/internal/tui/latex.go b/internal/tui/latex.go index 0577601..dac2f60 100644 --- a/internal/tui/latex.go +++ b/internal/tui/latex.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "regexp" + "strconv" "strings" "sync" @@ -243,6 +244,27 @@ func buildFormat(ctx context.Context, texDir, name, src string) error { return nil } +// overfullRe matches TeX's report of a line wider than the page. +var overfullRe = regexp.MustCompile(`Overfull \\hbox \(([0-9.]+)pt too wide\)`) + +// overfullBy is how far, in pt, the widest line in a TeX log runs past the +// page, or 0 when every line fits. +func overfullBy(log []byte) float64 { + widest := 0.0 + for _, m := range overfullRe.FindAllSubmatch(log, -1) { + if v, err := strconv.ParseFloat(string(m[1]), 64); err == nil && v > widest { + widest = v + } + } + return widest +} + +// widerPage is the preamble line that widens standalone's varwidth page by +// over pt, plus a little for rounding in the log. +func widerPage(over float64) string { + return fmt.Sprintf("\\standaloneconfig{varwidth=\\dimexpr\\linewidth+%dpt\\relax}\n", int(over)+2) +} + // latexDPI rasterises 10pt TeX at about the size of typst's 18pt at 144 ppi. const latexDPI = "260" @@ -321,6 +343,21 @@ func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byt if out, err := runTool(ctx, dir, texEnv(texDir), "pdftex", args...); err != nil { return nil, texError(out, filepath.Join(dir, "m.log"), err) } + // The page is \linewidth wide, and a longer display or a wider picture + // is cut off at its right edge. TeX reports by how much it ran over, so + // that block alone renders again on a page widened by as much; a block + // that fits keeps the usual width, so multline and equation numbers + // still sit where they would in a document. + if log, err := os.ReadFile(filepath.Join(dir, "m.log")); err == nil { + if over := overfullBy(log); over > 0 { + if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(widerPage(over)+latexDocument(src, fg)), 0o644); err != nil { + return nil, err + } + if out, err := runTool(ctx, dir, texEnv(texDir), "pdftex", args...); err != nil { + return nil, texError(out, filepath.Join(dir, "m.log"), err) + } + } + } if out, err := runTool(ctx, dir, nil, "pdftocairo", "-png", "-transp", "-singlefile", "-r", latexDPI, "m.pdf", "m"); err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("latex: pdftocairo: %w", err) @@ -365,7 +402,8 @@ func currentLatexFormat() string { // latexKey names a latex block in memory and, without the "math:" prefix, // its PNG on disk: theme colour, format (wrapper + preamble hash) and source. -// crop1 versions the key so uncropped PNGs from before cropToInk are not read. +// The version token changes whenever the pictures do, so PNGs that an +// older zn cached are not read. func latexKey(fg, format, src string) string { - return contentKey("math", "latex\x00crop1\x00"+fg+"\x00"+format+"\x00"+src) + return contentKey("math", "latex\x00wide1\x00"+fg+"\x00"+format+"\x00"+src) } diff --git a/internal/tui/latex_test.go b/internal/tui/latex_test.go index a18c9df..1b3a4e5 100644 --- a/internal/tui/latex_test.go +++ b/internal/tui/latex_test.go @@ -1249,3 +1249,58 @@ func TestLatexKeyVersionedForCrop(t *testing.T) { // AC88 } t.Fatalf("no new PNG beside the pre-crop one under %s: %v", texDir, pngFiles(texDir)) } + +func TestOverfullBy(t *testing.T) { + log := []byte("This is pdfTeX\n" + + "Overfull \\hbox (12.5pt too wide) detected at line 3\n" + + "Overfull \\hbox (395.19788pt too wide) detected at line 2\n" + + "Overfull \\vbox (80.0pt too high) has occurred while \\output is active\n") + if got := overfullBy(log); got != 395.19788 { + t.Fatalf("overfullBy = %v, want the widest hbox, 395.19788", got) + } + if got := overfullBy([]byte("This is pdfTeX\nOutput written on m.pdf\n")); got != 0 { + t.Fatalf("overfullBy of a clean log = %v, want 0", got) + } +} + +func TestOverfullBlockRendersAgainOnAWiderPage(t *testing.T) { + latexEnv(t) + n := 0 + calls := fakeTeX(t, texFake{render: func(ctx context.Context, base string) ([]byte, error) { + n++ + log := "This is pdfTeX\n" + if n == 1 { + log += "Overfull \\hbox (395.19788pt too wide) detected at line 2\n" + } + if err := os.WriteFile(base+".pdf", []byte("%PDF-1.5 fake"), 0o644); err != nil { + return nil, err + } + return nil, os.WriteFile(base+".log", []byte(log), 0o644) + }}) + if img := renderLatex(newLatexApp(t, 0), `\[ a+b+c \]`, 10*time.Second); failed(img) { + t.Fatalf("render: %v", imgErr(img)) + } + r := renderCalls(calls()) + if len(r) != 2 { + t.Fatalf("pdftex rendered %d times, want 2 (the second on a wider page)", len(r)) + } + const wider = `\standaloneconfig{varwidth=\dimexpr\linewidth+397pt\relax}` + if strings.Contains(r[0].allTeX(), wider) { + t.Fatalf("the first render already widened the page:\n%s", r[0].allTeX()) + } + second := r[1].allTeX() + if !strings.HasPrefix(second, wider+"\n") || !strings.Contains(second, `a+b+c`) { + t.Fatalf("the second render does not widen the page by the overflow:\n%s", second) + } +} + +func TestFittingBlockRendersOnce(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + if img := renderLatex(newLatexApp(t, 0), `\[ x \]`, 10*time.Second); failed(img) { + t.Fatalf("render: %v", imgErr(img)) + } + if r := renderCalls(calls()); len(r) != 1 { + t.Fatalf("pdftex rendered %d times for a block that fits, want 1", len(r)) + } +} diff --git a/internal/tui/latex_tex_test.go b/internal/tui/latex_tex_test.go index 2af8b9b..c1916d8 100644 --- a/internal/tui/latex_tex_test.go +++ b/internal/tui/latex_tex_test.go @@ -371,3 +371,33 @@ func TestRealDisplayPNGCroppedToInk(t *testing.T) { // AC88 t.Errorf("displayed height %d rows at 80 cols, %d at 120: it depends on the pane width", narrow.rows, wide.rows) } } + +func TestRealWideBlocksAreNotClipped(t *testing.T) { + _, texDir := realTeXEnv(t) + long := "" + for c := 'a'; c <= 'z'; c++ { + long += string(c) + "+" + string(c-'a'+'A') + "+" + } + for _, tc := range []struct { + name, src string + minW int + }{ + // 26 letter pairs run past standalone's 345pt page; whole, they + // are well over 2000 px at latexDPI. + {"display", "\\[ " + long + "1 \\]", 2000}, + // 20cm is about 2050 px at latexDPI; the page alone is 1256. + {"tikz", "\\begin{tikzpicture}\\draw (0,0) rectangle (20cm,1cm);\\end{tikzpicture}", 2000}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.RemoveAll(texDir); err != nil { + t.Fatal(err) + } + if img := renderReal(newLatexApp(t, 0), tc.src, realTimeout); failed(img) { + t.Fatalf("render: %v", imgErr(img)) + } + if w := onlyPNG(t, texDir).Bounds().Dx(); w < tc.minW { + t.Fatalf("PNG is %d px wide, want at least %d: the block was clipped at the page edge", w, tc.minW) + } + }) + } +} From 2e3d1f642025b757320bdb4a44e1ee33725a563e Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 09:53:25 -0500 Subject: [PATCH 07/10] Fix(tui): ```tikz fences render under latex, and zn mcp keeps the desktop's instructions Under math = "latex" the MCP server served a rewritten copy of the note-writing instructions that asked agents for a bare tikzpicture block instead of a ```tikz fence, because only the bare block rendered in the zn preview. But the vault is the desktop's too: the desktop, the phones and the share viewer render ```tikz fences and show a bare tikzpicture as plain text, so every figure an agent wrote through zn mcp would draw in one terminal and nowhere else. The instructions are the desktop's text again, byte for byte, under either engine. The preview meets them instead: under latex a ```tikz fence now renders through pdftex, normalised the way the desktop's TikZ renderer does it (wrapSource in apps/desktop/src/main/tikz.ts): a \documentclass line goes, and \usepackage or \usetikzlibrary lines above the picture stay in the preamble, ahead of \begin{document}. The default preamble.tex loads the TikZ libraries the desktop loads for every fence, so a fence written there (right=of a, -Stealth) draws here without lines of its own. Until the picture is ready, and when TeX fails, the fence shows as code, with the error above it. Under typst a fence stays code, as before. Bare tikzpicture blocks still render under latex. The real-TeX test now renders the instructions' own ```tikz example as a fence. --- README.md | 10 ++- internal/mcp/instructions.go | 43 +--------- internal/mcp/instructions_test.go | 131 ++---------------------------- internal/mcp/run_test.go | 45 ---------- internal/tui/embeds.go | 47 ++++++++--- internal/tui/embeds_test.go | 94 ++++++++++++++++----- internal/tui/helpdata.go | 2 +- internal/tui/latex.go | 66 +++++++++++++-- internal/tui/latex_test.go | 34 +++++++- internal/tui/latex_tex_test.go | 30 ++++--- internal/tui/previewglamour.go | 5 +- 11 files changed, 243 insertions(+), 264 deletions(-) diff --git a/README.md b/README.md index 385eaef..687a79c 100644 --- a/README.md +++ b/README.md @@ -627,14 +627,20 @@ sheet of your own, or `zen` for the built-in renderer). - **`[terminal] math`** picks the engine for display math. `typst`, the default, renders `$$` blocks when `typst` is installed. `latex` also renders `\[ \]`, `tikzpicture` and the amsmath display environments (`equation`, - `align`, `gather`, `multline` and the rest, starred or not), and needs + `align`, `gather`, `multline` and the rest, starred or not), and + ` ```tikz ` fences the way the desktop app draws them (`\usepackage` and + `\usetikzlibrary` lines above the picture are kept). It needs `pdftex` and `pdftocairo` (poppler) on PATH; `zn doctor` names a missing one. A basic TeX Live is enough: add the packages zn uses with `tlmgr install standalone varwidth preview xcolor amsmath amsfonts mhchem pgf` (or your distribution's TeX packages), and others later as your preamble needs them. Packages come from `preamble.tex` next to `config.toml`, written on the first render with `amsmath`, `amssymb`, - `mhchem` and `tikz` (`arrows.meta`, `calc`); zn never rewrites it. Pictures + `mhchem` and `tikz` with the TikZ libraries the desktop loads (`arrows.meta`, + `calc`, `positioning`, `shapes`, `decorations.pathreplacing`, + `intersections`, `patterns`); zn never rewrites it. The desktop also loads + `pgfplots` and `circuitikz` when a fence uses them; for those, install them + and add them to `preamble.tex`. Pictures are cached in the user cache directory under `zn/tex`, which zn does not prune. TeX can read files by absolute path, so under `latex` a note can show in the preview the contents of any file you can read. Leave latex off for diff --git a/internal/mcp/instructions.go b/internal/mcp/instructions.go index 09f4f75..12ab74c 100644 --- a/internal/mcp/instructions.go +++ b/internal/mcp/instructions.go @@ -9,55 +9,16 @@ import ( // ResolveInstructions is the effective system instruction: the user's // override from `/zennotes.mcp-instructions.md` (written by -// Settings → MCP in the desktop app) when present, else the compiled -// default for the `[terminal] math` engine: latexInstructions under latex, -// DefaultInstructions otherwise (also when config.toml cannot be read). -// Run calls it once, so a config change takes effect on the next start. +// Settings → MCP in the desktop app) when present, the compiled default +// otherwise. func ResolveInstructions() string { raw, err := os.ReadFile(config.MCPInstructionsPath()) if err == nil && strings.TrimSpace(string(raw)) != "" { return string(raw) } - if prefs, _, err := config.LoadPrefs(); err == nil && prefs.TerminalMath == "latex" { - return latexInstructions - } return DefaultInstructions } -// latexReplacements turns DefaultInstructions into the latex variant: the -// zn preview renders a bare tikzpicture block with the user's preamble.tex, -// so TikZ is asked for unfenced and without its own \usetikzlibrary line. -// Old/new pairs. TestLatexReplacementsAllApply fails when an old string no -// longer occurs exactly once in DefaultInstructions. -var latexReplacements = []string{ - "emit a ```tikz (or\n", - "emit a bare `tikzpicture` block (or a\n", - - "- ```tikz: vectors,", - "- bare `\\begin{tikzpicture}` block: vectors,", - - "- `tikz`: emit the TikZ content itself, usually a bare\n" + - " `\\begin{tikzpicture} \u2026 \\end{tikzpicture}` block. If you need\n" + - " extra libraries or packages, add `\\usetikzlibrary{\u2026}` /\n" + - " `\\usepackage{\u2026}` above it. Never emit `\\documentclass`.\n", - "- TikZ: no fence. Put a `\\begin{tikzpicture} \u2026 \\end{tikzpicture}`\n" + - " block on lines of its own. Libraries and packages come from the\n" + - " user's `preamble.tex`, whose default loads amsmath, amssymb, mhchem,\n" + - " tikz and the TikZ libraries arrows.meta,calc. Never emit\n" + - " `\\documentclass`, `\\usepackage` or `\\usetikzlibrary`.\n", - - "convert to ```tikz.\n", - "convert to a bare `tikzpicture` block.\n", - - "```tikz\n\\usetikzlibrary{arrows.meta,calc}\n\\begin{tikzpicture}", - "\\begin{tikzpicture}", - - "\\end{tikzpicture}\n```\n", - "\\end{tikzpicture}\n", -} - -var latexInstructions = strings.NewReplacer(latexReplacements...).Replace(DefaultInstructions) - // DefaultInstructions is a verbatim copy of the desktop MCP server's // instructions (apps/desktop/src/mcp/instructions.ts), so an agent behaves // the same whichever binary is serving the vault. diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go index c4ed981..f4fba04 100644 --- a/internal/mcp/instructions_test.go +++ b/internal/mcp/instructions_test.go @@ -3,134 +3,21 @@ package mcp import ( "os" "path/filepath" - "strings" "testing" - - "github.com/ZenNotes/tui/internal/config" ) -const tikzFence = "```tikz" - -// useMath points the config dir at a fresh temp dir whose config.toml sets -// [terminal] math to engine ("" writes no config.toml at all). -func useMath(t *testing.T, engine string) string { - t.Helper() - dir := t.TempDir() - t.Setenv("ZENNOTES_CONFIG_DIR", dir) - if engine != "" { - writeMath(t, dir, engine) - } - return dir -} - -func writeMath(t *testing.T, dir, engine string) { - t.Helper() - body := "[terminal]\nmath = \"" + engine + "\"\n" - if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} - -// katexParagraph is the "Math → KaTeX" paragraph of DefaultInstructions, -// up to the blank line that ends it. -func katexParagraph(t *testing.T) string { - t.Helper() - start := strings.Index(DefaultInstructions, "**Math → KaTeX.**") - if start < 0 { - t.Fatal("DefaultInstructions has no \"Math → KaTeX\" paragraph") - } - end := strings.Index(DefaultInstructions[start:], "\n\n") - if end < 0 { - t.Fatal("the KaTeX paragraph has no end") - } - return DefaultInstructions[start : start+end+2] -} - -func TestInstructionsLatexAsksForBareTikz(t *testing.T) { // AC64 - useMath(t, "latex") - got := ResolveInstructions() - if !strings.Contains(got, `\begin{tikzpicture}`) { - t.Fatalf("AC64: latex instructions do not ask for a \\begin{tikzpicture} block") - } - if n := strings.Count(got, tikzFence); n != 0 { - t.Fatalf("AC64: latex instructions still ask for %s fences (%d mentions)", tikzFence, n) - } - if strings.Contains(got, `\usetikzlibrary{arrows.meta,calc}`) { - t.Fatalf("AC64: the latex scaffold still loads its own library with \\usetikzlibrary") - } - if !strings.Contains(got, "preamble.tex") { - t.Fatalf("AC64: latex instructions do not say libraries and packages come from preamble.tex") - } - if !strings.Contains(got, "arrows.meta,calc") { - t.Fatalf("AC64: latex instructions do not say the default preamble loads arrows.meta,calc") - } - // The scaffold is a bare block: the line above its \begin{tikzpicture} - // is not a code fence of any language. - lines := strings.Split(got, "\n") - found := false - for i, l := range lines { - if !strings.HasPrefix(l, `\begin{tikzpicture}[>=Stealth`) { - continue - } - found = true - if i > 0 && strings.HasPrefix(strings.TrimSpace(lines[i-1]), "```") { - t.Fatalf("AC64: the TikZ scaffold is still fenced: %q", lines[i-1]) - } - } - if !found { - t.Fatalf("AC64: latex instructions lost the TikZ scaffold") - } -} - -func TestInstructionsLatexKeepsKatexParagraph(t *testing.T) { // AC65 - useMath(t, "latex") - if want := katexParagraph(t); !strings.Contains(ResolveInstructions(), want) { - t.Fatalf("AC65: the Math → KaTeX paragraph changed under latex; want byte-equal:\n%s", want) - } -} - -func TestInstructionsTypstIsDefault(t *testing.T) { // AC66 - for _, engine := range []string{"typst", ""} { - useMath(t, engine) - if got := ResolveInstructions(); got != DefaultInstructions { - t.Fatalf("AC66: math=%q instructions differ from DefaultInstructions", engine) - } - } -} - -func TestInstructionsOverrideWinsForBothEngines(t *testing.T) { // AC67 - const override = "My own rules.\n```tikz\nkeep me\n```\n" +// The desktop renders ```tikz fences and the zn preview renders them under +// either engine, so the served text stays the desktop's whatever the math +// engine: an agent writes notes that draw on every surface. +func TestInstructionsAreTheDesktopsUnderEitherEngine(t *testing.T) { for _, engine := range []string{"typst", "latex"} { - useMath(t, engine) - if err := os.WriteFile(config.MCPInstructionsPath(), []byte(override), 0o644); err != nil { + dir := t.TempDir() + t.Setenv("ZENNOTES_CONFIG_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("[terminal]\nmath = \""+engine+"\"\n"), 0o644); err != nil { t.Fatal(err) } - if got := ResolveInstructions(); got != override { - t.Fatalf("AC67: math=%s served %q, want the override unchanged", engine, got) - } - } -} - -// Risk guard: latexInstructions is DefaultInstructions through exact-substring -// replacements. If DefaultInstructions is edited so a replacement no longer -// matches, a fence or the scaffold's library line survives into latex. -func TestLatexReplacementsAllApply(t *testing.T) { - if !strings.Contains(DefaultInstructions, tikzFence) || - !strings.Contains(DefaultInstructions, `\usetikzlibrary{arrows.meta,calc}`) { - t.Fatal("DefaultInstructions no longer carries the tikz fences this guard checks") - } - for i := 0; i < len(latexReplacements); i += 2 { - if n := strings.Count(DefaultInstructions, latexReplacements[i]); n != 1 { - t.Errorf("latex replacement %d matches DefaultInstructions %d times, want 1: %q", i/2, n, latexReplacements[i]) + if got := ResolveInstructions(); got != DefaultInstructions { + t.Fatalf("math = %q serves instructions that differ from DefaultInstructions", engine) } } - if latexInstructions == DefaultInstructions { - t.Fatal("latexInstructions is DefaultInstructions: no replacement applied") - } - if n := strings.Count(latexInstructions, tikzFence); n != 0 { - t.Fatalf("%d %s mention(s) survived into latexInstructions", n, tikzFence) - } - if strings.Contains(latexInstructions, `\usetikzlibrary{arrows.meta,calc}`) { - t.Fatal("the scaffold's \\usetikzlibrary line survived into latexInstructions") - } } diff --git a/internal/mcp/run_test.go b/internal/mcp/run_test.go index 560e78f..059f928 100644 --- a/internal/mcp/run_test.go +++ b/internal/mcp/run_test.go @@ -213,48 +213,3 @@ func TestRunBlamesAStaleEnvironmentTokenOnA401(t *testing.T) { t.Fatalf("stale environment token hint: %s", text) } } - -// gatedTransport tells the test when Run starts serving, then waits for the -// test before connecting. -type gatedTransport struct { - sdk.Transport - serving chan struct{} - release chan struct{} -} - -func (g gatedTransport) Connect(ctx context.Context) (sdk.Connection, error) { - close(g.serving) - <-g.release - return g.Transport.Connect(ctx) -} - -func TestRunKeepsInstructionsFromStart(t *testing.T) { // AC68 - dir := t.TempDir() - t.Setenv("ZENNOTES_CONFIG_DIR", dir) - write := func(engine string) { - if err := os.WriteFile(filepath.Join(dir, "config.toml"), []byte("[terminal]\nmath = \""+engine+"\"\n"), 0o644); err != nil { - t.Fatal(err) - } - } - write("typst") - ctx, cancel := context.WithCancel(t.Context()) - defer cancel() - serverT, clientT := sdk.NewInMemoryTransports() - gate := gatedTransport{Transport: serverT, serving: make(chan struct{}), release: make(chan struct{})} - done := make(chan error, 1) - go func() { done <- Run(ctx, Options{Transport: gate}) }() - <-gate.serving - write("latex") // the server is running; the client has not connected yet - close(gate.release) - client := sdk.NewClient(&sdk.Implementation{Name: "test", Version: "0"}, nil) - cs, err := client.Connect(ctx, clientT, nil) - if err != nil { - t.Fatal(err) - } - got := cs.InitializeResult().Instructions - _ = cs.Close() - <-done - if !strings.Contains(got, "```tikz") { - t.Fatalf("AC68: after config.toml switched to latex mid-run, the served instructions lost the ```tikz fences:\n%s", got) - } -} diff --git a/internal/tui/embeds.go b/internal/tui/embeds.go index b3d4811..41335f0 100644 --- a/internal/tui/embeds.go +++ b/internal/tui/embeds.go @@ -157,7 +157,8 @@ func (a *App) assetIndex() map[string]string { } // embedBlockLines renders a block when it is an embed of some kind; ok is -// false for ordinary Markdown, which Glamour then renders. +// false for ordinary Markdown, which Glamour then renders below any rows +// returned anyway (a ```tikz fence's TeX error). func (a *App) embedBlockLines(b mdBlock, lines []string, width int, fromPath string) ([]previewLine, bool) { switch b.kind { case "fence": @@ -165,6 +166,9 @@ func (a *App) embedBlockLines(b mdBlock, lines []string, width int, fromPath str if lang == "mermaid" && b.end-b.start > 2 { return a.mermaidLines(strings.Join(lines[b.start+1:b.end-1], "\n"), b.start, width), true } + if lang == "tikz" && a.prefs.TerminalMath == "latex" && b.end-b.start > 2 { + return a.tikzLines(strings.Join(lines[b.start+1:b.end-1], "\n"), b.start, width) + } return nil, false case "math", "tex": body := strings.Join(lines[b.start:b.end], "\n") @@ -446,12 +450,7 @@ func (a *App) mathLines(body string, srcLine, width int) []previewLine { if a.prefs.TerminalMath == "latex" { src := strings.TrimSpace(body) img = a.externalImage(latexKey(string(th.Fg), currentLatexFormat(), src), width-2, "latex", src) - if img != nil && img.err != nil && !errors.Is(img.err, exec.ErrNotFound) { - // Wrapped, not truncated: the cause sits at the end of long paths. - for _, row := range wrapCommentText(firstLine(img.err.Error()), width-2) { - out = append(out, previewLine{text: " " + th.StatusError.Render(row), srcLine: srcLine}) - } - } + out = append(out, a.latexErrorRows(img, srcLine, width)...) } else { img = a.externalImage(contentKey("math", fmt.Sprintf("typst\x00%t\x00%s", th.Dark, inner)), width-2, "math", inner) } @@ -465,6 +464,34 @@ func (a *App) mathLines(body string, srcLine, width int) []previewLine { return out } +// tikzLines renders a ```tikz fence through LaTeX under math = "latex", as +// the desktop draws the same fence. ok is false until the picture is ready +// and when it fails, so the fence shows as code, with a TeX error above it. +func (a *App) tikzLines(src string, srcLine, width int) ([]previewLine, bool) { + if a.imageMode() != imagesKitty { + return nil, false + } + img := a.externalImage(latexKey(string(a.theme.Fg), currentLatexFormat(), latexKeySrc("tikz", src)), width-2, "tikz", src) + if img != nil && img.err == nil { + return a.pictureLines(img, srcLine, width, "tikz"), true + } + return a.latexErrorRows(img, srcLine, width), false +} + +// latexErrorRows is the first line of a failed latex render, wrapped rather +// than truncated because the cause sits at the end of long paths. A render +// that worked, is still running or lacks its tools has none. +func (a *App) latexErrorRows(img *termImage, srcLine, width int) []previewLine { + if img == nil || img.err == nil || errors.Is(img.err, exec.ErrNotFound) { + return nil + } + out := []previewLine{} + for _, row := range wrapCommentText(firstLine(img.err.Error()), width-2) { + out = append(out, previewLine{text: " " + a.theme.StatusError.Render(row), srcLine: srcLine}) + } + return out +} + // externalImage returns a rendered picture for a key once a background // render finished; it starts the render on first call and returns nil // until then; a failed render is returned with its err set. Renders need @@ -481,7 +508,7 @@ func (a *App) externalImage(key string, maxCols int, kind, src string) *termImag d.mu.Unlock() return nil } - tools := map[string][]string{"mermaid": {"mmdc"}, "math": {"typst"}, "latex": {"pdftex", "pdftocairo"}}[kind] + tools := map[string][]string{"mermaid": {"mmdc"}, "math": {"typst"}, "latex": {"pdftex", "pdftocairo"}, "tikz": {"pdftex", "pdftocairo"}}[kind] for _, tool := range tools { if _, err := exec.LookPath(tool); err != nil { img := &termImage{err: err} @@ -541,11 +568,11 @@ func (a *App) renderEmbed(key string, req renderReq) *termImage { defer cancel() var data []byte var err error - if req.kind == "latex" { + if req.kind == "latex" || req.kind == "tikz" { if req.fg == "" { req.fg = string(a.theme.Fg) } - data, err = latexPNG(ctx, &d.latex, req.src, req.fg) + data, err = latexPNG(ctx, &d.latex, req.kind, req.src, req.fg) } else { data, err = renderExternal(ctx, req.kind, req.src, req.dark) } diff --git a/internal/tui/embeds_test.go b/internal/tui/embeds_test.go index b5ddcd3..62af679 100644 --- a/internal/tui/embeds_test.go +++ b/internal/tui/embeds_test.go @@ -1324,28 +1324,29 @@ func TestSplitBlocksTypstLeavesLatexAsText(t *testing.T) { // AC11 } } -func TestTikzFenceStaysCodeBlock(t *testing.T) { // AC12 +// Under latex a ```tikz fence renders through pdftex instead, as on the +// desktop (TestTikzFenceRendersThroughLatex); it is a fence block either way. +func TestTikzFenceStaysCodeBlockUnderTypst(t *testing.T) { // AC12 note := "Before.\n\n```tikz\n\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}\n```\n\nAfter." - for _, engine := range []string{"typst", "latex"} { - t.Run(engine, func(t *testing.T) { - if got, want := strings.Join(blockKinds(note, engine == "latex"), " "), "para[0,1) blank[1,2) fence[2,7) blank[7,8) para[8,9)"; got != want { - t.Fatalf("blocks %q, want %q", got, want) - } - latexEnv(t) - addTool(t, "typst") - calls := fakeTeX(t, texFake{}) - typst := fakeTypst(t) - a, done := newNoteApp(t, engine, "kitty") - lines := showNote(a, done, note, 300*time.Millisecond) - if n := typst(); n != 0 || len(calls()) != 0 { - t.Fatalf("tikz fence was rendered: typst %d, tex %+v", n, calls()) - } - if isPicture(lines) || !strings.Contains(joinPreview(lines), "\\draw (0,0) -- (1,1);") { - t.Fatalf("tikz fence is not shown as a code block: %s", joinPreview(lines)) - } - sameLines(t, lines, beforeChange(t, note)) - }) + if got, want := strings.Join(blockKinds(note, true), " "), "para[0,1) blank[1,2) fence[2,7) blank[7,8) para[8,9)"; got != want { + t.Fatalf("latex blocks %q, want %q", got, want) + } + if got, want := strings.Join(blockKinds(note, false), " "), "para[0,1) blank[1,2) fence[2,7) blank[7,8) para[8,9)"; got != want { + t.Fatalf("typst blocks %q, want %q", got, want) + } + latexEnv(t) + addTool(t, "typst") + calls := fakeTeX(t, texFake{}) + typst := fakeTypst(t) + a, done := newNoteApp(t, "typst", "kitty") + lines := showNote(a, done, note, 300*time.Millisecond) + if n := typst(); n != 0 || len(calls()) != 0 { + t.Fatalf("tikz fence was rendered: typst %d, tex %+v", n, calls()) } + if isPicture(lines) || !strings.Contains(joinPreview(lines), "\\draw (0,0) -- (1,1);") { + t.Fatalf("tikz fence is not shown as a code block: %s", joinPreview(lines)) + } + sameLines(t, lines, beforeChange(t, note)) } func TestInlineMathUnchangedUnderLatex(t *testing.T) { // AC13 @@ -1362,3 +1363,56 @@ func TestInlineMathUnchangedUnderLatex(t *testing.T) { // AC13 } sameLines(t, lines, beforeChange(t, note)) } + +func tikzFence() ([]string, mdBlock) { + lines := []string{"```tikz", "\\begin{tikzpicture}", "\\draw (0,0) -- (1,1);", "\\end{tikzpicture}", "```"} + return lines, mdBlock{kind: "fence", start: 0, end: len(lines)} +} + +func TestTikzFenceRendersThroughLatex(t *testing.T) { + latexEnv(t) + calls := fakeTeX(t, texFake{}) + a, done := newMathApp(t, mathPrefs("latex", "kitty")) + lines, b := tikzFence() + if _, ok := a.embedBlockLines(b, lines, 80, ""); ok { + t.Fatal("the fence is a picture before its render finished") + } + if !nextRender(done, 5*time.Second) { + t.Fatalf("no render finished; calls %+v", calls()) + } + out, ok := a.embedBlockLines(b, lines, 80, "") + if !ok || !isPicture(out) { + t.Fatalf("the ```tikz fence is not a picture under latex:\n%s", joinPreview(out)) + } + doc := renderInput(t, calls()) + if !strings.Contains(doc, "\\begin{document}") || !strings.Contains(doc, "\\draw (0,0) -- (1,1);") || strings.Contains(doc, "```") { + t.Fatalf("pdftex did not get the fence body as a document:\n%s", doc) + } +} + +func TestFailedTikzFenceShowsTheErrorAboveTheCode(t *testing.T) { + latexEnv(t) + fakeTeX(t, texFake{render: func(ctx context.Context, base string) ([]byte, error) { + _ = os.WriteFile(base+".log", []byte("! Undefined control sequence.\n"), 0o644) + return []byte("! Undefined control sequence."), errors.New("exit status 1") + }}) + a, _ := newDatabaseTestApp(t) + a.prefs = prefsView{Prefs: mathPrefs("latex", "kitty")} + a.imageStore = &imageStore{byKey: map[string]*termImage{}, nextID: 1000, cellW: 10, cellH: 20, out: io.Discard} + done := watchRenders(t, a) + body := strings.Join(func() []string { l, _ := tikzFence(); return l }(), "\n") + if _, err := a.renderGlamourLinesFrom(body, 80, "Doc.md"); err != nil { + t.Fatal(err) + } + if !nextRender(done, 5*time.Second) { + t.Fatal("no render finished") + } + out, err := a.renderGlamourLinesFrom(body, 80, "Doc.md") + if err != nil { + t.Fatal(err) + } + errRow, codeRow := rowIndex(out, "latex: ! Undefined control sequence."), rowIndex(out, "\\draw (0,0)") + if errRow < 0 || codeRow < 0 || errRow > codeRow { + t.Fatalf("want the TeX error above the fence's code (error row %d, code row %d):\n%s", errRow, codeRow, joinPreview(out)) + } +} diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index 4abecb2..228f043 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -174,7 +174,7 @@ func helpLines(a *App, filter string) []string { "[terminal] theme\tA theme for zn alone, when the terminal should not match the desktop app", ":style \tReading view style: auto (zennotes, matches the palette), dark, light, dracula, tokyo-night, pink, ascii, notty, a Glamour JSON file, or the built-in zen renderer", "[terminal] preview_style\tThe same setting in config.toml, next to mouse = true|false", - "[terminal] math\tEngine for display math: typst (default) or latex, which also renders \\[ \\], align and tikzpicture blocks and needs pdftex (a basic TeX Live plus the packages the README lists) and pdftocairo (poppler); packages come from preamble.tex next to config.toml. Leave latex off for vaults you do not trust: a note can show any file you can read", + "[terminal] math\tEngine for display math: typst (default) or latex, which also renders \\[ \\], align and tikzpicture blocks and ```tikz fences; it needs pdftex (a basic TeX Live plus the packages the README lists) and pdftocairo (poppler); packages come from preamble.tex next to config.toml. Leave latex off for vaults you do not trust: a note can show any file you can read", "[terminal] render_timeout\tSeconds one diagram or math render may take (default 10); render_concurrency caps how many run at once (default 2, after a restart)", "", "## Preview mode", diff --git a/internal/tui/latex.go b/internal/tui/latex.go index dac2f60..3ef9b16 100644 --- a/internal/tui/latex.go +++ b/internal/tui/latex.go @@ -28,7 +28,7 @@ const defaultPreamble = `% zn LaTeX preamble: loaded before every display-math b \usepackage{amssymb} \usepackage[version=4]{mhchem} \usepackage{tikz} -\usetikzlibrary{arrows.meta,calc} +\usetikzlibrary{arrows.meta,calc,positioning,shapes,decorations.pathreplacing,intersections,patterns} ` // formatWrapper goes around preamble.tex in the precompiled format. The @@ -103,6 +103,37 @@ func latexDocument(src, fg string) string { if len(body) >= 4 && strings.HasPrefix(body, "$$") && strings.HasSuffix(body, "$$") { body = "\\[\n" + strings.TrimSpace(body[2:len(body)-2]) + "\n\\]" } + return texBody(body, fg) +} + +// tikzDocument is the per-render source for a ```tikz fence, normalised the +// way the desktop's TikZ renderer does it (wrapSource in +// apps/desktop/src/main/tikz.ts), so a fence draws the same in both: a +// \documentclass line goes, and the \usepackage and \usetikzlibrary lines +// above the picture stay ahead of \begin{document}, where the format is +// still in the preamble and \usepackage is allowed. +func tikzDocument(src, fg string) string { + s := strings.TrimSpace(docClassLineRe.ReplaceAllString(strings.TrimSpace(src), "")) + begin, end := strings.Index(s, `\begin{document}`), strings.LastIndex(s, `\end{document}`) + if begin >= 0 && end > begin { + return strings.TrimSpace(s[:begin]) + "\n" + texBody(strings.TrimSpace(s[begin+len(`\begin{document}`):end]), fg) + } + s = strings.TrimSpace(strings.NewReplacer(`\begin{document}`, "", `\end{document}`, "").Replace(s)) + if loc := tikzBodyStartRe.FindStringIndex(s); loc != nil && loc[0] > 0 { + return strings.TrimSpace(s[:loc[0]]) + "\n" + texBody(strings.TrimSpace(s[loc[0]:]), fg) + } + return texBody(s, fg) +} + +var ( + docClassLineRe = regexp.MustCompile(`(?m)^\s*\\documentclass(?:\[[^\]]*\])?\{[^}]+\}\s*$`) + // tikzBodyStartRe finds where a fence's drawing starts, as the desktop + // does; whatever comes before it is preamble. + tikzBodyStartRe = regexp.MustCompile(`\\begin\{[^}]+\}|\\(?:tikz|draw|path|node|coordinate|matrix|graph)\b`) +) + +// texBody wraps a block's body in the document, in the theme's text colour. +func texBody(body, fg string) string { colour := "" if m := hexColour.FindStringSubmatch(fg); m != nil { colour = "\\color[HTML]{" + strings.ToUpper(m[1]) + "}" @@ -110,6 +141,24 @@ func latexDocument(src, fg string) string { return "\\begin{document}" + colour + "\n" + body + "\n\\end{document}\n" } +// texSource is the document a latex render compiles: display math, or a +// ```tikz fence under kind "tikz". +func texSource(kind, src, fg string) string { + if kind == "tikz" { + return tikzDocument(src, fg) + } + return latexDocument(src, fg) +} + +// latexKeySrc is the source part of a block's key. A ```tikz fence is keyed +// apart from display math, since the two are compiled differently. +func latexKeySrc(kind, src string) string { + if kind == "tikz" { + return "tikz\x00" + src + } + return src +} + // latexDir is /zn/tex: formats and, later, PNGs. func latexDir() (string, error) { c, err := os.UserCacheDir() @@ -309,10 +358,11 @@ func cropToInk(data []byte, border int) ([]byte, error) { return buf.Bytes(), nil } -// latexPNG renders one block to PNG bytes with the format built from -// preamble.tex. The PNG is cached on disk in /zn/tex, named -// by colour, format and source, so a later session runs no tools for it. -func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byte, error) { +// latexPNG renders one block (kind "latex" for display math, "tikz" for a +// ```tikz fence) to PNG bytes with the format built from preamble.tex. The +// PNG is cached on disk in /zn/tex, named by colour, format +// and source, so a later session runs no tools for it. +func latexPNG(ctx context.Context, formats *latexFormats, kind, src, fg string) ([]byte, error) { preamble, err := ensurePreamble() if err != nil { return nil, err @@ -323,7 +373,7 @@ func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byt } fmtSrc := formatWrapper + string(preamble) name := latexFormatName(preamble) - cached := filepath.Join(texDir, strings.TrimPrefix(latexKey(fg, name, src), "math:")+".png") + cached := filepath.Join(texDir, strings.TrimPrefix(latexKey(fg, name, latexKeySrc(kind, src)), "math:")+".png") if b, err := os.ReadFile(cached); err == nil { return b, nil } @@ -335,7 +385,7 @@ func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byt return nil, err } defer os.RemoveAll(dir) - if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(latexDocument(src, fg)), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(texSource(kind, src, fg)), 0o644); err != nil { return nil, err } args := append([]string{"-fmt=" + name}, texSandbox...) @@ -350,7 +400,7 @@ func latexPNG(ctx context.Context, formats *latexFormats, src, fg string) ([]byt // still sit where they would in a document. if log, err := os.ReadFile(filepath.Join(dir, "m.log")); err == nil { if over := overfullBy(log); over > 0 { - if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(widerPage(over)+latexDocument(src, fg)), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "m.tex"), []byte(widerPage(over)+texSource(kind, src, fg)), 0o644); err != nil { return nil, err } if out, err := runTool(ctx, dir, texEnv(texDir), "pdftex", args...); err != nil { diff --git a/internal/tui/latex_test.go b/internal/tui/latex_test.go index 1b3a4e5..51cc954 100644 --- a/internal/tui/latex_test.go +++ b/internal/tui/latex_test.go @@ -401,8 +401,10 @@ func TestEnsurePreambleWritesDefaultPackages(t *testing.T) { // AC16 t.Errorf("preamble.tex does not load %s:\n%s", pkg, pre) } } - if !strings.Contains(pre, `\usetikzlibrary{arrows.meta,calc}`) { - t.Errorf("preamble.tex lacks \\usetikzlibrary{arrows.meta,calc}:\n%s", pre) + // The desktop's TikZ renderer loads these libraries for every fence, so a + // fence written there draws here without a \usetikzlibrary line. + if !strings.Contains(pre, `\usetikzlibrary{arrows.meta,calc,positioning,shapes,decorations.pathreplacing,intersections,patterns}`) { + t.Errorf("preamble.tex does not load the desktop's TikZ libraries:\n%s", pre) } } @@ -1304,3 +1306,31 @@ func TestFittingBlockRendersOnce(t *testing.T) { t.Fatalf("pdftex rendered %d times for a block that fits, want 1", len(r)) } } + +func TestTikzDocumentNormalisesLikeTheDesktop(t *testing.T) { + const fg = "#EBDBB2" + body := texBody + for _, tc := range []struct{ name, src, want string }{ + { + "library line stays in the preamble", + "\\usetikzlibrary{positioning}\n\\begin{tikzpicture}\n\\node (a) {A};\n\\end{tikzpicture}", + "\\usetikzlibrary{positioning}\n" + body("\\begin{tikzpicture}\n\\node (a) {A};\n\\end{tikzpicture}", fg), + }, + { + "documentclass goes, a full document keeps its preamble", + "\\documentclass[tikz]{standalone}\n\\usepackage{amsmath}\n\\begin{document}\n\\tikz \\draw (0,0) -- (1,1);\n\\end{document}", + "\\usepackage{amsmath}\n" + body("\\tikz \\draw (0,0) -- (1,1);", fg), + }, + { + "bare commands are all body", + "\\draw (0,0) -- (1,1);", + body("\\draw (0,0) -- (1,1);", fg), + }, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tikzDocument(tc.src, fg); got != tc.want { + t.Fatalf("tikzDocument:\n%s\nwant:\n%s", got, tc.want) + } + }) + } +} diff --git a/internal/tui/latex_tex_test.go b/internal/tui/latex_tex_test.go index c1916d8..5c2c39f 100644 --- a/internal/tui/latex_tex_test.go +++ b/internal/tui/latex_tex_test.go @@ -307,40 +307,37 @@ func TestRealPNGHasTransparentBackground(t *testing.T) { // AC39 // instructionsTikz cuts the first bare tikzpicture out of the MCP // instructions. +// instructionsTikz is the ```tikz example fence of the MCP instructions, +// fence lines included: what an agent writes into a note. func instructionsTikz(t *testing.T) []string { t.Helper() out := []string{} for _, l := range strings.Split(mcp.DefaultInstructions, "\n") { - if len(out) == 0 && !strings.HasPrefix(l, "\\begin{tikzpicture}") { + if len(out) == 0 && l != "```tikz" { continue } out = append(out, l) - if strings.HasPrefix(l, "\\end{tikzpicture}") { + if len(out) > 1 && l == "```" { return out } } - t.Fatalf("no \\begin{tikzpicture} … \\end{tikzpicture} lines in mcp.DefaultInstructions") + t.Fatalf("no ```tikz example fence in mcp.DefaultInstructions") return nil } -func TestRealInstructionsTikzExampleRenders(t *testing.T) { // AC74 +func TestRealInstructionsTikzExampleRenders(t *testing.T) { realTeXEnv(t) tikz := instructionsTikz(t) - for _, l := range tikz { - if strings.Contains(l, "usetikzlibrary") { - t.Fatalf("cut lines load a library themselves: %q", l) - } - } lines := append(append([]string{"A vector:", ""}, tikz...), "", "done.") var blk *mdBlock for _, b := range splitBlocks(lines, true) { - if b.kind == "tex" { + if b.kind == "fence" { b := b blk = &b } } if blk == nil { - t.Fatalf("the bare tikzpicture is not a tex block: %+v", splitBlocks(lines, true)) + t.Fatalf("the ```tikz example is not a fence block: %+v", splitBlocks(lines, true)) } a, done := realMathApp(t) out := showBlock(a, done, lines, *blk) @@ -401,3 +398,14 @@ func TestRealWideBlocksAreNotClipped(t *testing.T) { }) } } + +func TestRealDesktopTikzFenceRenders(t *testing.T) { + realTeXEnv(t) + // right=of needs positioning, which the default preamble loads as the + // desktop does; the \usepackage line above the picture is preamble. + src := "\\usepackage{amssymb}\n\\begin{tikzpicture}\n\\node (a) {$\\mathbb{R}$};\n\\node[right=of a] (b) {B};\n\\draw[-Stealth] (a) -- (b);\n\\end{tikzpicture}" + img := newLatexApp(t, 0).renderEmbed(contentKey("math", "tikz\x00"+src), renderReq{kind: "tikz", src: src, dark: true, maxCols: 80, timeout: realTimeout}) + if failed(img) { + t.Fatalf("a desktop-style ```tikz fence does not render: %v", imgErr(img)) + } +} diff --git a/internal/tui/previewglamour.go b/internal/tui/previewglamour.go index 46f239d..67df699 100644 --- a/internal/tui/previewglamour.go +++ b/internal/tui/previewglamour.go @@ -403,8 +403,9 @@ func (a *App) renderGlamourLinesFrom(text string, width int, fromPath string) ([ lines := strings.Split(text, "\n") out := []previewLine{} for _, b := range splitBlocks(lines, a.prefs.TerminalMath == "latex") { - if embedded, ok := a.embedBlockLines(b, lines, width, fromPath); ok { - out = append(out, embedded...) + embedded, ok := a.embedBlockLines(b, lines, width, fromPath) + out = append(out, embedded...) + if ok { continue } switch b.kind { From 47a06090e89bcf9c4e1bb32164a2de1ada483f1a Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 09:54:49 -0500 Subject: [PATCH 08/10] Fix(editor): $$ math is highlighted as LaTeX under the default engine too The merge taught the editor to colour display math, picking chroma's lexer by engine: tex under latex, typst under typst. typst is the default, and the $$ blocks in a ZenNotes vault hold LaTeX, because the desktop renders them with KaTeX. chroma's typst lexer reads every backslash in \frac or \sqrt as an error token, which the theme paints red, so every existing note's math turned red in the editor for anyone who never touched the setting. $$ bodies are now lexed as tex under both engines; \[ \] and the display environments are still lexed only under latex, where they are math. A ```tikz fence, which chroma has no lexer for and so left plain, is lexed as tex as well. --- internal/tui/codehl.go | 28 ++++++++++++---------------- internal/tui/embeds_test.go | 19 +++++++++++++++---- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/internal/tui/codehl.go b/internal/tui/codehl.go index c9eab46..773b233 100644 --- a/internal/tui/codehl.go +++ b/internal/tui/codehl.go @@ -91,9 +91,11 @@ func (a *App) codeSpansFor(buf *noteBuffer, lines []string) map[int][]codeSpan { } // highlightFences finds ```lang blocks and display-math blocks and tokenizes -// their bodies. engine is prefs.TerminalMath: under "latex" $$…$$ bodies, -// block-level \[…\] and the AC7 environments are lexed as tex; otherwise -// $$…$$ bodies are lexed as typst. +// their bodies. $$…$$ bodies are lexed as tex under either engine: $$ holds +// LaTeX on every ZenNotes surface (the desktop renders it with KaTeX), and +// chroma's typst lexer marks each of its backslashes as an error, in red. +// engine is prefs.TerminalMath; under "latex" block-level \[…\] and the +// display environments are lexed as tex too. func highlightFences(lines []string, styleName, engine string) map[int][]codeSpan { out := map[int][]codeSpan{} style := styles.Get(styleName) @@ -114,23 +116,17 @@ func highlightFences(lines []string, styleName, engine string) map[int][]codeSpa for j < len(lines) && !fenceRe.MatchString(lines[j]) { j++ } + if lang == "tikz" { + // chroma has no tikz lexer; the fence body is TeX. + lang = "tex" + } if lang != "" && j > i+1 { highlightBlock(out, lines[i+1:j], i+1, lang, style) } i = j + 1 } - latex := engine == "latex" - mathLang := "typst" - if latex { - mathLang = "tex" - } - for _, b := range splitBlocks(lines, latex) { - lang := mathLang - switch b.kind { - case "math": - case "tex": - lang = "tex" - default: + for _, b := range splitBlocks(lines, engine == "latex") { + if b.kind != "math" && b.kind != "tex" { continue } // The opener is line b.start; the closer, when there is one, is b.end-1. @@ -140,7 +136,7 @@ func highlightFences(lines []string, styleName, engine string) map[int][]codeSpa end-- } if end > b.start+1 { - highlightBlock(out, lines[b.start+1:end], b.start+1, lang, style) + highlightBlock(out, lines[b.start+1:end], b.start+1, "tex", style) } } return out diff --git a/internal/tui/embeds_test.go b/internal/tui/embeds_test.go index 62af679..56119a9 100644 --- a/internal/tui/embeds_test.go +++ b/internal/tui/embeds_test.go @@ -219,18 +219,29 @@ func TestHighlightEnvironmentWithTex(t *testing.T) { // AC58 } } -func TestHighlightDollarBodyWithTypstUnderTypst(t *testing.T) { // AC59 - lines := []string{"Some prose", "", "$$", `sum_(i=1)^n x_i = "hi" + 2`, "$$", "", "after"} +// $$ holds the desktop's KaTeX, so typst does not change how it is lexed: +// chroma's typst lexer would paint every backslash as an error. +func TestHighlightDollarBodyWithTexUnderTypst(t *testing.T) { + lines := []string{"Some prose", "", "$$", `x = \frac{-b \pm \sqrt{b^2-4ac}}{2a}`, "$$", "", "after"} spans := highlightFences(lines, "gruvbox", "typst") - assertMathBodyLexed(t, spans, lines, 2, 4, "typst", "tex") + assertMathBodyLexed(t, spans, lines, 2, 4, "tex", "typst") if _, ok := spans[0]; ok { t.Fatal("prose is untouched") } } +func TestHighlightTikzFenceWithTex(t *testing.T) { + lines := []string{"```tikz", `\begin{tikzpicture}`, `\draw (0,0) -- (1,1);`, `\end{tikzpicture}`, "```"} + for _, engine := range []string{"typst", "latex"} { + assertMathBodyLexed(t, highlightFences(lines, "gruvbox", engine), lines, 0, 4, "tex", "typst") + } +} + func TestCodeSpansForRehighlightsWhenEngineChanges(t *testing.T) { a := &App{theme: buildTheme(true), prefs: prefsView{Prefs: config.Prefs{TerminalMath: "typst"}}} - text := "$$\n\\frac{a}{b}\n$$" + // \[ \] is display math only under latex, so its spans change with the + // engine; $$ is lexed as tex under both. + text := "\\[\n\\frac{a}{b}\n\\]" buf := ¬eBuffer{ed: vim.New(text, vim.Options{}, vim.Hooks{})} lines := strings.Split(text, "\n") typst := fmt.Sprint(a.codeSpansFor(buf, lines)) From 21940b4428c281d78b510181c26aad39f9744932 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 09:56:50 -0500 Subject: [PATCH 09/10] Fix(tui): the LaTeX cache deletes what it has not used for 30 days Everything latex renders stays under /zn/tex: one format per preamble.tex (3.5 MB with the default packages, so every edit of the preamble leaves another behind) and one PNG per block per theme colour, including every intermediate version of a formula typed with the preview open. Nothing was ever deleted, and the README said so. A session now touches the format it renders with and every PNG it reads back, and the first render of a session deletes the formats and PNGs untouched for 30 days, plus the temp files of renders that died midway. Age rather than "not the current preamble" decides, because a second zn with its own config dir, and so its own preamble, shares the cache and keeps its format by using it. A file that cannot be deleted, open in another zn on Windows, waits for the next session. --- README.md | 32 +++++++-------- internal/tui/latex.go | 49 +++++++++++++++++++++++ internal/tui/latex_test.go | 73 ++++++++++++++++++++++++++++++++++ internal/tui/latex_tex_test.go | 6 ++- 4 files changed, 142 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 687a79c..0861c09 100644 --- a/README.md +++ b/README.md @@ -627,24 +627,24 @@ sheet of your own, or `zen` for the built-in renderer). - **`[terminal] math`** picks the engine for display math. `typst`, the default, renders `$$` blocks when `typst` is installed. `latex` also renders `\[ \]`, `tikzpicture` and the amsmath display environments (`equation`, - `align`, `gather`, `multline` and the rest, starred or not), and - ` ```tikz ` fences the way the desktop app draws them (`\usepackage` and - `\usetikzlibrary` lines above the picture are kept). It needs - `pdftex` and `pdftocairo` (poppler) on PATH; `zn doctor` names a missing - one. A basic TeX Live is enough: add the packages zn uses with + `align`, `gather`, `multline` and the rest, starred or not), and ` ```tikz ` + fences the way the desktop app draws them (`\usepackage` and + `\usetikzlibrary` lines above the picture are kept). It needs `pdftex` and + `pdftocairo` (poppler) on PATH; `zn doctor` names a missing one. A basic TeX + Live is enough: add the packages zn uses with `tlmgr install standalone varwidth preview xcolor amsmath amsfonts mhchem pgf` (or your distribution's TeX packages), and others later as your preamble - needs them. Packages come from `preamble.tex` next to - `config.toml`, written on the first render with `amsmath`, `amssymb`, - `mhchem` and `tikz` with the TikZ libraries the desktop loads (`arrows.meta`, - `calc`, `positioning`, `shapes`, `decorations.pathreplacing`, - `intersections`, `patterns`); zn never rewrites it. The desktop also loads - `pgfplots` and `circuitikz` when a fence uses them; for those, install them - and add them to `preamble.tex`. Pictures - are cached in the user cache directory under `zn/tex`, which zn does not - prune. TeX can read files by absolute path, so under `latex` a note can show - in the preview the contents of any file you can read. Leave latex off for - vaults you do not trust. + needs them. Packages come from `preamble.tex` next to `config.toml`, written + on the first render with `amsmath`, `amssymb`, `mhchem` and `tikz` with the + TikZ libraries the desktop loads (`arrows.meta`, `calc`, `positioning`, + `shapes`, `decorations.pathreplacing`, `intersections`, `patterns`); zn + never rewrites it. The desktop also loads `pgfplots` and `circuitikz` when a + fence uses them; for those, install them and add them to `preamble.tex`. + Pictures are cached in the user cache directory under `zn/tex`; zn deletes + the pictures and formats there that it has not used for 30 days. TeX can + read files by absolute path, so under `latex` a note can show in the preview + the contents of any file you can read. Leave latex off for vaults you do not + trust. - **`render_timeout`** (seconds, default 10) bounds one diagram or math render; **`render_concurrency`** (default 2) caps how many run at once and takes effect after a restart. diff --git a/internal/tui/latex.go b/internal/tui/latex.go index 3ef9b16..7d41596 100644 --- a/internal/tui/latex.go +++ b/internal/tui/latex.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/ZenNotes/tui/internal/config" ) @@ -230,6 +231,8 @@ func (l *latexFormats) ensure(ctx context.Context, texDir, name, src string) err l.mu.Unlock() if !fileExists(path) { b.err = buildFormat(ctx, texDir, name, src) + } else { + touch(path) } if errors.Is(b.err, context.DeadlineExceeded) { l.mu.Lock() @@ -264,6 +267,49 @@ func fileExists(p string) bool { return err == nil } +// touch marks a cached file as used now, so pruneLatexCache keeps it. +func touch(p string) { + now := time.Now() + _ = os.Chtimes(p, now, now) +} + +// latexCacheMaxAge is how long a format or a picture may go unused before +// pruneLatexCache deletes it. Every session touches the format it uses and +// every picture it reads, so only leftovers age: the format of a preamble +// since edited (a few MB each) and the PNGs of formulas since changed. +const latexCacheMaxAge = 30 * 24 * time.Hour + +var latexPruneOnce sync.Once + +// pruneLatexCache deletes the formats and pictures in texDir that no render +// has used for latexCacheMaxAge, and what renders that died midway left +// behind. Another zn with another preamble keeps its format by touching it. +// A file that cannot be deleted (open in another zn on Windows) is tried +// again next session. +func pruneLatexCache(texDir string, now time.Time) { + entries, err := os.ReadDir(texDir) + if err != nil { + return + } + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue + } + name, age := e.Name(), now.Sub(info.ModTime()) + switch { + case strings.HasSuffix(name, ".fmt") || strings.HasSuffix(name, ".png"): + if age > latexCacheMaxAge { + _ = os.Remove(filepath.Join(texDir, name)) + } + case strings.HasPrefix(name, "build-") || strings.HasPrefix(name, "png-"): + if age > time.Hour { + _ = os.RemoveAll(filepath.Join(texDir, name)) + } + } + } +} + // buildFormat dumps the wrapper and preamble into a format in a temp dir // inside texDir, then renames it into place so readers never see half a file. func buildFormat(ctx context.Context, texDir, name, src string) error { @@ -375,11 +421,14 @@ func latexPNG(ctx context.Context, formats *latexFormats, kind, src, fg string) name := latexFormatName(preamble) cached := filepath.Join(texDir, strings.TrimPrefix(latexKey(fg, name, latexKeySrc(kind, src)), "math:")+".png") if b, err := os.ReadFile(cached); err == nil { + touch(cached) return b, nil } if err := formats.ensure(ctx, texDir, name, fmtSrc); err != nil { return nil, err } + // After ensure, which touched this session's format. + latexPruneOnce.Do(func() { go pruneLatexCache(texDir, time.Now()) }) dir, err := os.MkdirTemp("", "zn-latex") if err != nil { return nil, err diff --git a/internal/tui/latex_test.go b/internal/tui/latex_test.go index 51cc954..e0c9a14 100644 --- a/internal/tui/latex_test.go +++ b/internal/tui/latex_test.go @@ -1334,3 +1334,76 @@ func TestTikzDocumentNormalisesLikeTheDesktop(t *testing.T) { }) } } + +func TestPruneLatexCacheDropsWhatNoRenderUsed(t *testing.T) { + dir := t.TempDir() + now := time.Now() + age := func(name string, d time.Duration, isDir bool) { + p := filepath.Join(dir, name) + var err error + if isDir { + err = os.Mkdir(p, 0o755) + } else { + err = os.WriteFile(p, []byte("x"), 0o644) + } + if err != nil { + t.Fatal(err) + } + if err := os.Chtimes(p, now.Add(-d), now.Add(-d)); err != nil { + t.Fatal(err) + } + } + day := 24 * time.Hour + age("zn-old.fmt", 40*day, false) + age("zn-cur.fmt", time.Minute, false) + age("old.png", 40*day, false) + age("new.png", day, false) + age("build-dead", 2*time.Hour, true) + age("png-dead", 2*time.Hour, false) + age("build-live", time.Minute, true) + age("notes.txt", 40*day, false) + pruneLatexCache(dir, now) + left := []string{} + entries, _ := os.ReadDir(dir) + for _, e := range entries { + left = append(left, e.Name()) + } + if got, want := strings.Join(left, " "), "build-live new.png notes.txt zn-cur.fmt"; got != want { + t.Fatalf("left %q, want %q", got, want) + } +} + +func TestLatexRenderTouchesWhatItUses(t *testing.T) { + _, texDir := latexEnv(t) + fakeTeX(t, texFake{}) + if img := renderLatex(newLatexApp(t, 0), `\[ x \]`, 10*time.Second); failed(img) { + t.Fatalf("render: %v", imgErr(img)) + } + old := time.Now().Add(-40 * 24 * time.Hour) + files := append(fmtFiles(t, texDir), pngFiles(texDir)...) + if len(files) != 2 { + t.Fatalf("want one format and one PNG, got %v", files) + } + for _, f := range files { + if err := os.Chtimes(f, old, old); err != nil { + t.Fatal(err) + } + } + // A new session reads the cached PNG, then renders another block on the + // same format. + a := newLatexApp(t, 0) + for _, src := range []string{`\[ x \]`, `\[ y \]`} { + if img := renderLatex(a, src, 10*time.Second); failed(img) { + t.Fatalf("render %s: %v", src, imgErr(img)) + } + } + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + t.Fatal(err) + } + if time.Since(info.ModTime()) > time.Hour { + t.Errorf("%s was used but not touched, so the prune would delete it", filepath.Base(f)) + } + } +} diff --git a/internal/tui/latex_tex_test.go b/internal/tui/latex_tex_test.go index 5c2c39f..4de58b4 100644 --- a/internal/tui/latex_tex_test.go +++ b/internal/tui/latex_tex_test.go @@ -217,8 +217,10 @@ func TestRealFormatReusedAcrossApps(t *testing.T) { // AC25 if err != nil { t.Fatalf("format gone after later renders: %v", err) } - if !after.ModTime().Equal(before.ModTime()) || !os.SameFile(before, after) { - t.Fatalf("format rebuilt: mtime %v -> %v", before.ModTime(), after.ModTime()) + // A rebuild renames a new file into place; the later session only + // touches the one it reuses, so the mtime moves and the file does not. + if !os.SameFile(before, after) { + t.Fatalf("format rebuilt: %s is a new file", fmts[0]) } if got := fmtFiles(t, texDir); len(got) != 1 { t.Fatalf("want still one format, got %v", got) From 354723e5a0f31f957607ef9056ebb273b6de5b38 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Tue, 27 Oct 2026 10:00:45 -0500 Subject: [PATCH 10/10] Chore(tui): comments say what the LaTeX code guarantees, not which spec item it met The latex work arrived with comments and test messages keyed to an acceptance-criteria list (AC7, AC29, "Step 5", "rev 7") that never entered the repository, so a reader met references they could not follow. They now state the constraint itself, and "the AC7 environments" reads "the display environments". One comment was also wrong rather than only unreadable: texEnv claimed pdftex could not read outside its working dir. openin_any=p does not stop it; TeX Live 2026 reads any file by absolute or ../ path anyway, which is what the README warns about and what the real-TeX sandbox test asserts. The comment now says so, and that writes are what stay inside. --- internal/cli/commands_diagnostics_test.go | 16 ++-- internal/config/prefs_test.go | 46 +++++------ internal/tui/authoring_test.go | 8 +- internal/tui/embeds.go | 4 +- internal/tui/embeds_test.go | 94 +++++++++++------------ internal/tui/latex.go | 15 ++-- internal/tui/latex_test.go | 66 ++++++++-------- internal/tui/latex_tex_test.go | 24 +++--- internal/tui/previewglamour.go | 8 +- internal/tui/theme_render_test.go | 2 +- internal/tui/tui_test.go | 4 +- packaging/homebrew/test_update_formula.py | 2 +- 12 files changed, 145 insertions(+), 144 deletions(-) diff --git a/internal/cli/commands_diagnostics_test.go b/internal/cli/commands_diagnostics_test.go index 5f69fdb..6ba2dd7 100644 --- a/internal/cli/commands_diagnostics_test.go +++ b/internal/cli/commands_diagnostics_test.go @@ -56,37 +56,37 @@ func latexToolsCheck(t *testing.T, checks []diagnosticCheck, out string) diagnos return diagnosticCheck{} } -// AC75: under latex, a missing pdftex is a failing check that names it. +// Under latex, a missing pdftex is a failing check that names it. func TestDoctorLatexReportsMissingPdftex(t *testing.T) { out, checks := doctorWithTools(t, `math = "latex"`, "pdftocairo") c := latexToolsCheck(t, checks, out) if c.OK || !strings.Contains(c.Message, "pdftex") { - t.Fatalf("AC75: latex tools check %+v", c) + t.Fatalf("latex tools check %+v", c) } if strings.Contains(c.Message, "pdftocairo") { - t.Fatalf("AC75: pdftocairo is on PATH but reported missing: %+v", c) + t.Fatalf("pdftocairo is on PATH but reported missing: %+v", c) } } -// AC76: under latex, a missing pdftocairo is a failing check that names it. +// Under latex, a missing pdftocairo is a failing check that names it. func TestDoctorLatexReportsMissingPdftocairo(t *testing.T) { out, checks := doctorWithTools(t, `math = "latex"`, "pdftex") c := latexToolsCheck(t, checks, out) if c.OK || !strings.Contains(c.Message, "pdftocairo") { - t.Fatalf("AC76: latex tools check %+v", c) + t.Fatalf("latex tools check %+v", c) } if strings.Contains(strings.ReplaceAll(c.Message, "pdftocairo", ""), "pdftex") { - t.Fatalf("AC76: pdftex is on PATH but reported missing: %+v", c) + t.Fatalf("pdftex is on PATH but reported missing: %+v", c) } } -// AC77: under typst, or with no math key, doctor never mentions the TeX tools. +// Under typst, or with no math key, doctor never mentions the TeX tools. func TestDoctorTypstNeverMentionsTexTools(t *testing.T) { for _, terminal := range []string{`math = "typst"`, ``} { out, _ := doctorWithTools(t, terminal) for _, tool := range []string{"pdftex", "pdftocairo"} { if strings.Contains(out, tool) { - t.Errorf("AC77 %q: doctor output mentions %s:\n%s", terminal, tool, out) + t.Errorf("%q: doctor output mentions %s:\n%s", terminal, tool, out) } } } diff --git a/internal/config/prefs_test.go b/internal/config/prefs_test.go index bc4077c..7793eb2 100644 --- a/internal/config/prefs_test.go +++ b/internal/config/prefs_test.go @@ -90,7 +90,7 @@ func writeTerminalConfig(t *testing.T, body string) { } } -// AC78: the starter file carries an active `math = "typst"` under [terminal] +// The starter file carries an active `math = "typst"` under [terminal] // and a comment naming the tools latex needs. func TestStarterConfigNamesMathAndTools(t *testing.T) { section := DefaultConfigTOML @@ -114,29 +114,29 @@ func TestStarterConfigNamesMathAndTools(t *testing.T) { } } if !active { - t.Fatalf("AC78: [terminal] has no active math = \"typst\" line:\n%s", section) + t.Fatalf("[terminal] has no active math = \"typst\" line:\n%s", section) } for _, want := range []string{"latex", "pdftex", "pdftocairo"} { if !strings.Contains(comments, want) { - t.Errorf("AC78: [terminal] comments do not name %s:\n%s", want, comments) + t.Errorf("[terminal] comments do not name %s:\n%s", want, comments) } } } -// AC1: no math key means typst. +// No math key means typst. func TestPrefsMathDefaultsToTypst(t *testing.T) { if got := DefaultPrefs().TerminalMath; got != "typst" { - t.Fatalf("AC1 default: %q", got) + t.Fatalf("default: %q", got) } t.Setenv("ZENNOTES_CONFIG_DIR", t.TempDir()) prefs, _, err := LoadPrefs() if err != nil || prefs.TerminalMath != "typst" { - t.Fatalf("AC1 no file: math %q, err %v", prefs.TerminalMath, err) + t.Fatalf("no file: math %q, err %v", prefs.TerminalMath, err) } writeTerminalConfig(t, `images = "auto"`) prefs, _, err = LoadPrefs() if err != nil || prefs.TerminalMath != "typst" { - t.Fatalf("AC1 no key: math %q, err %v", prefs.TerminalMath, err) + t.Fatalf("no key: math %q, err %v", prefs.TerminalMath, err) } writeTerminalConfig(t, `math = "latex"`) prefs, _, err = LoadPrefs() @@ -145,28 +145,28 @@ func TestPrefsMathDefaultsToTypst(t *testing.T) { } } -// AC4: an unknown engine is not an error when zn loads; it behaves as typst. +// An unknown engine is not an error when zn loads; it behaves as typst. func TestPrefsUnknownMathLoadsAsTypst(t *testing.T) { writeTerminalConfig(t, `math = "foo"`) prefs, _, err := LoadPrefs() if err != nil { - t.Fatalf("AC4: LoadPrefs must not fail on math = \"foo\": %v", err) + t.Fatalf("LoadPrefs must not fail on math = \"foo\": %v", err) } if prefs.TerminalMath != "typst" { - t.Fatalf("AC4: math = \"foo\" loaded as %q, want typst", prefs.TerminalMath) + t.Fatalf("math = \"foo\" loaded as %q, want typst", prefs.TerminalMath) } } -// AC5: zn doctor's configuration check names the key and the allowed values. +// The configuration check of zn doctor names the key and the allowed values. func TestValidateConfigRejectsUnknownMath(t *testing.T) { writeTerminalConfig(t, `math = "foo"`) err := ValidateConfig() if err == nil { - t.Fatal("AC5: ValidateConfig accepted math = \"foo\"") + t.Fatal("ValidateConfig accepted math = \"foo\"") } for _, want := range []string{"terminal.math", "typst, latex"} { if !strings.Contains(err.Error(), want) { - t.Errorf("AC5: error %q does not contain %q", err, want) + t.Errorf("error %q does not contain %q", err, want) } } for _, ok := range []string{`math = "typst"`, `math = "latex"`} { @@ -177,13 +177,13 @@ func TestValidateConfigRejectsUnknownMath(t *testing.T) { } } -// AC37: no key, zero and negative all give a 10 second timeout. +// No key, zero and negative all give a 10 second timeout. func TestPrefsRenderTimeoutDefaultsAndNonPositive(t *testing.T) { for _, body := range []string{``, `render_timeout = 0`, `render_timeout = -3`} { writeTerminalConfig(t, body) prefs, _, err := LoadPrefs() if err != nil || prefs.RenderTimeout != 10 { - t.Errorf("AC37 %q: timeout %v, err %v", body, prefs.RenderTimeout, err) + t.Errorf("%q: timeout %v, err %v", body, prefs.RenderTimeout, err) } } writeTerminalConfig(t, `render_timeout = 2`) @@ -193,26 +193,26 @@ func TestPrefsRenderTimeoutDefaultsAndNonPositive(t *testing.T) { } } -// AC38: a non-integer timeout is a wrong-type error in doctor; zn uses 10. +// A non-integer timeout is a wrong-type error in doctor; zn uses 10. func TestPrefsRenderTimeoutWrongType(t *testing.T) { writeTerminalConfig(t, `render_timeout = "ten"`) prefs, _, err := LoadPrefs() if err != nil || prefs.RenderTimeout != 10 { - t.Fatalf("AC38 load: timeout %v, err %v", prefs.RenderTimeout, err) + t.Fatalf("load: timeout %v, err %v", prefs.RenderTimeout, err) } err = ValidateConfig() if err == nil || !strings.Contains(err.Error(), "terminal.render_timeout") || !strings.Contains(err.Error(), "wrong type") { - t.Fatalf("AC38 doctor: %v", err) + t.Fatalf("doctor: %v", err) } } -// AC50: no key, zero and negative all give a cap of 2. +// No key, zero and negative all give a cap of 2. func TestPrefsRenderConcurrencyDefaultsAndNonPositive(t *testing.T) { for _, body := range []string{``, `render_concurrency = 0`, `render_concurrency = -1`} { writeTerminalConfig(t, body) prefs, _, err := LoadPrefs() if err != nil || prefs.RenderConcurrency != 2 { - t.Errorf("AC50 %q: cap %v, err %v", body, prefs.RenderConcurrency, err) + t.Errorf("%q: cap %v, err %v", body, prefs.RenderConcurrency, err) } } writeTerminalConfig(t, `render_concurrency = 4`) @@ -222,15 +222,15 @@ func TestPrefsRenderConcurrencyDefaultsAndNonPositive(t *testing.T) { } } -// AC51: a non-integer cap is a wrong-type error in doctor; zn uses 2. +// A non-integer cap is a wrong-type error in doctor; zn uses 2. func TestPrefsRenderConcurrencyWrongType(t *testing.T) { writeTerminalConfig(t, `render_concurrency = "two"`) prefs, _, err := LoadPrefs() if err != nil || prefs.RenderConcurrency != 2 { - t.Fatalf("AC51 load: cap %v, err %v", prefs.RenderConcurrency, err) + t.Fatalf("load: cap %v, err %v", prefs.RenderConcurrency, err) } err = ValidateConfig() if err == nil || !strings.Contains(err.Error(), "terminal.render_concurrency") || !strings.Contains(err.Error(), "wrong type") { - t.Fatalf("AC51 doctor: %v", err) + t.Fatalf("doctor: %v", err) } } diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index d437107..6a77af3 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -118,12 +118,11 @@ func assertSnippet(t *testing.T, buf *noteBuffer, want string) { } } -// AC60 func TestSnippetBracketClosesUnderLatex(t *testing.T) { assertSnippet(t, typeLineAndEnter(t, "latex", `\[`), "\\[\n\n\\]") } -// AC60 as typed: auto-pairing leaves \[] with the cursor before the ], +// As typed: auto-pairing leaves \[] with the cursor before the ], // and Enter must still give \[ … \] instead of a stray ]. func TestTypedBracketClosesOnEnterUnderLatex(t *testing.T) { for _, typed := range []string{`\[`, `\begin{align}`} { @@ -147,7 +146,6 @@ func TestTypedBracketClosesOnEnterUnderLatex(t *testing.T) { } } -// AC61 func TestSnippetBeginClosesAnyEnvironment(t *testing.T) { for _, env := range []string{"align", "foo*"} { t.Run(env, func(t *testing.T) { @@ -157,12 +155,10 @@ func TestSnippetBeginClosesAnyEnvironment(t *testing.T) { } } -// AC62 func TestSnippetDollarUnchangedUnderLatex(t *testing.T) { assertSnippet(t, typeLineAndEnter(t, "latex", "$$"), "$$\n\n$$") } -// AC63 func TestSnippetTypstUnchangedAndNoBracket(t *testing.T) { assertSnippet(t, typeLineAndEnter(t, "typst", "$$"), "$$\n\n$$") for _, line := range []string{`\[`, `\begin{align}`} { @@ -173,7 +169,7 @@ func TestSnippetTypstUnchangedAndNoBracket(t *testing.T) { } } -// AC60/AC61: a closer that already follows is not added a second time. +// A closer that already follows is not added a second time. func TestSnippetLatexSkipsExistingCloser(t *testing.T) { for _, body := range []string{"\\[\n\\]", "\\begin{align}\n\\end{align}"} { a, _ := newFeatureTestApp(t) diff --git a/internal/tui/embeds.go b/internal/tui/embeds.go index 41335f0..159f75a 100644 --- a/internal/tui/embeds.go +++ b/internal/tui/embeds.go @@ -524,7 +524,7 @@ func (a *App) externalImage(key string, maxCols int, kind, src string) *termImag img := a.renderEmbed(key, req) d.mu.Lock() // A timeout is stored like any failure, so a redraw does not loop; - // opening a note drops it via forgetEmbedTimeouts (AC83). + // opening a note drops it via forgetEmbedTimeouts. d.png[key] = img delete(d.inFly, key) d.mu.Unlock() @@ -534,7 +534,7 @@ func (a *App) externalImage(key string, maxCols int, kind, src string) *termImag } // forgetEmbedTimeouts drops every timed-out render, so the next show of -// its block renders again. Called when a note is opened (AC83). +// its block renders again. Called when a note is opened. func (a *App) forgetEmbedTimeouts() { d := a.diagrams() d.mu.Lock() diff --git a/internal/tui/embeds_test.go b/internal/tui/embeds_test.go index 56119a9..7cca700 100644 --- a/internal/tui/embeds_test.go +++ b/internal/tui/embeds_test.go @@ -192,7 +192,7 @@ func assertMathBodyLexed(t *testing.T, spans map[int][]codeSpan, lines []string, } } -func TestHighlightDollarBodyWithTexUnderLatex(t *testing.T) { // AC56 +func TestHighlightDollarBodyWithTexUnderLatex(t *testing.T) { lines := []string{"Some prose", "", "$$", `\frac{a}{b} + x_1 = 2`, "$$", "", "after"} spans := highlightFences(lines, "gruvbox", "latex") assertMathBodyLexed(t, spans, lines, 2, 4, "tex", "typst") @@ -201,7 +201,7 @@ func TestHighlightDollarBodyWithTexUnderLatex(t *testing.T) { // AC56 } } -func TestHighlightBracketBlockWithTex(t *testing.T) { // AC57 +func TestHighlightBracketBlockWithTex(t *testing.T) { lines := []string{"Some prose", "", `\[`, `\alpha^2 + \beta = 3`, `\]`, "", "after"} spans := highlightFences(lines, "gruvbox", "latex") assertMathBodyLexed(t, spans, lines, 2, 4, "tex", "typst") @@ -210,7 +210,7 @@ func TestHighlightBracketBlockWithTex(t *testing.T) { // AC57 } } -func TestHighlightEnvironmentWithTex(t *testing.T) { // AC58 +func TestHighlightEnvironmentWithTex(t *testing.T) { lines := []string{"Some prose", "", `\begin{align}`, `a &= \sqrt{b} \\`, `c &= \frac{d}{e}`, `\end{align}`, "", "after"} spans := highlightFences(lines, "gruvbox", "latex") assertMathBodyLexed(t, spans, lines, 2, 5, "tex", "typst") @@ -329,7 +329,7 @@ func TestPreviewRendersEmbedsAsCardsAndDiagrams(t *testing.T) { } } -// --- Step 3: shared runner with timeout and the shared render pool --- +// --- shared runner with timeout and the shared render pool --- // TestHelperSleep is the child process for TestRunToolKillsProcessAfterTimeout. // With ZN_HELPER_SLEEP=1 it starts a grandchild that holds its output pipe @@ -362,7 +362,7 @@ func TestRunToolKillsProcessAfterTimeout(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "timed out") { t.Fatalf("want a timed out error, got %v", err) } - // AC82, AC83: a timeout must be told apart from a tool error. + // A timeout must be told apart from a tool error. if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("timeout error %v does not match context.DeadlineExceeded", err) } @@ -504,7 +504,7 @@ func isPicture(lines []previewLine) bool { return strings.Contains(joinPreview(lines), "\U0010EEEE") } -func TestTypstRenderUsesUnchangedArgs(t *testing.T) { // AC2 +func TestTypstRenderUsesUnchangedArgs(t *testing.T) { prefs := config.DefaultPrefs() prefs.TerminalMath = "typst" a, done := newRenderTestApp(t, prefs, "typst") @@ -528,7 +528,7 @@ func TestTypstRenderUsesUnchangedArgs(t *testing.T) { // AC2 } } -func TestTypstTimeoutFallsBackToRawSource(t *testing.T) { // AC35 +func TestTypstTimeoutFallsBackToRawSource(t *testing.T) { prefs := config.DefaultPrefs() prefs.TerminalMath = "typst" prefs.RenderTimeout = 1 @@ -552,7 +552,7 @@ func TestTypstTimeoutFallsBackToRawSource(t *testing.T) { // AC35 } } -func TestMermaidTimeoutFallsBackToText(t *testing.T) { // AC36 +func TestMermaidTimeoutFallsBackToText(t *testing.T) { prefs := config.DefaultPrefs() prefs.RenderTimeout = 1 a, done := newRenderTestApp(t, prefs, "mmdc") @@ -577,7 +577,7 @@ func TestMermaidTimeoutFallsBackToText(t *testing.T) { // AC36 } } -func TestTypstErrorShowsRawSourceOnly(t *testing.T) { // AC55 +func TestTypstErrorShowsRawSourceOnly(t *testing.T) { src := "$$\nx^2 + \\foo\n$$" t.Run("compile error", func(t *testing.T) { prefs := config.DefaultPrefs() @@ -635,12 +635,12 @@ func inFlight() (func(context.Context) ([]byte, error), func() int) { } } -func TestRenderPoolCapsConcurrency(t *testing.T) { // AC47, AC48 +func TestRenderPoolCapsConcurrency(t *testing.T) { for _, tc := range []struct { name string cap int // 0 keeps the default want int - }{{"default 2 (AC47)", 0, 2}, {"configured 4 (AC48)", 4, 4}} { + }{{"default 2", 0, 2}, {"configured 4", 4, 4}} { t.Run(tc.name, func(t *testing.T) { prefs := config.DefaultPrefs() prefs.TerminalMath = "typst" @@ -661,7 +661,7 @@ func TestRenderPoolCapsConcurrency(t *testing.T) { // AC47, AC48 } } -func TestRenderPoolSharedByMermaidAndMath(t *testing.T) { // AC49 +func TestRenderPoolSharedByMermaidAndMath(t *testing.T) { prefs := config.DefaultPrefs() prefs.TerminalMath = "typst" prefs.RenderConcurrency = 1 @@ -679,11 +679,11 @@ func TestRenderPoolSharedByMermaidAndMath(t *testing.T) { // AC49 } } -// --- Step 6: the math engine wired into the reading view --- +// --- the math engine wired into the reading view --- // // These drive `$$` blocks through mathLines (and whole notes through the // preview renderers), with pdftex and pdftocairo faked by fakeTeX and typst -// by fakeTypst. Block detection for \[…\] and environments is Step 7. +// by fakeTypst. Block detection for \[…\] and environments is tested further down. // fakeTypst wraps the current runTool: typst runs succeed and write their // PNG, every other tool goes to the wrapped fake. It returns the typst run @@ -784,15 +784,15 @@ func preambleExists() bool { return err == nil } -func TestMathEngineRouting(t *testing.T) { // AC1, AC2, AC3, AC4 +func TestMathEngineRouting(t *testing.T) { for _, tc := range []struct { name, terminal string latex bool }{ - {"no math key (AC1)", "images = \"kitty\"\n", false}, - {"typst (AC2)", "math = \"typst\"\nimages = \"kitty\"\n", false}, - {"latex (AC3)", "math = \"latex\"\nimages = \"kitty\"\n", true}, - {"unknown foo (AC4)", "math = \"foo\"\nimages = \"kitty\"\n", false}, + {"no math key", "images = \"kitty\"\n", false}, + {"typst", "math = \"typst\"\nimages = \"kitty\"\n", false}, + {"latex", "math = \"latex\"\nimages = \"kitty\"\n", true}, + {"unknown foo", "math = \"foo\"\nimages = \"kitty\"\n", false}, } { t.Run(tc.name, func(t *testing.T) { latexEnv(t) @@ -838,7 +838,7 @@ func TestMathEngineRouting(t *testing.T) { // AC1, AC2, AC3, AC4 } } -func TestLatexImagesOffShowsSourceWithoutPdftex(t *testing.T) { // AC14 +func TestLatexImagesOffShowsSourceWithoutPdftex(t *testing.T) { for _, images := range []string{"off", "auto"} { t.Run(images, func(t *testing.T) { latexEnv(t) @@ -862,7 +862,7 @@ func TestLatexImagesOffShowsSourceWithoutPdftex(t *testing.T) { // AC14 } } -func TestNoPreambleWithoutDisplayMath(t *testing.T) { // AC17 +func TestNoPreambleWithoutDisplayMath(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a, _ := newDatabaseTestApp(t) @@ -882,7 +882,7 @@ func TestNoPreambleWithoutDisplayMath(t *testing.T) { // AC17 } } -func TestNoPreambleUnderTypst(t *testing.T) { // AC18 +func TestNoPreambleUnderTypst(t *testing.T) { latexEnv(t) addTool(t, "typst") fakeTeX(t, texFake{}) @@ -897,9 +897,9 @@ func TestNoPreambleUnderTypst(t *testing.T) { // AC18 } } -func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { // AC40, AC41, AC42 +func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { body := "$$\nx^2\n$$" - t.Run("engine (AC40)", func(t *testing.T) { + t.Run("engine", func(t *testing.T) { latexEnv(t) addTool(t, "typst") calls := fakeTeX(t, texFake{}) @@ -914,7 +914,7 @@ func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { // AC40, AC41, t.Fatalf("the same block under latex after typst: %d pdftex renders, want 1 (typst entry reused)", n) } }) - t.Run("theme (AC41)", func(t *testing.T) { + t.Run("theme", func(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a, done := newMathApp(t, mathPrefs("latex", "kitty")) @@ -936,7 +936,7 @@ func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { // AC40, AC41, t.Fatalf("second render does not use the new theme colour %s:\n%s", hex, r[1].allTeX()) } }) - t.Run("preamble (AC42)", func(t *testing.T) { + t.Run("preamble", func(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a, done := newMathApp(t, mathPrefs("latex", "kitty")) @@ -957,7 +957,7 @@ func TestMathKeyChangesWithEngineThemeAndPreamble(t *testing.T) { // AC40, AC41, }) } -func TestTypstWritesNothingUnderTexCache(t *testing.T) { // AC45 +func TestTypstWritesNothingUnderTexCache(t *testing.T) { _, texDir := latexEnv(t) addTool(t, "typst") fakeTeX(t, texFake{}) @@ -978,7 +978,7 @@ func TestTypstWritesNothingUnderTexCache(t *testing.T) { // AC45 } } -func TestLatexMissingToolsFallBack(t *testing.T) { // AC52, AC53 +func TestLatexMissingToolsFallBack(t *testing.T) { for _, missing := range []string{"pdftex", "pdftocairo"} { t.Run("no "+missing, func(t *testing.T) { latexEnv(t) @@ -996,7 +996,7 @@ func TestLatexMissingToolsFallBack(t *testing.T) { // AC52, AC53 } } -func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 +func TestLatexErrorRowAboveSource(t *testing.T) { // wantRow checks that the block is not a picture and that a row holding // msg sits above the row holding the block's source. wantRow := func(t *testing.T, lines []previewLine, msg, src string) { @@ -1010,7 +1010,7 @@ func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 t.Fatalf("want a %q row above the source %q, got:\n%s", msg, src, all) } } - t.Run("LaTeX error (AC54)", func(t *testing.T) { + t.Run("LaTeX error", func(t *testing.T) { latexEnv(t) log := "This is pdfTeX, Version 3.141592653\n! Undefined control sequence.\nl.3 \\nosuchmacro\n! Emergency stop.\n" fakeTeX(t, texFake{render: func(_ context.Context, base string) ([]byte, error) { @@ -1024,7 +1024,7 @@ func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 t.Fatalf("error row is not only the first TeX error line: %s", joinPreview(lines)) } }) - t.Run("broken preamble (AC27)", func(t *testing.T) { + t.Run("broken preamble", func(t *testing.T) { latexEnv(t) if err := os.WriteFile(config.PreamblePath(), []byte("\\usepackage{amsmath}\n\\badmacro\n"), 0o644); err != nil { t.Fatal(err) @@ -1042,7 +1042,7 @@ func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 wantRow(t, showMath(a, done, b.body, 5*time.Second), "! Undefined control sequence.", b.src) } }) - t.Run("unwritable config dir (AC20)", func(t *testing.T) { + t.Run("unwritable config dir", func(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("read-only directories via chmod are not enforced on windows") } @@ -1060,7 +1060,7 @@ func TestLatexErrorRowAboveSource(t *testing.T) { // AC20, AC27, AC54 }) } -func TestZenStyleLeavesDollarBlockUnrendered(t *testing.T) { // AC81 +func TestZenStyleLeavesDollarBlockUnrendered(t *testing.T) { body := "# Doc\n\nBefore.\n\n$$\nx^2 + y^2\n$$\n\nAfter.\n" for _, engine := range []string{"typst", "latex"} { t.Run(engine, func(t *testing.T) { @@ -1101,7 +1101,7 @@ func TestZenStyleLeavesDollarBlockUnrendered(t *testing.T) { // AC81 } } -// --- Step 7: block detection for \[…\] and the AC7 environments --- +// --- block detection for \[…\] and the display environments --- // blockKinds is splitBlocks' result as "kind[start,end)" entries. func blockKinds(src string, latex bool) []string { @@ -1159,23 +1159,23 @@ func sameLines(t *testing.T, got, want []previewLine) { } } -// latexDisplaySources is one block-level source per AC6/AC7 form. +// latexDisplaySources is one block-level source per detected form. func latexDisplaySources() map[string]string { - srcs := map[string]string{"\\[ \\] (AC6)": "\\[\nx^2 + y^2\n\\]"} + srcs := map[string]string{"\\[ \\]": "\\[\nx^2 + y^2\n\\]"} for _, env := range []string{"equation", "align", "gather", "multline", "alignat", "flalign", "eqnarray"} { for _, name := range []string{env, env + "*"} { begin := "\\begin{" + name + "}" if env == "alignat" { begin += "{2}" } - srcs[name+" (AC7)"] = begin + "\na &= b + c\n\\end{" + name + "}" + srcs[name+""] = begin + "\na &= b + c\n\\end{" + name + "}" } } - srcs["tikzpicture (AC7)"] = "\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}" + srcs["tikzpicture"] = "\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}" return srcs } -func TestSplitBlocksLatexDisplay(t *testing.T) { // AC6, AC7 +func TestSplitBlocksLatexDisplay(t *testing.T) { for name, src := range latexDisplaySources() { t.Run(name, func(t *testing.T) { n := strings.Count(src, "\n") + 1 @@ -1208,7 +1208,7 @@ func TestSplitBlocksLatexDisplay(t *testing.T) { // AC6, AC7 } } -func TestSplitBlocksLatexInsideFenceIsCode(t *testing.T) { // AC8 +func TestSplitBlocksLatexInsideFenceIsCode(t *testing.T) { for name, src := range latexDisplaySources() { t.Run(name, func(t *testing.T) { note := "Before.\n\n```latex\n" + src + "\n```\n\nAfter." @@ -1227,7 +1227,7 @@ func TestSplitBlocksLatexInsideFenceIsCode(t *testing.T) { // AC8 } } -func TestSplitBlocksDollarBlockOwnsEnvironment(t *testing.T) { // AC9 +func TestSplitBlocksDollarBlockOwnsEnvironment(t *testing.T) { for _, env := range []string{"align", "equation*", "tikzpicture"} { t.Run(env, func(t *testing.T) { note := "$$\n\\begin{" + env + "}\na &= b\n\\end{" + env + "}\n$$" @@ -1246,7 +1246,7 @@ func TestSplitBlocksDollarBlockOwnsEnvironment(t *testing.T) { // AC9 } } -func TestSplitBlocksLatexNestedInListQuoteCallout(t *testing.T) { // AC10 +func TestSplitBlocksLatexNestedInListQuoteCallout(t *testing.T) { nest := map[string]func(src string) string{ "list": func(src string) string { return "- item\n " + strings.ReplaceAll(src, "\n", "\n ") @@ -1259,7 +1259,7 @@ func TestSplitBlocksLatexNestedInListQuoteCallout(t *testing.T) { // AC10 }, } srcs := latexDisplaySources() - for _, name := range []string{"\\[ \\] (AC6)", "align* (AC7)", "tikzpicture (AC7)"} { + for _, name := range []string{"\\[ \\]", "align*", "tikzpicture"} { for where, wrap := range nest { t.Run(where+" "+name, func(t *testing.T) { note := "Before.\n\n" + wrap(srcs[name]) + "\n\nAfter." @@ -1279,7 +1279,7 @@ func TestSplitBlocksLatexNestedInListQuoteCallout(t *testing.T) { // AC10 } } -func TestSplitBlocksSingleLineBracket(t *testing.T) { // AC86 +func TestSplitBlocksSingleLineBracket(t *testing.T) { for _, tc := range []struct{ note, want string }{ {"text\n\\[ x^2 \\]\nmore", "para[0,1) tex[1,2) para[2,3)"}, {"text\n\n\\[ x^2 \\]\n\nmore", "para[0,1) blank[1,2) tex[2,3) blank[3,4) para[4,5)"}, @@ -1303,7 +1303,7 @@ func TestSplitBlocksSingleLineBracket(t *testing.T) { // AC86 } } -func TestSplitBlocksTypstLeavesLatexAsText(t *testing.T) { // AC11 +func TestSplitBlocksTypstLeavesLatexAsText(t *testing.T) { // Block kinds as splitBlocks returned them before this change. for _, tc := range []struct{ note, want string }{ {"Before.\n\n\\[\nx^2\n\\]\n\nAfter.", "para[0,1) blank[1,2) para[2,5) blank[5,6) para[6,7)"}, @@ -1337,7 +1337,7 @@ func TestSplitBlocksTypstLeavesLatexAsText(t *testing.T) { // AC11 // Under latex a ```tikz fence renders through pdftex instead, as on the // desktop (TestTikzFenceRendersThroughLatex); it is a fence block either way. -func TestTikzFenceStaysCodeBlockUnderTypst(t *testing.T) { // AC12 +func TestTikzFenceStaysCodeBlockUnderTypst(t *testing.T) { note := "Before.\n\n```tikz\n\\begin{tikzpicture}\n\\draw (0,0) -- (1,1);\n\\end{tikzpicture}\n```\n\nAfter." if got, want := strings.Join(blockKinds(note, true), " "), "para[0,1) blank[1,2) fence[2,7) blank[7,8) para[8,9)"; got != want { t.Fatalf("latex blocks %q, want %q", got, want) @@ -1360,7 +1360,7 @@ func TestTikzFenceStaysCodeBlockUnderTypst(t *testing.T) { // AC12 sameLines(t, lines, beforeChange(t, note)) } -func TestInlineMathUnchangedUnderLatex(t *testing.T) { // AC13 +func TestInlineMathUnchangedUnderLatex(t *testing.T) { note := "Inline $x^2$ and $\\alpha + \\beta$ in a line.\n\nAnd \\(y\\) too.\n" if hasTexBlock(note, true) { t.Fatalf("inline math detected as a tex block: %v", blockKinds(note, true)) diff --git a/internal/tui/latex.go b/internal/tui/latex.go index 7d41596..658ecd9 100644 --- a/internal/tui/latex.go +++ b/internal/tui/latex.go @@ -84,7 +84,7 @@ func ensurePreamble() ([]byte, error) { } // preambleError drops the (temp) file path, so the preview's error row is -// short enough to show the cause (AC20): "preamble.tex: open: permission +// short enough to show the cause: "preamble.tex: open: permission // denied". func preambleError(err error) error { var pe *fs.PathError @@ -169,13 +169,18 @@ func latexDir() (string, error) { return filepath.Join(c, "zn", "tex"), nil } -// texEnv sandboxes pdftex: no reads or writes outside the working dir -// (AC29), and formats found in the cache dir before the TeX defaults. +// texEnv sandboxes pdftex: openout_any=p keeps every write inside the +// working dir, and formats are found in the cache dir before the TeX +// defaults. openin_any=p is asked for too, but TeX Live (2026) still reads +// any file by absolute or ../ path under it, so a note can show a readable +// file in its picture; the README warns about that. Reads stay local: no +// write leaves the work dir and \write18 is off. func texEnv(texDir string) []string { return []string{"openin_any=p", "openout_any=p", "TEXFORMATS=" + texDir + string(os.PathListSeparator)} } -// texSandbox is passed to every pdftex run (AC28). +// texSandbox is passed to every pdftex run: no shell escape, and a TeX +// error ends the run instead of waiting for input. var texSandbox = []string{"-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error"} // texError is the first "! " line of a TeX log, else the output's first line. @@ -201,7 +206,7 @@ func texError(out []byte, logPath string, err error) error { // latexFormats single-flights format builds per App and memoises TeX // failures per format name, so every block shows the same error until the // preamble changes. A timed-out build is not remembered: the next block -// tries again (AC82). +// tries again. type latexFormats struct { mu sync.Mutex builds map[string]*fmtBuild diff --git a/internal/tui/latex_test.go b/internal/tui/latex_test.go index e0c9a14..8ecf087 100644 --- a/internal/tui/latex_test.go +++ b/internal/tui/latex_test.go @@ -22,7 +22,7 @@ import ( "github.com/ZenNotes/tui/internal/config" ) -// --- Step 4: LaTeX pipeline, part 1: preamble, document, format, sandbox --- +// --- LaTeX pipeline, part 1: preamble, document, format, sandbox --- // // The pipeline is driven through the synchronous seam renderEmbed with a // renderReq of kind "latex" whose src is the block's whole source, $$ @@ -308,7 +308,7 @@ func imgErr(img *termImage) error { return img.err } -func TestLatexDocumentIsStandaloneWithBody(t *testing.T) { // AC15 +func TestLatexDocumentIsStandaloneWithBody(t *testing.T) { for _, src := range []string{ "\\[ a + b = c \\]", "\\begin{align}\n a &= b \\\\\n c &= d\n\\end{align}", @@ -336,7 +336,7 @@ func TestLatexDocumentIsStandaloneWithBody(t *testing.T) { // AC15 } } -func TestLatexDollarBlockWrappedAsDisplay(t *testing.T) { // AC73 +func TestLatexDollarBlockWrappedAsDisplay(t *testing.T) { for _, tc := range []struct{ name, src, want string }{ {"one line", "$$ x^2 $$", `\\\[\s*x\^2\s*\\\]`}, {"multi line", "$$\na^2\n+ b^2\n$$", `\\\[\s*a\^2\n\+ b\^2\s*\\\]`}, @@ -356,7 +356,7 @@ func TestLatexDollarBlockWrappedAsDisplay(t *testing.T) { // AC73 } } -func TestLatexThemeColourAfterPreambleNotInFile(t *testing.T) { // AC22 +func TestLatexThemeColourAfterPreambleNotInFile(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a := newLatexApp(t, 0) @@ -383,7 +383,7 @@ func TestLatexThemeColourAfterPreambleNotInFile(t *testing.T) { // AC22 } } -func TestEnsurePreambleWritesDefaultPackages(t *testing.T) { // AC16 +func TestEnsurePreambleWritesDefaultPackages(t *testing.T) { latexEnv(t) fakeTeX(t, texFake{}) if _, err := os.Stat(config.PreamblePath()); !os.IsNotExist(err) { @@ -408,7 +408,7 @@ func TestEnsurePreambleWritesDefaultPackages(t *testing.T) { // AC16 } } -func TestEnsurePreambleKeepsEditedBytes(t *testing.T) { // AC19 +func TestEnsurePreambleKeepsEditedBytes(t *testing.T) { latexEnv(t) fakeTeX(t, texFake{}) mine := []byte("% my preamble\r\n\\usepackage{amsmath}\n\\newcommand{\\R}{\\mathbb{R}}") @@ -455,7 +455,7 @@ func TestEnsurePreambleParallelFirstReadsSeeWholeFile(t *testing.T) { // risk: s } } -func TestEnsurePreambleReportsWriteError(t *testing.T) { // AC20 +func TestEnsurePreambleReportsWriteError(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("read-only directories via chmod are not enforced on windows") } @@ -477,7 +477,7 @@ func TestEnsurePreambleReportsWriteError(t *testing.T) { // AC20 } } -func TestFormatBuiltIntoCacheAndUsed(t *testing.T) { // AC24 +func TestFormatBuiltIntoCacheAndUsed(t *testing.T) { _, texDir := latexEnv(t) calls := fakeTeX(t, texFake{}) renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) @@ -512,7 +512,7 @@ func TestFormatBuiltIntoCacheAndUsed(t *testing.T) { // AC24 } } -func TestFormatNotRebuiltAcrossSessions(t *testing.T) { // AC25 +func TestFormatNotRebuiltAcrossSessions(t *testing.T) { _, texDir := latexEnv(t) calls := fakeTeX(t, texFake{}) first := newLatexApp(t, 0) @@ -535,7 +535,7 @@ func TestFormatNotRebuiltAcrossSessions(t *testing.T) { // AC25 } } -func TestFormatRebuiltWhenPreambleChanges(t *testing.T) { // AC26 +func TestFormatRebuiltWhenPreambleChanges(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a := newLatexApp(t, 0) @@ -605,7 +605,7 @@ func TestFormatBuiltOnceForParallelRenders(t *testing.T) { // risk: single-fligh } } -func TestFormatErrorFailsEveryBlockWithFirstLine(t *testing.T) { // AC27 +func TestFormatErrorFailsEveryBlockWithFirstLine(t *testing.T) { latexEnv(t) if err := os.WriteFile(config.PreamblePath(), []byte("\\usepackage{amsmath}\n\\badmacro\n"), 0o644); err != nil { t.Fatal(err) @@ -632,7 +632,7 @@ func TestFormatErrorFailsEveryBlockWithFirstLine(t *testing.T) { // AC27 } } -func TestFormatBuildTimeoutFailsBlocks(t *testing.T) { // AC33 +func TestFormatBuildTimeoutFailsBlocks(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{build: func(ctx context.Context) ([]byte, error) { <-ctx.Done() @@ -657,7 +657,7 @@ func TestFormatBuildTimeoutFailsBlocks(t *testing.T) { // AC33 } } -func TestPdftexRunsWithNoShellEscapeAndParanoidIO(t *testing.T) { // AC28, AC29 +func TestPdftexRunsWithNoShellEscapeAndParanoidIO(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) renderLatex(newLatexApp(t, 0), "\\[ \\immediate\\write18{touch pwned} x \\]", 10*time.Second) @@ -690,7 +690,7 @@ func TestPdftexRunsWithNoShellEscapeAndParanoidIO(t *testing.T) { // AC28, AC29 } } -// --- Step 5: LaTeX pipeline, part 2: rasterise, errors, timeouts, disk cache --- +// --- LaTeX pipeline, part 2: rasterise, errors, timeouts, disk cache --- func cairoCalls(calls []texCall) []texCall { out := []texCall{} @@ -726,7 +726,7 @@ func blockOnCtx(ctx context.Context) ([]byte, error) { } } -func TestPdftocairoTransparentPNG(t *testing.T) { // AC39 +func TestPdftocairoTransparentPNG(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) img := renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second) @@ -753,7 +753,7 @@ func TestPdftocairoTransparentPNG(t *testing.T) { // AC39 mustPicture(t, img) } -func TestLatexErrorFirstLogLine(t *testing.T) { // AC54 +func TestLatexErrorFirstLogLine(t *testing.T) { latexEnv(t) log := "This is pdfTeX, Version 3.141592653\n" + "! Undefined control sequence.\n" + @@ -775,7 +775,7 @@ func TestLatexErrorFirstLogLine(t *testing.T) { // AC54 } } -func TestLatexRenderTimeoutDefault10s(t *testing.T) { // AC31 +func TestLatexRenderTimeoutDefault10s(t *testing.T) { latexEnv(t) timeout := time.Duration(config.DefaultPrefs().RenderTimeout) * time.Second calls := fakeTeX(t, texFake{render: func(ctx context.Context, _ string) ([]byte, error) { @@ -799,7 +799,7 @@ func TestLatexRenderTimeoutDefault10s(t *testing.T) { // AC31 } } -func TestLatexRenderTimeoutConfigured(t *testing.T) { // AC32 +func TestLatexRenderTimeoutConfigured(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{render: func(ctx context.Context, _ string) ([]byte, error) { return blockOnCtx(ctx) @@ -822,7 +822,7 @@ func TestLatexRenderTimeoutConfigured(t *testing.T) { // AC32 } } -func TestPdftocairoTimeoutFallsBack(t *testing.T) { // AC34 +func TestPdftocairoTimeoutFallsBack(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{cairo: blockOnCtx}) start := time.Now() @@ -843,7 +843,7 @@ func TestPdftocairoTimeoutFallsBack(t *testing.T) { // AC34 } } -func TestLatexPNGWrittenToDiskCache(t *testing.T) { // AC43 +func TestLatexPNGWrittenToDiskCache(t *testing.T) { _, texDir := latexEnv(t) fakeTeX(t, texFake{}) mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) @@ -852,7 +852,7 @@ func TestLatexPNGWrittenToDiskCache(t *testing.T) { // AC43 } } -func TestLatexDiskCacheHitSkipsPdftex(t *testing.T) { // AC44 +func TestLatexDiskCacheHitSkipsPdftex(t *testing.T) { _, texDir := latexEnv(t) fakeTeX(t, texFake{}) mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) @@ -867,7 +867,7 @@ func TestLatexDiskCacheHitSkipsPdftex(t *testing.T) { // AC44 mustPicture(t, img) } -func TestLatexRebuildsAfterCacheDeleted(t *testing.T) { // AC46 +func TestLatexRebuildsAfterCacheDeleted(t *testing.T) { _, texDir := latexEnv(t) fakeTeX(t, texFake{}) mustPicture(t, renderLatex(newLatexApp(t, 0), "\\[ x \\]", 10*time.Second)) @@ -889,7 +889,7 @@ func TestLatexRebuildsAfterCacheDeleted(t *testing.T) { // AC46 } } -// --- Step 5: failure memo — a timeout is retried, a TeX error is reused --- +// --- failure memo: a timeout is retried, a TeX error is reused --- // showLatex is one show of a latex block through the in-memory picture // cache: nil while the background render runs, then its result. @@ -908,7 +908,7 @@ func nextRender(done chan string, d time.Duration) bool { } } -func TestFormatTimeoutIsRetried(t *testing.T) { // AC82 +func TestFormatTimeoutIsRetried(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{build: blockOnCtx}) a := newLatexApp(t, 0) @@ -972,7 +972,7 @@ func timedOutNote(t *testing.T, tool string, fake texFake, runs func([]texCall) return a, done, calls, src } -func TestLatexRenderTimeoutIsRetried(t *testing.T) { // AC83 +func TestLatexRenderTimeoutIsRetried(t *testing.T) { for _, tc := range timeoutCases() { t.Run(tc.tool, func(t *testing.T) { a, done, calls, src := timedOutNote(t, tc.tool, tc.fake, tc.runs, false) @@ -986,7 +986,7 @@ func TestLatexRenderTimeoutIsRetried(t *testing.T) { // AC83 } } -func TestLatexTimeoutForgottenByOpenNoteQuiet(t *testing.T) { // AC83 +func TestLatexTimeoutForgottenByOpenNoteQuiet(t *testing.T) { for _, tc := range timeoutCases() { t.Run(tc.tool, func(t *testing.T) { a, done, calls, src := timedOutNote(t, tc.tool, tc.fake, tc.runs, true) @@ -1000,7 +1000,7 @@ func TestLatexTimeoutForgottenByOpenNoteQuiet(t *testing.T) { // AC83 } } -func TestSingleLineBracketRendersThroughPdftex(t *testing.T) { // AC86 +func TestSingleLineBracketRendersThroughPdftex(t *testing.T) { latexEnv(t) calls := fakeTeX(t, texFake{}) a, done := newNoteApp(t, "latex", "kitty") @@ -1012,7 +1012,7 @@ func TestSingleLineBracketRendersThroughPdftex(t *testing.T) { // AC86 } body := renderInput(t, got) // The body starts after the \begin{document} line, which also carries - // the theme's \color (AC23). + // the theme's \color. begin, end := strings.Index(body, `\begin{document}`), strings.LastIndex(body, `\end{document}`) nl := -1 if begin >= 0 { @@ -1026,7 +1026,7 @@ func TestSingleLineBracketRendersThroughPdftex(t *testing.T) { // AC86 } } -func TestFormatTexErrorIsReused(t *testing.T) { // AC84 +func TestFormatTexErrorIsReused(t *testing.T) { latexEnv(t) log := "This is pdfTeX, Version 3.141592653\n" + "! LaTeX Error: File `nosuch.sty' not found.\n" + @@ -1052,7 +1052,7 @@ func TestFormatTexErrorIsReused(t *testing.T) { // AC84 } } -func TestLatexRenderTexErrorIsReused(t *testing.T) { // AC85 +func TestLatexRenderTexErrorIsReused(t *testing.T) { latexEnv(t) log := "This is pdfTeX, Version 3.141592653\n" + "! Undefined control sequence.\n" + @@ -1080,7 +1080,7 @@ func TestLatexRenderTexErrorIsReused(t *testing.T) { // AC85 } } -// --- Step 8a: crop display PNGs to their ink --- +// --- crop display PNGs to their ink --- // inkPNG is a w×h transparent PNG with each rect filled opaque white. func inkPNG(t *testing.T, w, h int, ink ...image.Rectangle) []byte { @@ -1124,7 +1124,7 @@ func inkBox(img image.Image) image.Rectangle { return box } -func TestCropToInk(t *testing.T) { // AC88 +func TestCropToInk(t *testing.T) { const border = 8 cases := []struct { name string @@ -1195,7 +1195,7 @@ func TestCropToInk(t *testing.T) { // AC88 } } -func TestLatexKeyVersionedForCrop(t *testing.T) { // AC88 +func TestLatexKeyVersionedForCrop(t *testing.T) { _, texDir := latexEnv(t) if _, err := ensurePreamble(); err != nil { t.Fatal(err) diff --git a/internal/tui/latex_tex_test.go b/internal/tui/latex_tex_test.go index 4de58b4..c1e04de 100644 --- a/internal/tui/latex_tex_test.go +++ b/internal/tui/latex_tex_test.go @@ -17,7 +17,7 @@ import ( "github.com/ZenNotes/tui/internal/mcp" ) -// --- Step 8: real pdftex and pdftocairo --- +// --- real pdftex and pdftocairo --- // // These run the real tools found on PATH. Each test skips when either tool // is missing (CI has no TeX) or on windows. runTool is not swapped. @@ -136,7 +136,7 @@ func realMathApp(t *testing.T) (*App, chan string) { return newMathApp(t, p) } -func TestRealDefaultPreambleRendersAmsMhchemTikz(t *testing.T) { // AC16 +func TestRealDefaultPreambleRendersAmsMhchemTikz(t *testing.T) { realTeXEnv(t) a := newLatexApp(t, 0) align := "\\begin{align*}\na &= \\ce{H2O} \\\\\nb &\\leq \\mathbb{R}\n\\end{align*}" @@ -152,7 +152,7 @@ func TestRealDefaultPreambleRendersAmsMhchemTikz(t *testing.T) { // AC16 } } -func TestRealUserPackageInPreamble(t *testing.T) { // AC21 +func TestRealUserPackageInPreamble(t *testing.T) { realTeXEnv(t) src := "\\[ \\bm{x} \\]" if img := renderReal(newLatexApp(t, 0), src, realTimeout); !failed(img) { @@ -170,7 +170,7 @@ func TestRealUserPackageInPreamble(t *testing.T) { // AC21 } } -func TestRealTikzStrokeFollowsTheme(t *testing.T) { // AC23 +func TestRealTikzStrokeFollowsTheme(t *testing.T) { requireRealTeX(t) src := "\\begin{tikzpicture}\n\\draw[line width=4pt] (0,0) rectangle (2,1);\n\\fill (3,0.5) circle (0.5);\n\\end{tikzpicture}" for _, dark := range []bool{true, false} { @@ -193,7 +193,7 @@ func TestRealTikzStrokeFollowsTheme(t *testing.T) { // AC23 } } -func TestRealFormatReusedAcrossApps(t *testing.T) { // AC25 +func TestRealFormatReusedAcrossApps(t *testing.T) { _, texDir := realTeXEnv(t) if img := renderReal(newLatexApp(t, 0), "\\[ x \\]", realTimeout); failed(img) { t.Fatalf("first render: %v", imgErr(img)) @@ -227,7 +227,7 @@ func TestRealFormatReusedAcrossApps(t *testing.T) { // AC25 } } -func TestRealWrite18Blocked(t *testing.T) { // AC28 +func TestRealWrite18Blocked(t *testing.T) { realTeXEnv(t) target := filepath.Join(t.TempDir(), "pwned") src := "\\immediate\\write18{touch " + target + "}\n\\[ x \\]" @@ -238,7 +238,7 @@ func TestRealWrite18Blocked(t *testing.T) { // AC28 mustPicture(t, img) // the render ran, so the check above is not vacuous } -func TestRealSandboxBlocksWritesOutsideWorkDir(t *testing.T) { // AC30 +func TestRealSandboxBlocksWritesOutsideWorkDir(t *testing.T) { _, texDir := realTeXEnv(t) target := filepath.Join(t.TempDir(), "leak.txt") src := "\\newwrite\\znout\n\\immediate\\openout\\znout=" + target + "\n" + @@ -257,8 +257,8 @@ func TestRealSandboxBlocksWritesOutsideWorkDir(t *testing.T) { // AC30 t.Fatalf("render did not fail on the refused write: %v", err) } - // Guard: reading by absolute path is accepted TeX behaviour (rev 7). If - // TeX starts refusing it, revisit the README warning (AC87). + // Guard: reading by absolute path is accepted TeX behaviour. If + // TeX starts refusing it, revisit the README warning. t.Run("absolute_read_still_happens", func(t *testing.T) { in := filepath.Join(t.TempDir(), "box.tex") if err := os.WriteFile(in, []byte("\\rule{4cm}{4cm}\n"), 0o644); err != nil { @@ -276,7 +276,7 @@ func TestRealSandboxBlocksWritesOutsideWorkDir(t *testing.T) { // AC30 }) } -func TestRealLoopKilledByTimeout(t *testing.T) { // AC32 +func TestRealLoopKilledByTimeout(t *testing.T) { realTeXEnv(t) a := newLatexApp(t, 0) // Build the format first, so the 2s budget below is spent on the loop. @@ -293,7 +293,7 @@ func TestRealLoopKilledByTimeout(t *testing.T) { // AC32 } } -func TestRealPNGHasTransparentBackground(t *testing.T) { // AC39 +func TestRealPNGHasTransparentBackground(t *testing.T) { _, texDir := realTeXEnv(t) if img := renderReal(newLatexApp(t, 0), "\\[ x^2 + y^2 \\]", realTimeout); failed(img) { t.Fatalf("render: %v", imgErr(img)) @@ -351,7 +351,7 @@ func TestRealInstructionsTikzExampleRenders(t *testing.T) { } } -func TestRealDisplayPNGCroppedToInk(t *testing.T) { // AC88 +func TestRealDisplayPNGCroppedToInk(t *testing.T) { _, texDir := realTeXEnv(t) const src = "\\[ \\frac{a}{b} \\]" render := func(cols int) *termImage { diff --git a/internal/tui/previewglamour.go b/internal/tui/previewglamour.go index 67df699..9201be3 100644 --- a/internal/tui/previewglamour.go +++ b/internal/tui/previewglamour.go @@ -105,9 +105,9 @@ var ( texEnvRe = regexp.MustCompile(`^ {0,3}\\begin\{((?:equation|align|gather|multline|alignat|flalign|eqnarray)\*?|tikzpicture)\}`) ) -// texBlockEnd returns one past the last line of a block-level \[…\] or AC7 -// environment that starts at line i, or -1 when none starts there or it is -// never closed, so an unclosed opener stays text. +// texBlockEnd returns one past the last line of a block-level \[…\] or +// display environment (texEnvRe) that starts at line i, or -1 when none +// starts there or it is never closed, so an unclosed opener stays text. func texBlockEnd(lines []string, i int) int { if texLineRe.MatchString(lines[i]) { return i + 1 @@ -134,7 +134,7 @@ func texBlockEnd(lines []string, i int) int { } // splitBlocks walks the source lines and groups them into blocks. With -// latex, block-level \[…\] and the AC7 environments become "tex" blocks. +// latex, block-level \[…\] and the display environments become "tex" blocks. func splitBlocks(lines []string, latex bool) []mdBlock { blocks := []mdBlock{} i := 0 diff --git a/internal/tui/theme_render_test.go b/internal/tui/theme_render_test.go index 148831f..a2039df 100644 --- a/internal/tui/theme_render_test.go +++ b/internal/tui/theme_render_test.go @@ -96,7 +96,7 @@ func TestThemePaintPreservesNestedColorsAndHonorsNoColor(t *testing.T) { } } -// AC56–AC58 in the editor itself: a math body is painted with the same +// In the editor itself: a math body is painted with the same // lexer colors as the identical body in a ```tex fence, not as prose. func TestEditorPaintsMathBodyLikeTexFence(t *testing.T) { profile := lipgloss.ColorProfile() diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 32df879..1793b08 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -240,7 +240,7 @@ func TestHumanSize(t *testing.T) { } } -// AC71: the in-app help mentions the latex settings and the preamble file. +// The in-app help mentions the latex settings and the preamble file. func TestHelpMentionsLatexSettings(t *testing.T) { a := &App{} a.keymap = newTestResolver() @@ -252,7 +252,7 @@ func TestHelpMentionsLatexSettings(t *testing.T) { } } -// AC79: the help row keyed `[terminal] math` names the tools latex needs. +// The help row keyed `[terminal] math` names the tools latex needs. func TestHelpMathEntryNamesTools(t *testing.T) { a := &App{} a.keymap = newTestResolver() diff --git a/packaging/homebrew/test_update_formula.py b/packaging/homebrew/test_update_formula.py index 34c528b..444ccfa 100644 --- a/packaging/homebrew/test_update_formula.py +++ b/packaging/homebrew/test_update_formula.py @@ -50,7 +50,7 @@ def test_rejects_formula_without_all_platforms(self): render_formula(formula, "1.2.3", self.release) def test_formula_has_latex_caveats_without_dependencies(self): - # AC69: caveats name the latex tools; no depends_on is added for them. + # The caveats name the latex tools; no depends_on is added for them. caveats = re.search(r"^ def caveats\n(.*?)^ end\n", self.formula, re.M | re.S) self.assertIsNotNone(caveats, "formula has no def caveats") for want in ('math = "latex"', "pdftex", "pdftocairo"):