From 8c9847e321bf277e5f8de8d7b7eca6a17dd86b8e Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:05:16 -0500 Subject: [PATCH 01/15] build: split nix dev shell into `default` and `presets` (#48) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the single dev shell mixed dev tooling (linters, test runner) with preset compiler tools, causing heavy rebuilds (e.g. Chromium for `mermaid-cli`) for contributors who only need the dev tools. Solution: extract dev tooling into a shared `devTools` list and expose two shells — `default` for development and `presets` for running all built-in preset compilers (`typst`, `texliveMedium`, `tectonic`, `pandoc`, `asciidoctor`, `quarto`, `plantuml`, `mermaid-cli`). --- flake.nix | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/flake.nix b/flake.nix index d5bdae4..636f4d0 100644 --- a/flake.nix +++ b/flake.nix @@ -19,9 +19,9 @@ { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - devShells = forEachSystem (pkgs: { - default = pkgs.mkShell { - packages = [ + devShells = forEachSystem (pkgs: + let + devTools = [ (pkgs.luajit.withPackages ( ps: with ps; [ busted @@ -32,10 +32,24 @@ pkgs.stylua pkgs.selene pkgs.lua-language-server - pkgs.plantuml - pkgs.mermaid-cli ]; - }; - }); + in + { + default = pkgs.mkShell { + packages = devTools; + }; + presets = pkgs.mkShell { + packages = devTools ++ [ + pkgs.typst + pkgs.texliveMedium + pkgs.tectonic + pkgs.pandoc + pkgs.asciidoctor + pkgs.quarto + pkgs.plantuml + pkgs.mermaid-cli + ]; + }; + }); }; } From 00caad18bfb2196fd6ed79a95315d0a4a5b5af82 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:05:34 -0500 Subject: [PATCH 02/15] refactor(presets): simplify `mermaid` error parser (#49) * feat: add `mermaid` preset Problem: no built-in support for compiling mermaid diagrams via `mmdc`. Solution: add a `mermaid` preset that compiles `.mmd` files to SVG and parses `Parse error on line N` diagnostics from stderr. Add `mermaid-cli` to the nix dev shell. * refactor(presets): simplify `mermaid` error parser Problem: the inline `mermaid` error_parser looped over every line and used the `Parse error on line N:` header as the message, losing the useful `Expecting ..., got ...` token detail. Solution: extract `parse_mermaid` alongside the other parse functions, use a single `output:match` (mermaid's JISON parser stops at the first error), and surface the `Expecting ..., got ...` line as the message. * ci: format * ci: format --- flake.nix | 6 ++++-- lua/preview/presets.lua | 32 +++++++++++++++++++------------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/flake.nix b/flake.nix index 636f4d0..f6c279b 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,8 @@ { formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); - devShells = forEachSystem (pkgs: + devShells = forEachSystem ( + pkgs: let devTools = [ (pkgs.luajit.withPackages ( @@ -50,6 +51,7 @@ pkgs.mermaid-cli ]; }; - }); + } + ); }; } diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index 1b5333e..d4b03cc 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -115,6 +115,24 @@ local function parse_asciidoctor(output) return diagnostics end +---@param output string +---@return preview.Diagnostic[] +local function parse_mermaid(output) + local lnum = output:match('Parse error on line (%d+)') + if not lnum then + return {} + end + local msg = output:match('(Expecting .+)') or 'parse error' + return { + { + lnum = tonumber(lnum) - 1, + col = 0, + message = msg, + severity = vim.diagnostic.severity.ERROR, + }, + } +end + ---@type preview.ProviderConfig M.typst = { ft = 'typst', @@ -313,19 +331,7 @@ M.mermaid = { return (ctx.file:gsub('%.mmd$', '.svg')) end, error_parser = function(output) - local diagnostics = {} - for line in output:gmatch('[^\r\n]+') do - local lnum = line:match('^%s*Parse error on line (%d+)') - if lnum then - table.insert(diagnostics, { - lnum = tonumber(lnum) - 1, - col = 0, - message = line, - severity = vim.diagnostic.severity.ERROR, - }) - end - end - return diagnostics + return parse_mermaid(output) end, clean = function(ctx) return { 'rm', '-f', (ctx.file:gsub('%.mmd$', '.svg')) } From 1fbc307badcad1454d65694b673cc986c94cf697 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:24:52 -0500 Subject: [PATCH 03/15] Update README to refine project description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80a2650..1c59fd6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # preview.nvim -**Universal document previewer for Neovim** +**Universal previewer for Neovim** An extensible framework for compiling and previewing _any_ documents (LaTeX, Typst, Markdown, etc.)—diagnostics included. From 12cb20d154dd4ccbb5144855ebdba96e97fb68f6 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:26:28 -0500 Subject: [PATCH 04/15] feat: add `extra_args` provider field (#51) * feat: add `extra_args` provider field Problem: Overriding a single flag (e.g. `-outdir=build`) required redefining the entire `args` function, duplicating all preset defaults. Solution: Add `extra_args` field that appends to the resolved `args` after evaluation. Accepts a static table or a context function. * docs: document `extra_args` provider field --- doc/preview.txt | 4 ++++ lua/preview/compiler.lua | 3 +++ lua/preview/init.lua | 2 ++ 3 files changed, 9 insertions(+) diff --git a/doc/preview.txt b/doc/preview.txt index 383a8f5..b4b74f5 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -68,6 +68,10 @@ Provider fields: ~ receives a |preview.Context| and returns a string[]. + {extra_args} (string[]|function) Appended to {args} after evaluation. + Useful for adding flags to a preset + without replacing its defaults. + {cwd} (string|function) Working directory. If a function, receives a |preview.Context|. Default: git root or file directory. diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index a193ad2..3e8de12 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -309,6 +309,9 @@ function M.compile(bufnr, name, provider, ctx, opts) if provider.args then vim.list_extend(cmd, eval_list(provider.args, resolved_ctx)) end + if provider.extra_args then + vim.list_extend(cmd, eval_list(provider.extra_args, resolved_ctx)) + end log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' ')) diff --git a/lua/preview/init.lua b/lua/preview/init.lua index 7cb982b..489679f 100644 --- a/lua/preview/init.lua +++ b/lua/preview/init.lua @@ -2,6 +2,7 @@ ---@field ft? string ---@field cmd string[] ---@field args? string[]|fun(ctx: preview.Context): string[] +---@field extra_args? string[]|fun(ctx: preview.Context): string[] ---@field cwd? string|fun(ctx: preview.Context): string ---@field env? table ---@field output? string|fun(ctx: preview.Context): string @@ -94,6 +95,7 @@ function M.setup(opts) vim.validate(prefix .. '.cmd', provider.cmd, 'table') vim.validate(prefix .. '.cmd[1]', provider.cmd[1], 'string') vim.validate(prefix .. '.args', provider.args, { 'table', 'function' }, true) + vim.validate(prefix .. '.extra_args', provider.extra_args, { 'table', 'function' }, true) vim.validate(prefix .. '.cwd', provider.cwd, { 'string', 'function' }, true) vim.validate(prefix .. '.output', provider.output, { 'string', 'function' }, true) vim.validate(prefix .. '.error_parser', provider.error_parser, 'function', true) From d102c9525bac7f83e56574ffebad301cc48e0598 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:39:26 -0500 Subject: [PATCH 05/15] docs: add SyncTeX section with viewer recipes (#50) * docs: add SyncTeX section with viewer recipes Problem: SyncTeX setup for forward/inverse search was undocumented, forcing users to figure out viewer-specific CLI flags on their own. Solution: Add `preview-synctex` vimdoc section with shared setup and per-viewer recipes for Zathura, Sioyek, and Okular. Add FAQ entry in README pointing to the new section. * build: add `zathura` and `sioyek` to nix dev shell * docs: fix Okular inverse search instructions Problem: Okular settings path was incomplete and didn't mention the trigger keybinding. Solution: Update to full path (Settings -> Configure Okular -> Editor) and note that Shift+click triggers inverse search. --- README.md | 5 +++ doc/preview.txt | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ flake.nix | 2 + 3 files changed, 119 insertions(+) diff --git a/README.md b/README.md index 1c59fd6..6d896ac 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,8 @@ vim.g.preview = { typst = { open = { 'sioyek', '--new-instance' } }, } ``` + +**Q: How do I set up SyncTeX (forward/inverse search)?** + +See `:help preview-synctex` for full recipes covering Zathura, Sioyek, and +Okular. diff --git a/doc/preview.txt b/doc/preview.txt index b4b74f5..6bf5171 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -26,6 +26,7 @@ CONTENTS *preview-contents* 7. Lua API ................................................... |preview-api| 8. Events ............................................... |preview-events| 9. Health ............................................... |preview-health| + 10. SyncTeX ............................................. |preview-synctex| ============================================================================== REQUIREMENTS *preview-requirements* @@ -276,5 +277,116 @@ Checks: ~ - Each configured provider's binary is executable - Each configured provider's opener binary (if any) is executable +============================================================================== +SYNCTEX *preview-synctex* + +SyncTeX enables bidirectional navigation between LaTeX source and the +compiled PDF. The `latex` preset compiles with `-synctex=1` by default. + +Forward search (editor -> viewer) requires caching the output path. +Inverse search (viewer -> editor) requires a fixed Neovim server socket. + +The following configs leverage the below basic setup: ~ + +>lua + vim.fn.serverstart('/tmp/nvim-preview.sock') + + local synctex_pdf = {} + vim.api.nvim_create_autocmd('User', { + pattern = 'PreviewCompileSuccess', + callback = function(args) + synctex_pdf[args.data.bufnr] = args.data.output + end, + }) +< + +The recipes below bind `s` for forward search. To scroll the PDF +automatically on cursor movement, call the forward search function from a +|CursorMoved| or |CursorHold| autocmd instead. + +Viewer-specific recipes: ~ + + *preview-synctex-zathura* +Zathura ~ + +Inverse search: Ctrl+click. + +>lua + vim.keymap.set('n', 's', function() + local pdf = synctex_pdf[vim.api.nvim_get_current_buf()] + if pdf then + vim.fn.jobstart({ + 'zathura', '--synctex-forward', + vim.fn.line('.') .. ':0:' .. vim.fn.expand('%:p'), pdf, + }) + end + end) + + vim.g.preview = { + latex = { + open = { + 'zathura', + '--synctex-editor-command', + 'nvim --server /tmp/nvim-preview.sock' + .. [[ --remote-expr "execute('b +%{line} %{input}')"]], + }, + }, + } +< + + *preview-synctex-sioyek* +Sioyek ~ + +Inverse search: right-click with synctex mode active. + +Add to `~/.config/sioyek/prefs_user.config`: > + inverse_search_command nvim --server /tmp/nvim-preview.sock --remote-expr "execute('b +%2 %1')" +< + +>lua + vim.keymap.set('n', 's', function() + local pdf = synctex_pdf[vim.api.nvim_get_current_buf()] + if pdf then + vim.fn.jobstart({ + 'sioyek', + '--instance-name', 'preview', + '--forward-search-file', vim.fn.expand('%:p'), + '--forward-search-line', tostring(vim.fn.line('.')), + pdf, + }) + end + end) + + vim.g.preview = { + latex = { + open = { 'sioyek', '--instance-name', 'preview' }, + }, + } +< + + *preview-synctex-okular* +Okular ~ + +Inverse search (Shift+click): one-time GUI setup via +Settings -> Configure Okular -> Editor -> Custom Text Editor: > + nvim --server /tmp/nvim-preview.sock --remote-expr "execute('b +%l %f')" +< + +>lua + vim.keymap.set('n', 's', function() + local pdf = synctex_pdf[vim.api.nvim_get_current_buf()] + if pdf then + vim.fn.jobstart({ + 'okular', '--unique', + ('%s#src:%d:%s'):format(pdf, vim.fn.line('.'), vim.fn.expand('%:p')), + }) + end + end) + + vim.g.preview = { + latex = { open = { 'okular', '--unique' } }, + } +< + ============================================================================== vim:tw=78:ts=8:ft=help:norl: diff --git a/flake.nix b/flake.nix index f6c279b..539840f 100644 --- a/flake.nix +++ b/flake.nix @@ -49,6 +49,8 @@ pkgs.quarto pkgs.plantuml pkgs.mermaid-cli + pkgs.zathura + pkgs.sioyek ]; }; } From d1fd2b2a730518cee3906e37b7e1eda8052f867a Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Thu, 5 Mar 2026 23:58:19 -0500 Subject: [PATCH 06/15] refactor(compiler): replace 7 state tables with unified `BufState` (#52) * refactor(compiler): replace 7 state tables with unified `BufState` Problem: `compiler.lua` tracked per-buffer state across 7 separate module-level tables, causing scattered cleanup, triplicated error-handling blocks, accumulating `BufUnload` autocmds on every compile, and a race condition where `active[bufnr]` (cleared asynchronously on process exit) was used as the toggle on/off gate. Solution: Consolidate all per-buffer state into a single `state` table holding a `preview.BufState` record per buffer. Extract `handle_errors` and `clear_errors` helpers. Move `BufUnload` lifecycle entirely into `M.toggle` with `unload_autocmd` tracking to prevent accumulation. Toggle now gates on `s.watching` (synchronous boolean) and reopens a closed viewer when watching is active rather than stopping. * fix(compiler): split nil guard in `M.stop` for type narrowing * ci: typing --- lua/preview/compiler.lua | 501 +++++++++++++++++++-------------------- spec/compiler_spec.lua | 49 ++-- 2 files changed, 269 insertions(+), 281 deletions(-) diff --git a/lua/preview/compiler.lua b/lua/preview/compiler.lua index 3e8de12..5480d57 100644 --- a/lua/preview/compiler.lua +++ b/lua/preview/compiler.lua @@ -3,45 +3,102 @@ local M = {} local diagnostic = require('preview.diagnostic') local log = require('preview.log') ----@type table -local active = {} +---@class preview.BufState +---@field watching boolean +---@field process? table +---@field is_reload? boolean +---@field provider? string +---@field output? string +---@field viewer? table +---@field viewer_open? boolean +---@field open_watcher? uv.uv_fs_event_t +---@field debounce? uv.uv_timer_t +---@field bwp_autocmd? integer +---@field unload_autocmd? integer ----@type table -local watching = {} - ----@type table -local opened = {} - ----@type table -local last_output = {} - ----@type table -local viewer_procs = {} - ----@type table -local open_watchers = {} - -local debounce_timers = {} +---@type table +local state = {} local DEBOUNCE_MS = 500 ---@param bufnr integer -local function stop_open_watcher(bufnr) - local w = open_watchers[bufnr] - if w then - w:stop() - w:close() - open_watchers[bufnr] = nil +---@return preview.BufState +local function get_state(bufnr) + if not state[bufnr] then + state[bufnr] = { watching = false } end + return state[bufnr] +end + +---@param bufnr integer +local function stop_open_watcher(bufnr) + local s = state[bufnr] + if not (s and s.open_watcher) then + return + end + s.open_watcher:stop() + s.open_watcher:close() + s.open_watcher = nil end ---@param bufnr integer local function close_viewer(bufnr) - local obj = viewer_procs[bufnr] - if obj then - local kill = obj.kill - kill(obj, 'sigterm') - viewer_procs[bufnr] = nil + local s = state[bufnr] + if not (s and s.viewer) then + return + end + s.viewer:kill('sigterm') + s.viewer = nil +end + +---@param bufnr integer +---@param name string +---@param provider preview.ProviderConfig +---@param ctx preview.Context +---@param output string +local function handle_errors(bufnr, name, provider, ctx, output) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if not (provider.error_parser and errors_mode) then + return + end + if errors_mode == 'diagnostic' then + diagnostic.set(bufnr, name, provider.error_parser, output, ctx) + elseif errors_mode == 'quickfix' then + local ok, diags = pcall(provider.error_parser, output, ctx) + if ok and diags and #diags > 0 then + local items = {} + for _, d in ipairs(diags) do + table.insert(items, { + bufnr = bufnr, + lnum = d.lnum + 1, + col = d.col + 1, + text = d.message, + type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', + }) + end + vim.fn.setqflist(items, 'r') + local win = vim.fn.win_getid() + vim.cmd.cwindow() + vim.fn.win_gotoid(win) + end + end +end + +---@param bufnr integer +---@param provider preview.ProviderConfig +local function clear_errors(bufnr, provider) + local errors_mode = provider.errors + if errors_mode == nil then + errors_mode = 'diagnostic' + end + if errors_mode == 'diagnostic' then + diagnostic.clear(bufnr) + elseif errors_mode == 'quickfix' then + vim.fn.setqflist({}, 'r') + vim.cmd.cwindow() end end @@ -54,7 +111,23 @@ local function do_open(bufnr, output_file, open_config) elseif type(open_config) == 'table' then local open_cmd = vim.list_extend({}, open_config) table.insert(open_cmd, output_file) - viewer_procs[bufnr] = vim.system(open_cmd) + log.dbg('opening viewer for buffer %d: %s', bufnr, table.concat(open_cmd, ' ')) + local proc + proc = vim.system( + open_cmd, + {}, + vim.schedule_wrap(function() + local s = state[bufnr] + if s and s.viewer == proc then + log.dbg('viewer exited for buffer %d, resetting viewer_open', bufnr) + s.viewer = nil + s.viewer_open = nil + else + log.dbg('viewer exited for buffer %d (stale proc, ignoring)', bufnr) + end + end) + ) + get_state(bufnr).viewer = proc end end @@ -90,10 +163,30 @@ local function resolve_reload_cmd(provider, ctx) return nil end +---@param bufnr integer +---@param s preview.BufState +local function stop_watching(bufnr, s) + s.watching = false + M.stop(bufnr) + stop_open_watcher(bufnr) + close_viewer(bufnr) + s.viewer_open = nil + if s.bwp_autocmd then + vim.api.nvim_del_autocmd(s.bwp_autocmd) + s.bwp_autocmd = nil + end + if s.debounce then + s.debounce:stop() + s.debounce:close() + s.debounce = nil + end +end + ---@param bufnr integer ---@param name string ---@param provider preview.ProviderConfig ---@param ctx preview.Context +---@param opts? {oneshot?: boolean} function M.compile(bufnr, name, provider, ctx, opts) opts = opts or {} @@ -109,7 +202,9 @@ function M.compile(bufnr, name, provider, ctx, opts) vim.cmd('silent! update') end - if active[bufnr] then + local s = get_state(bufnr) + + if s.process then log.dbg('killing existing process for buffer %d before recompile', bufnr) M.stop(bufnr) end @@ -127,7 +222,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end if output_file ~= '' then - last_output[bufnr] = output_file + s.output = output_file end local reload_cmd @@ -155,74 +250,20 @@ function M.compile(bufnr, name, provider, ctx, opts) return end stderr_acc[#stderr_acc + 1] = data - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if provider.error_parser and errors_mode then - local output = table.concat(stderr_acc) - if errors_mode == 'diagnostic' then - diagnostic.set(bufnr, name, provider.error_parser, output, ctx) - elseif errors_mode == 'quickfix' then - local ok, diags = pcall(provider.error_parser, output, ctx) - if ok and diags and #diags > 0 then - local items = {} - for _, d in ipairs(diags) do - table.insert(items, { - bufnr = bufnr, - lnum = d.lnum + 1, - col = d.col + 1, - text = d.message, - type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', - }) - end - vim.fn.setqflist(items, 'r') - local win = vim.fn.win_getid() - vim.cmd.cwindow() - vim.fn.win_gotoid(win) - end - end - end + handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc)) end), }, vim.schedule_wrap(function(result) - if active[bufnr] and active[bufnr].obj == obj then - active[bufnr] = nil + local cs = state[bufnr] + if cs and cs.process == obj then + cs.process = nil end if not vim.api.nvim_buf_is_valid(bufnr) then return end - if result.code ~= 0 then log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code) - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if provider.error_parser and errors_mode then - local output = (result.stdout or '') .. (result.stderr or '') - if errors_mode == 'diagnostic' then - diagnostic.set(bufnr, name, provider.error_parser, output, ctx) - elseif errors_mode == 'quickfix' then - local ok, diagnostics = pcall(provider.error_parser, output, ctx) - if ok and diagnostics and #diagnostics > 0 then - local items = {} - for _, d in ipairs(diagnostics) do - table.insert(items, { - bufnr = bufnr, - lnum = d.lnum + 1, - col = d.col + 1, - text = d.message, - type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', - }) - end - vim.fn.setqflist(items, 'r') - local win = vim.fn.win_getid() - vim.cmd.cwindow() - vim.fn.win_gotoid(win) - end - end - end + handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or '')) vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileFailed', data = { @@ -236,7 +277,7 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - if provider.open and not opts.oneshot and not opened[bufnr] and output_file ~= '' then + if provider.open and not opts.oneshot and not s.viewer_open and output_file ~= '' then local pre_stat = vim.uv.fs_stat(output_file) local pre_mtime = pre_stat and pre_stat.mtime.sec or 0 local out_dir = vim.fn.fnamemodify(output_file, ':h') @@ -244,7 +285,7 @@ function M.compile(bufnr, name, provider, ctx, opts) stop_open_watcher(bufnr) local watcher = vim.uv.new_fs_event() if watcher then - open_watchers[bufnr] = watcher + s.open_watcher = watcher watcher:start( out_dir, {}, @@ -252,8 +293,12 @@ function M.compile(bufnr, name, provider, ctx, opts) if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then return end - if opened[bufnr] then - stop_open_watcher(bufnr) + local cs = state[bufnr] + if not cs then + return + end + if cs.viewer_open then + log.dbg('watcher fired for buffer %d but viewer already open', bufnr) return end if not vim.api.nvim_buf_is_valid(bufnr) then @@ -262,41 +307,27 @@ function M.compile(bufnr, name, provider, ctx, opts) end local new_stat = vim.uv.fs_stat(output_file) if not (new_stat and new_stat.mtime.sec > pre_mtime) then + log.dbg( + 'watcher fired for buffer %d but mtime not newer (%d <= %d)', + bufnr, + new_stat and new_stat.mtime.sec or 0, + pre_mtime + ) return end - stop_open_watcher(bufnr) + log.dbg('watcher opening viewer for buffer %d', bufnr) + cs.viewer_open = true stderr_acc = {} - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if errors_mode == 'diagnostic' then - diagnostic.clear(bufnr) - elseif errors_mode == 'quickfix' then - vim.fn.setqflist({}, 'r') - vim.cmd.cwindow() - end + clear_errors(bufnr, provider) do_open(bufnr, output_file, provider.open) - opened[bufnr] = true end) ) end end - active[bufnr] = { obj = obj, provider = name, output_file = output_file, is_reload = true } - - vim.api.nvim_create_autocmd('BufUnload', { - buffer = bufnr, - once = true, - callback = function() - M.stop(bufnr) - stop_open_watcher(bufnr) - if not provider.detach then - close_viewer(bufnr) - end - last_output[bufnr] = nil - end, - }) + s.process = obj + s.provider = name + s.is_reload = true vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileStarted', @@ -318,31 +349,18 @@ function M.compile(bufnr, name, provider, ctx, opts) local obj obj = vim.system( cmd, - { - cwd = cwd, - env = provider.env, - }, + { cwd = cwd, env = provider.env }, vim.schedule_wrap(function(result) - if active[bufnr] and active[bufnr].obj == obj then - active[bufnr] = nil + local cs = state[bufnr] + if cs and cs.process == obj then + cs.process = nil end if not vim.api.nvim_buf_is_valid(bufnr) then return end - - local errors_mode = provider.errors - if errors_mode == nil then - errors_mode = 'diagnostic' - end - if result.code == 0 then log.dbg('compilation succeeded for buffer %d', bufnr) - if errors_mode == 'diagnostic' then - diagnostic.clear(bufnr) - elseif errors_mode == 'quickfix' then - vim.fn.setqflist({}, 'r') - vim.cmd.cwindow() - end + clear_errors(bufnr, provider) vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileSuccess', data = { bufnr = bufnr, provider = name, output = output_file }, @@ -353,42 +371,21 @@ function M.compile(bufnr, name, provider, ctx, opts) r.inject(output_file) r.broadcast() end + cs = state[bufnr] if provider.open and not opts.oneshot - and not opened[bufnr] + and cs + and not cs.viewer_open and output_file ~= '' and vim.uv.fs_stat(output_file) then + cs.viewer_open = true do_open(bufnr, output_file, provider.open) - opened[bufnr] = true end else log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code) - if provider.error_parser and errors_mode then - local output = (result.stdout or '') .. (result.stderr or '') - if errors_mode == 'diagnostic' then - diagnostic.set(bufnr, name, provider.error_parser, output, ctx) - elseif errors_mode == 'quickfix' then - local ok, diagnostics = pcall(provider.error_parser, output, ctx) - if ok and diagnostics and #diagnostics > 0 then - local items = {} - for _, d in ipairs(diagnostics) do - table.insert(items, { - bufnr = bufnr, - lnum = d.lnum + 1, - col = d.col + 1, - text = d.message, - type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E', - }) - end - vim.fn.setqflist(items, 'r') - local win = vim.fn.win_getid() - vim.cmd.cwindow() - vim.fn.win_gotoid(win) - end - end - end + handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or '')) vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileFailed', data = { @@ -402,19 +399,9 @@ function M.compile(bufnr, name, provider, ctx, opts) end) ) - active[bufnr] = { obj = obj, provider = name, output_file = output_file } - - vim.api.nvim_create_autocmd('BufUnload', { - buffer = bufnr, - once = true, - callback = function() - M.stop(bufnr) - if not provider.detach then - close_viewer(bufnr) - end - last_output[bufnr] = nil - end, - }) + s.process = obj + s.provider = name + s.is_reload = false vim.api.nvim_exec_autocmds('User', { pattern = 'PreviewCompileStarted', @@ -424,39 +411,37 @@ end ---@param bufnr integer function M.stop(bufnr) - local proc = active[bufnr] - if not proc then + local s = state[bufnr] + if not s then + return + end + local obj = s.process + if not obj then return end log.dbg('stopping process for buffer %d', bufnr) - ---@type fun(self: table, signal: string|integer) - local kill = proc.obj.kill - kill(proc.obj, 'sigterm') + obj:kill('sigterm') local timer = vim.uv.new_timer() if timer then timer:start(5000, 0, function() timer:close() - if active[bufnr] and active[bufnr].obj == proc.obj then - kill(proc.obj, 'sigkill') - active[bufnr] = nil + local cs = state[bufnr] + if cs and cs.process == obj then + obj:kill('sigkill') + cs.process = nil end end) end end function M.stop_all() - for bufnr, _ in pairs(active) do - M.stop(bufnr) - end - for bufnr, _ in pairs(watching) do - M.unwatch(bufnr) - end - for bufnr, _ in pairs(open_watchers) do - stop_open_watcher(bufnr) - end - for bufnr, _ in pairs(viewer_procs) do - close_viewer(bufnr) + for bufnr, s in pairs(state) do + stop_watching(bufnr, s) + if s.unload_autocmd then + vim.api.nvim_del_autocmd(s.unload_autocmd) + end + state[bufnr] = nil end require('preview.reload').stop() end @@ -467,76 +452,77 @@ end ---@param ctx_builder fun(bufnr: integer): preview.Context function M.toggle(bufnr, name, provider, ctx_builder) local is_longrunning = type(provider.reload) == 'table' or type(provider.reload) == 'function' + local s = get_state(bufnr) - if is_longrunning then - if active[bufnr] then - M.stop(bufnr) - vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) + if s.watching then + local output = s.output + if not s.viewer_open and provider.open and output and vim.uv.fs_stat(output) then + log.dbg('toggle reopen viewer for buffer %d', bufnr) + s.viewer_open = true + do_open(bufnr, output, provider.open) else - M.compile(bufnr, name, provider, ctx_builder(bufnr)) - vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) + log.dbg('toggle off for buffer %d', bufnr) + stop_watching(bufnr, s) + vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) end return end - if watching[bufnr] then - M.unwatch(bufnr) - vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO) - return + log.dbg('toggle on for buffer %d', bufnr) + s.watching = true + + if s.unload_autocmd then + vim.api.nvim_del_autocmd(s.unload_autocmd) end - - local au_id = vim.api.nvim_create_autocmd('BufWritePost', { - buffer = bufnr, - callback = function() - if debounce_timers[bufnr] then - debounce_timers[bufnr]:stop() - else - debounce_timers[bufnr] = vim.uv.new_timer() - end - debounce_timers[bufnr]:start( - DEBOUNCE_MS, - 0, - vim.schedule_wrap(function() - local ctx = ctx_builder(bufnr) - M.compile(bufnr, name, provider, ctx) - end) - ) - end, - }) - - watching[bufnr] = au_id - log.dbg('watching buffer %d with provider "%s"', bufnr, name) - vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) - - vim.api.nvim_create_autocmd('BufUnload', { + s.unload_autocmd = vim.api.nvim_create_autocmd('BufUnload', { buffer = bufnr, once = true, callback = function() - M.unwatch(bufnr) + M.stop(bufnr) stop_open_watcher(bufnr) if not provider.detach then close_viewer(bufnr) end - opened[bufnr] = nil + state[bufnr] = nil end, }) + if not is_longrunning then + s.bwp_autocmd = vim.api.nvim_create_autocmd('BufWritePost', { + buffer = bufnr, + callback = function() + local ds = state[bufnr] + if not ds then + return + end + if ds.debounce then + ds.debounce:stop() + else + ds.debounce = vim.uv.new_timer() + end + ds.debounce:start( + DEBOUNCE_MS, + 0, + vim.schedule_wrap(function() + M.compile(bufnr, name, provider, ctx_builder(bufnr)) + end) + ) + end, + }) + log.dbg('watching buffer %d with provider "%s"', bufnr, name) + end + + vim.notify('[preview.nvim]: watching with "' .. name .. '"', vim.log.levels.INFO) M.compile(bufnr, name, provider, ctx_builder(bufnr)) end ---@param bufnr integer function M.unwatch(bufnr) - local au_id = watching[bufnr] - if not au_id then + local s = state[bufnr] + if not s then return end - vim.api.nvim_del_autocmd(au_id) - if debounce_timers[bufnr] then - debounce_timers[bufnr]:stop() - debounce_timers[bufnr]:close() - debounce_timers[bufnr] = nil - end - watching[bufnr] = nil + stop_watching(bufnr, s) log.dbg('unwatched buffer %d', bufnr) end @@ -585,7 +571,8 @@ end ---@param bufnr integer ---@return boolean function M.open(bufnr, open_config) - local output = last_output[bufnr] + local s = state[bufnr] + local output = s and s.output if not output then log.dbg('no last output file for buffer %d', bufnr) return false @@ -601,26 +588,20 @@ end ---@param bufnr integer ---@return preview.Status function M.status(bufnr) - local proc = active[bufnr] - if proc then - return { - compiling = not proc.is_reload, - watching = watching[bufnr] ~= nil or proc.is_reload == true, - provider = proc.provider, - output_file = proc.output_file, - } + local s = state[bufnr] + if not s then + return { compiling = false, watching = false } end - return { compiling = false, watching = watching[bufnr] ~= nil } + return { + compiling = s.process ~= nil and not s.is_reload, + watching = s.watching, + provider = s.provider, + output_file = s.output, + } end M._test = { - active = active, - watching = watching, - opened = opened, - last_output = last_output, - debounce_timers = debounce_timers, - viewer_procs = viewer_procs, - open_watchers = open_watchers, + state = state, } return M diff --git a/spec/compiler_spec.lua b/spec/compiler_spec.lua index cd1dd9f..5b12cfb 100644 --- a/spec/compiler_spec.lua +++ b/spec/compiler_spec.lua @@ -8,8 +8,13 @@ describe('compiler', function() compiler = require('preview.compiler') end) + local function process_done(bufnr) + local s = compiler._test.state[bufnr] + return not s or s.process == nil + end + describe('compile', function() - it('spawns a process and tracks it in active table', function() + it('spawns a process and tracks it in state', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test.txt') vim.bo[bufnr].modified = false @@ -23,15 +28,16 @@ describe('compiler', function() } compiler.compile(bufnr, 'echo', provider, ctx) - local active = compiler._test.active - assert.is_not_nil(active[bufnr]) - assert.are.equal('echo', active[bufnr].provider) + local s = compiler._test.state[bufnr] + assert.is_not_nil(s) + assert.is_not_nil(s.process) + assert.are.equal('echo', s.provider) vim.wait(2000, function() - return active[bufnr] == nil + return process_done(bufnr) end, 50) - assert.is_nil(active[bufnr]) + assert.is_nil(compiler._test.state[bufnr].process) helpers.delete_buffer(bufnr) end) @@ -61,7 +67,7 @@ describe('compiler', function() assert.is_true(fired) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) helpers.delete_buffer(bufnr) @@ -124,7 +130,7 @@ describe('compiler', function() vim.notify = orig assert.is_true(notified) - assert.is_nil(compiler._test.active[bufnr]) + assert.is_true(process_done(bufnr)) helpers.delete_buffer(bufnr) end) @@ -186,7 +192,7 @@ describe('compiler', function() compiler.compile(bufnr, 'falsecmd', provider, ctx) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) assert.is_false(parser_called) @@ -218,7 +224,7 @@ describe('compiler', function() compiler.compile(bufnr, 'qfcmd', provider, ctx) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) local qflist = vim.fn.getqflist() @@ -255,7 +261,7 @@ describe('compiler', function() compiler.compile(bufnr, 'truecmd', provider, ctx) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) assert.are.equal(0, #vim.fn.getqflist()) @@ -298,7 +304,7 @@ describe('compiler', function() compiler.stop(bufnr) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) helpers.delete_buffer(bufnr) @@ -329,11 +335,12 @@ describe('compiler', function() } compiler.compile(bufnr, 'testprov', provider, ctx) - assert.is_not_nil(compiler._test.last_output[bufnr]) - assert.are.equal('/tmp/preview_test_open.pdf', compiler._test.last_output[bufnr]) + local s = compiler._test.state[bufnr] + assert.is_not_nil(s) + assert.are.equal('/tmp/preview_test_open.pdf', s.output) vim.wait(2000, function() - return compiler._test.active[bufnr] == nil + return process_done(bufnr) end, 50) helpers.delete_buffer(bufnr) @@ -341,7 +348,7 @@ describe('compiler', function() end) describe('toggle', function() - it('registers autocmd and tracks in watching table', function() + it('starts watching and sets watching flag', function() local bufnr = helpers.create_buffer({ 'hello' }, 'text') vim.api.nvim_buf_set_name(bufnr, '/tmp/preview_test_watch.txt') @@ -351,7 +358,7 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_not_nil(compiler._test.watching[bufnr]) + assert.is_true(compiler.status(bufnr).watching) helpers.delete_buffer(bufnr) end) @@ -366,10 +373,10 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_not_nil(compiler._test.watching[bufnr]) + assert.is_true(compiler.status(bufnr).watching) compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_nil(compiler._test.watching[bufnr]) + assert.is_false(compiler.status(bufnr).watching) helpers.delete_buffer(bufnr) end) @@ -389,10 +396,10 @@ describe('compiler', function() end compiler.toggle(bufnr, 'echo', provider, ctx_builder) - assert.is_not_nil(compiler._test.watching[bufnr]) + assert.is_true(compiler.status(bufnr).watching) compiler.stop_all() - assert.is_nil(compiler._test.watching[bufnr]) + assert.is_false(compiler.status(bufnr).watching) helpers.delete_buffer(bufnr) end) From 872a8edd714127bc38cd2ec04b5752fa48fa8266 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 13:23:31 -0500 Subject: [PATCH 07/15] fix(presets): add `--mathml` to `markdown` and `github` pandoc args Problem: pandoc's default HTML math renderer cannot handle most TeX and dumps raw LaTeX source into the output. `--mathjax` and `--katex` are incompatible with `--embed-resources` because pandoc cannot inline dynamically-loaded JavaScript modules and fonts. Solution: add `--mathml` to both `markdown` and `github` preset args. MathML is rendered natively by all modern browsers with no external dependencies, making it the only math option compatible with self-contained HTML output. --- lua/preview/presets.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lua/preview/presets.lua b/lua/preview/presets.lua index d4b03cc..1f333bf 100644 --- a/lua/preview/presets.lua +++ b/lua/preview/presets.lua @@ -224,7 +224,7 @@ M.markdown = { ft = 'markdown', cmd = { 'pandoc' }, args = function(ctx) - return { ctx.file, '-s', '--embed-resources', '-o', ctx.output } + return { ctx.file, '-s', '--embed-resources', '--mathml', '-o', ctx.output } end, output = function(ctx) return (ctx.file:gsub('%.md$', '.html')) @@ -250,6 +250,7 @@ M.github = { ctx.file, '-s', '--embed-resources', + '--mathml', '--css', 'https://cdn.jsdelivr.net/gh/pixelbrackets/gfm-stylesheet@master/dist/gfm.css', '-o', From c9d3269689e8204c635eeb40dc93775cb354d145 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 13:30:17 -0500 Subject: [PATCH 08/15] docs(presets): add math rendering section with KaTeX recipe Problem: the `markdown` and `github` presets now default to `--mathml` but users may want KaTeX or MathJax rendering instead, and the incompatibility with `--embed-resources` is non-obvious. Solution: add a `preview-math` section to the presets docs explaining the default, why `--katex`/`--mathjax` require dropping `--embed-resources`, and a concrete recipe for KaTeX with `github`. --- doc/preview.txt | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/doc/preview.txt b/doc/preview.txt index 6bf5171..3912a41 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -22,6 +22,7 @@ CONTENTS *preview-contents* 3. Install ............................................... |preview-install| 4. Configuration ........................................... |preview-config| 5. Presets ............................................... |preview-presets| + - Math rendering ....................................... |preview-math| 6. Commands ............................................. |preview-commands| 7. Lua API ................................................... |preview-api| 8. Events ............................................... |preview-events| @@ -189,6 +190,33 @@ override individual fields by passing a table instead: >lua `mermaid` mmdc → SVG (Mermaid diagrams, `.mmd`) `quarto` quarto render → HTML (scientific publishing) +Math rendering (pandoc presets): ~ + *preview-math* + +The `markdown` and `github` presets use `--mathml` by default, which converts +TeX math to native MathML markup rendered by the browser. This is the only +math option compatible with `--embed-resources` (self-contained HTML). + +`--mathjax` and `--katex` insert ` + + + + +

Math Rendering Test

+

Inline Math

+

The quadratic formula is x = \frac{-b \pm +\sqrt{b^2 - 4ac}}{2a} and Euler's identity is e^{i\pi} + 1 = 0.

+

Display Math

+

\int_{-\infty}^{\infty} e^{-x^2} \, dx = +\sqrt{\pi}

+

\sum_{n=0}^{\infty} \frac{x^n}{n!} = +e^x

+

Matrices

+

\begin{pmatrix} a & b \\ c & d +\end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax ++ by \\ cx + dy \end{pmatrix}

+

Aligned Equations

+

\begin{aligned} +\nabla \cdot \mathbf{E} &= \frac{\rho}{\varepsilon_0} \\ +\nabla \cdot \mathbf{B} &= 0 \\ +\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} +\\ +\nabla \times \mathbf{B} &= \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 +\frac{\partial \mathbf{E}}{\partial t} +\end{aligned}

+

Fractions and Nested +Expressions

+

\cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + +\cfrac{1}{1 + \cdots}}}}

+

\binom{n}{k} = +\frac{n!}{k!(n-k)!}

+

Limits and Calculus

+

\lim_{n \to \infty} \left(1 + +\frac{1}{n}\right)^n = e

+

\oint_{\partial \Sigma} \mathbf{B} \cdot +d\boldsymbol{\ell} = \mu_0 \iint_{\Sigma} \mathbf{J} \cdot d\mathbf{S} + +\mu_0 \varepsilon_0 \frac{d}{dt} \iint_{\Sigma} \mathbf{E} \cdot +d\mathbf{S}

+

Greek and Symbols

+

\Gamma(z) = \int_0^{\infty} t^{z-1} e^{-t} +\, dt, \quad \Re(z) > 0

+

\mathcal{L}\{f(t)\} = \int_0^{\infty} f(t) +e^{-st} \, dt

+

Cases

+

|x| = \begin{cases} x & \text{if } x +\geq 0 \\ -x & \text{if } x < 0 \end{cases}

+ + + diff --git a/test_math.md b/test_math.md new file mode 100644 index 0000000..b89bf87 --- /dev/null +++ b/test_math.md @@ -0,0 +1,46 @@ +# Math Rendering Test + +## Inline Math + +The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ and Euler's identity is $e^{i\pi} + 1 = 0$. + +## Display Math + +$$\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}$$ + +$$\sum_{n=0}^{\infty} \frac{x^n}{n!} = e^x$$ + +## Matrices + +$$\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax + by \\ cx + dy \end{pmatrix}$$ + +## Aligned Equations + +$$\begin{aligned} +\nabla \cdot \mathbf{E} &= \frac{\rho}{\varepsilon_0} \\ +\nabla \cdot \mathbf{B} &= 0 \\ +\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} \\ +\nabla \times \mathbf{B} &= \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t} +\end{aligned}$$ + +## Fractions and Nested Expressions + +$$\cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + \cdots}}}}$$ + +$$\binom{n}{k} = \frac{n!}{k!(n-k)!}$$ + +## Limits and Calculus + +$$\lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n = e$$ + +$$\oint_{\partial \Sigma} \mathbf{B} \cdot d\boldsymbol{\ell} = \mu_0 \iint_{\Sigma} \mathbf{J} \cdot d\mathbf{S} + \mu_0 \varepsilon_0 \frac{d}{dt} \iint_{\Sigma} \mathbf{E} \cdot d\mathbf{S}$$ + +## Greek and Symbols + +$$\Gamma(z) = \int_0^{\infty} t^{z-1} e^{-t} \, dt, \quad \Re(z) > 0$$ + +$$\mathcal{L}\{f(t)\} = \int_0^{\infty} f(t) e^{-st} \, dt$$ + +## Cases + +$$|x| = \begin{cases} x & \text{if } x \geq 0 \\ -x & \text{if } x < 0 \end{cases}$$ From f5651cc3fc9aa71e655d42aeae176a674a0d1ae2 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 13:48:04 -0500 Subject: [PATCH 11/15] chore: remove debug files --- test_math.html | 91 -------------------------------------------------- test_math.md | 46 ------------------------- 2 files changed, 137 deletions(-) delete mode 100644 test_math.html delete mode 100644 test_math.md diff --git a/test_math.html b/test_math.html deleted file mode 100644 index f21d65a..0000000 --- a/test_math.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - - test_math - - - - - - - -

Math Rendering Test

-

Inline Math

-

The quadratic formula is x = \frac{-b \pm -\sqrt{b^2 - 4ac}}{2a} and Euler's identity is e^{i\pi} + 1 = 0.

-

Display Math

-

\int_{-\infty}^{\infty} e^{-x^2} \, dx = -\sqrt{\pi}

-

\sum_{n=0}^{\infty} \frac{x^n}{n!} = -e^x

-

Matrices

-

\begin{pmatrix} a & b \\ c & d -\end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax -+ by \\ cx + dy \end{pmatrix}

-

Aligned Equations

-

\begin{aligned} -\nabla \cdot \mathbf{E} &= \frac{\rho}{\varepsilon_0} \\ -\nabla \cdot \mathbf{B} &= 0 \\ -\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} -\\ -\nabla \times \mathbf{B} &= \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 -\frac{\partial \mathbf{E}}{\partial t} -\end{aligned}

-

Fractions and Nested -Expressions

-

\cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + -\cfrac{1}{1 + \cdots}}}}

-

\binom{n}{k} = -\frac{n!}{k!(n-k)!}

-

Limits and Calculus

-

\lim_{n \to \infty} \left(1 + -\frac{1}{n}\right)^n = e

-

\oint_{\partial \Sigma} \mathbf{B} \cdot -d\boldsymbol{\ell} = \mu_0 \iint_{\Sigma} \mathbf{J} \cdot d\mathbf{S} + -\mu_0 \varepsilon_0 \frac{d}{dt} \iint_{\Sigma} \mathbf{E} \cdot -d\mathbf{S}

-

Greek and Symbols

-

\Gamma(z) = \int_0^{\infty} t^{z-1} e^{-t} -\, dt, \quad \Re(z) > 0

-

\mathcal{L}\{f(t)\} = \int_0^{\infty} f(t) -e^{-st} \, dt

-

Cases

-

|x| = \begin{cases} x & \text{if } x -\geq 0 \\ -x & \text{if } x < 0 \end{cases}

- - - diff --git a/test_math.md b/test_math.md deleted file mode 100644 index b89bf87..0000000 --- a/test_math.md +++ /dev/null @@ -1,46 +0,0 @@ -# Math Rendering Test - -## Inline Math - -The quadratic formula is $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ and Euler's identity is $e^{i\pi} + 1 = 0$. - -## Display Math - -$$\int_{-\infty}^{\infty} e^{-x^2} \, dx = \sqrt{\pi}$$ - -$$\sum_{n=0}^{\infty} \frac{x^n}{n!} = e^x$$ - -## Matrices - -$$\begin{pmatrix} a & b \\ c & d \end{pmatrix} \begin{pmatrix} x \\ y \end{pmatrix} = \begin{pmatrix} ax + by \\ cx + dy \end{pmatrix}$$ - -## Aligned Equations - -$$\begin{aligned} -\nabla \cdot \mathbf{E} &= \frac{\rho}{\varepsilon_0} \\ -\nabla \cdot \mathbf{B} &= 0 \\ -\nabla \times \mathbf{E} &= -\frac{\partial \mathbf{B}}{\partial t} \\ -\nabla \times \mathbf{B} &= \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t} -\end{aligned}$$ - -## Fractions and Nested Expressions - -$$\cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + \cfrac{1}{1 + \cdots}}}}$$ - -$$\binom{n}{k} = \frac{n!}{k!(n-k)!}$$ - -## Limits and Calculus - -$$\lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n = e$$ - -$$\oint_{\partial \Sigma} \mathbf{B} \cdot d\boldsymbol{\ell} = \mu_0 \iint_{\Sigma} \mathbf{J} \cdot d\mathbf{S} + \mu_0 \varepsilon_0 \frac{d}{dt} \iint_{\Sigma} \mathbf{E} \cdot d\mathbf{S}$$ - -## Greek and Symbols - -$$\Gamma(z) = \int_0^{\infty} t^{z-1} e^{-t} \, dt, \quad \Re(z) > 0$$ - -$$\mathcal{L}\{f(t)\} = \int_0^{\infty} f(t) e^{-st} \, dt$$ - -## Cases - -$$|x| = \begin{cases} x & \text{if } x \geq 0 \\ -x & \text{if } x < 0 \end{cases}$$ From 047e169c2148472a4ca7a219b154227bbef3f664 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Fri, 6 Mar 2026 14:08:22 -0500 Subject: [PATCH 12/15] docs(presets): improve math rendering section with KaTeX recipes Problem: the math docs only showed a full `args` override for KaTeX, without explaining the tradeoffs or offering a simpler alternative. Solution: add an `extra_args` one-liner recipe (simple but slow due to `--embed-resources` inlining fonts), keep the `args` override as the fast option, and explain why `--mathjax` fails entirely. --- doc/preview.txt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/doc/preview.txt b/doc/preview.txt index ed3c13e..fb133d8 100644 --- a/doc/preview.txt +++ b/doc/preview.txt @@ -197,13 +197,19 @@ The `markdown` and `github` presets use `--mathml` by default, which converts TeX math to native MathML markup rendered by the browser. This is the only math option compatible with `--embed-resources` (self-contained HTML). -`--mathjax` and `--katex` insert `