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/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/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) + } +} 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) + } + } +} 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/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/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/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) { 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) + } + } +} 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()