From 26cf1698ecc0249225b4714fa73d49afdcac6d4b Mon Sep 17 00:00:00 2001 From: davidsanchez222 Date: Wed, 25 Mar 2026 23:20:56 -0400 Subject: [PATCH] feat: add Typst PDF export command --- README.md | 11 +++ lua/typst-preview/commands.lua | 150 ++++++++++++++++++++++++++++++--- lua/typst-preview/config.lua | 8 +- lua/typst-preview/init.lua | 1 + 4 files changed, 155 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e2b9861..0312770 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ plugin, i.e., `v1.1.*` instead of `v1.*`. - Scroll preview to the current cursor position. This can be used in combination with `:TypstPreviewNoFollowCursor` so that the preview only scroll to the current cursor position when you want it to. +- `:TypstPreviewExport [output]` or `require 'typst-preview'.export_pdf()`: + - Export the current typst file to PDF using `typst compile`. + - If `output` is omitted, it exports next to the main file with a `.pdf` extension. ## ⚙️ Configuration @@ -117,6 +120,10 @@ require 'typst-preview'.setup { -- Whether the preview will follow the cursor in the source file follow_cursor = true, + -- Typst binary used for PDF export + -- Example: typst_bin = '/usr/local/bin/typst' + typst_bin = 'typst', + -- Provide the path to binaries for dependencies. -- Setting this will skip the download of the binary by the plugin. -- Warning: Be aware that your version might be older than the one @@ -130,6 +137,10 @@ require 'typst-preview'.setup { -- For example, extra_args = { "--input=ver=draft", "--ignore-system-fonts" } extra_args = nil, + -- Extra arguments (or nil) to be passed to `typst compile` for export. + -- For example, export_args = { "--input=ver=draft" } + export_args = nil, + -- This function will be called to determine the root of the typst project get_root = function(path_of_main_file) local root = os.getenv 'TYPST_ROOT' diff --git a/lua/typst-preview/commands.lua b/lua/typst-preview/commands.lua index 75afd6d..1320e46 100644 --- a/lua/typst-preview/commands.lua +++ b/lua/typst-preview/commands.lua @@ -6,6 +6,57 @@ local servers = require 'typst-preview.servers' local M = {} +local function get_path(action) + local path = utils.get_buf_path(0) + if path == '' then + local message = action or 'preview' + utils.notify( + 'Can not ' .. message .. ' an unsaved buffer.', + vim.log.levels.ERROR + ) + return nil + end + + return config.opts.get_main_file(path) +end + +local function normalize_export_args(path, output) + local args = { + 'compile', + '--root', + config.opts.get_root(path), + } + + if config.opts.export_args ~= nil then + local extra = config.opts.export_args + if type(extra) == 'function' then + local ok, res = pcall(extra, path, output) + if ok and res ~= nil then + if type(res) == 'table' then + for _, v in ipairs(res) do + table.insert(args, v) + end + elseif type(res) == 'string' then + table.insert(args, res) + end + end + elseif type(extra) == 'table' then + for _, v in ipairs(extra) do + table.insert(args, v) + end + else + error 'config.opts.export_args must be a table or function' + end + end + + table.insert(args, path) + if output ~= nil and output ~= '' then + table.insert(args, output) + end + + return args +end + ---Scroll all preview to cursor position. function M.sync_with_cursor() for _, ser in pairs(servers.get_all()) do @@ -13,6 +64,79 @@ function M.sync_with_cursor() end end +---Export the current typst file to PDF using typst compile. +---@param output string|nil +function M.export_pdf(output) + local path = get_path 'export' + if path == nil then + return + end + + if output == nil or output == '' then + output = vim.fn.fnamemodify(path, ':r') .. '.pdf' + end + + local typst_bin = config.opts.typst_bin or 'typst' + if vim.fn.executable(typst_bin) == 0 then + utils.notify( + 'typst binary not found. Set typst_bin in setup or install typst.', + vim.log.levels.ERROR + ) + return + end + + local args = normalize_export_args(path, output) + local stdout = assert(vim.uv.new_pipe()) + local stderr = assert(vim.uv.new_pipe()) + local out_chunks = {} + local err_chunks = {} + + local handle, _ = vim.uv.spawn(typst_bin, { + args = args, + stdio = { nil, stdout, stderr }, + }, function(code) + stdout:close() + stderr:close() + if handle then + handle:close() + end + + local err_msg = table.concat(err_chunks, '') + if code == 0 then + utils.notify('Exported PDF to ' .. output, vim.log.levels.INFO) + else + if err_msg == '' then + err_msg = table.concat(out_chunks, '') + end + utils.notify( + 'typst compile failed (exit ' .. tostring(code) .. '): ' .. err_msg, + vim.log.levels.ERROR + ) + end + end) + + if not handle then + utils.notify('Failed to spawn typst process.', vim.log.levels.ERROR) + return + end + + stdout:read_start(function(err, data) + if err then + utils.debug('typst stdout error: ' .. err) + elseif data then + table.insert(out_chunks, data) + end + end) + + stderr:read_start(function(err, data) + if err then + utils.debug('typst stderr error: ' .. err) + elseif data then + table.insert(err_chunks, data) + end + end) +end + ---Create user commands function M.create_commands() local function preview_off() @@ -25,16 +149,6 @@ function M.create_commands() end end - local function get_path() - local path = utils.get_buf_path(0) - if path == '' then - utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) - return nil - else - return config.opts.get_main_file(path) - end - end - ---@param mode mode? local function preview_on(mode) -- check if binaries are available and tell them to fetch first @@ -51,7 +165,7 @@ function M.create_commands() end end - local path = get_path() + local path = get_path 'preview' if path == nil then return end @@ -89,7 +203,7 @@ function M.create_commands() end else assert(#opts.fargs == 0) - local path = get_path() + local path = get_path 'preview' if path == nil then return end @@ -108,7 +222,7 @@ function M.create_commands() }) vim.api.nvim_create_user_command('TypstPreviewStop', preview_off, {}) vim.api.nvim_create_user_command('TypstPreviewToggle', function() - local path = get_path() + local path = get_path 'preview' if path == nil then return end @@ -132,6 +246,16 @@ function M.create_commands() vim.api.nvim_create_user_command('TypstPreviewSyncCursor', function() M.sync_with_cursor() end, {}) + + vim.api.nvim_create_user_command('TypstPreviewExport', function(opts) + local output + if #opts.fargs == 1 then + output = opts.fargs[1] + end + M.export_pdf(output) + end, { + nargs = '?', + }) end return M diff --git a/lua/typst-preview/config.lua b/lua/typst-preview/config.lua index d9ddf43..21f6985 100644 --- a/lua/typst-preview/config.lua +++ b/lua/typst-preview/config.lua @@ -8,11 +8,13 @@ local M = { host = '127.0.0.1', invert_colors = 'never', follow_cursor = true, + typst_bin = 'typst', -- let user directly point to binary in case of different PATH dependencies_bin = { ['tinymist'] = nil, ['websocat'] = nil, }, extra_args = nil, + export_args = nil, get_root = function(path_of_main_file) local env_root = os.getenv 'TYPST_ROOT' if env_root then @@ -20,8 +22,10 @@ local M = { end -- Use project markers to pick a root that still allows parent imports - local main_dir = vim.fs.dirname(vim.fn.fnamemodify(path_of_main_file, ':p')) - local found = vim.fs.find(root_markers, { path = main_dir, upward = true }) + local main_dir = + vim.fs.dirname(vim.fn.fnamemodify(path_of_main_file, ':p')) + local found = + vim.fs.find(root_markers, { path = main_dir, upward = true }) if #found > 0 then return vim.fs.dirname(found[1]) end diff --git a/lua/typst-preview/init.lua b/lua/typst-preview/init.lua index facef16..df46681 100644 --- a/lua/typst-preview/init.lua +++ b/lua/typst-preview/init.lua @@ -10,6 +10,7 @@ local M = { set_follow_cursor = config.set_follow_cursor, get_follow_cursor = config.get_follow_cursor, sync_with_cursor = commands.sync_with_cursor, + export_pdf = commands.export_pdf, update = function() fetch.fetch(false) end,