diff --git a/lua/snacks/image/convert.lua b/lua/snacks/image/convert.lua new file mode 100644 index 00000000..918f6bbe --- /dev/null +++ b/lua/snacks/image/convert.lua @@ -0,0 +1,161 @@ +---@class snacks.image.convert +local M = {} +-- vim.list_extend(args, { +-- -- "-density", +-- -- "4000", +-- -- "-background", +-- -- "transparent", +-- -- "-flatten", +-- -- "+repage", +-- -- -- "-adaptive-resize", +-- -- -- "800", +-- -- "-quality", +-- -- "100", +-- -- "-trim", +-- }) + +---@alias snacks.image.generate.on_done fun(code: number) + +---@class snacks.image.generate +---@field cwd? string +---@field cmd string +---@field args string[] +---@field on_done? snacks.image.generate.on_done + +local uv = vim.uv or vim.loop + +local have_magick ---@type boolean + +---@param src string +function M.is_url(src) + return src:find("^https?://") == 1 +end + +---@param src string +function M.is_uri(src) + return src:find("^%w%w+://") == 1 +end + +---@param src string +function M.norm(src) + if src:find("^file://") then + return vim.fs.normalize(vim.uri_to_fname(src)) + end + return src +end + +---@param src string +---@param ext string +function M.tmpfile(src, ext) + local root = Snacks.image.config.cache + local base = vim.fn.fnamemodify(src, ":t:r") + if M.is_uri(src) then + base = src:gsub("%?.*", ""):match("^%w%w+://(.*)$") or base + end + base = base:gsub("[^%w%.]+", "-") + vim.fn.mkdir(root, "p") + return root .. "/" .. vim.fn.sha256(src):sub(1, 8) .. "-" .. base .. "." .. ext +end + +---@param file string +---@param opts snacks.image.generate +---@return (fun(): boolean) is_ready +function M.generate(file, opts) + local on_done = function(code) + if opts.on_done then + opts.on_done(code) + end + end + if vim.fn.filereadable(file) == 1 then + on_done(0) + return function() + return true + end + end + if Snacks.image.config.debug.convert then + Snacks.debug.cmd(opts) + end + local handle ---@type uv.uv_process_t + handle = uv.spawn(opts.cmd, opts, function(code) + handle:close() + on_done(code) + end) + return function() + return (not handle or handle:is_closing() or false) and vim.fn.filereadable(file) == 1 + end +end + +---@param src string +---@param dest string +---@param opts? {args?: (string|number)[], on_done?: snacks.image.generate.on_done} +---@return (fun(): boolean) is_ready +function M.magick(src, dest, opts) + opts = opts or {} + local args = opts.args or { src .. "[0]" } ---@type string[] + args[#args + 1] = dest + have_magick = have_magick == nil and vim.fn.executable("magick") == 1 or have_magick + return M.generate(dest, { + cmd = have_magick and "magick" or "convert", + args = args, + on_done = opts.on_done, + }) +end + +---@param src string +---@param dest string +---@param opts? {on_done?: snacks.image.generate.on_done} +---@return (fun(): boolean) is_ready +function M.tex2pdf(src, dest, opts) + return M.generate(dest, { + cmd = "pdflatex", + args = { "-output-directory=" .. vim.fn.fnamemodify(dest, ":h"), src }, + on_done = opts and opts.on_done, + }) +end + +---@param src string +---@param opts? {on_done?: snacks.image.generate.on_done} +---@return string png, (fun(): boolean) is_ready +function M.convert(src, opts) + local png = M.tmpfile(src, "png") + src = M.norm(src) + if not M.is_uri(src) then + src = vim.fs.normalize(src) + png = M.tmpfile(src, "png") + local ext = vim.fn.fnamemodify(src, ":e"):lower() + if ext == "png" then + if opts and opts.on_done then + opts.on_done(0) + end + return src, function() + return true + end + elseif ext == "tex" then + local pdf = src:gsub("%.tex$", ".pdf") + local is_ready = function() + return false + end + M.tex2pdf(src, pdf, { + on_done = function(code) + if code == 0 then + opts.args = { "-density", 300, pdf, "-trim" } + is_ready = M.magick(pdf, png, opts) + end + end, + }) + return png, function() + return is_ready() + end + end + end + opts.args = { + -- "-density", + -- 128, + src, + "-scale", + "200%", + } + return png, M.magick(src, png, opts) +end + +return M diff --git a/lua/snacks/image/image.lua b/lua/snacks/image/image.lua index d82b7ba8..eed9bcff 100644 --- a/lua/snacks/image/image.lua +++ b/lua/snacks/image/image.lua @@ -5,7 +5,8 @@ ---@field sent? boolean image data is sent ---@field placements table image placements ---@field augroup number ----@field _convert uv.uv_process_t? +---@field _ready fun(): boolean +---@field closed? boolean local M = {} M.__index = M @@ -16,7 +17,6 @@ local _pid = 0 local nvim_id = 0 local uv = vim.uv or vim.loop local images = {} ---@type table -local have_magick ---@type boolean? local terminal = Snacks.image.terminal ---@param src string @@ -25,6 +25,7 @@ function M.new(src) self.src = src self.file = self:convert() if images[self.file] then + self.closed = true return images[self.file] end images[self.file] = self @@ -38,18 +39,15 @@ function M.new(src) -- interleave the nvim id and the image id self.id = bit.bor(bit.lshift(nvim_id, 24 - NVIM_ID_BITS), _id) self.placements = {} - if self:ready() then - vim.schedule(function() - self:on_ready() - end) - end self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true }) return self end function M:on_ready() - self:send() + if not self.sent and not self.closed then + self:send() + end end function M:on_send() @@ -59,39 +57,23 @@ function M:on_send() end function M:ready() - return (not self._convert or self._convert:is_closing()) and self.file and vim.fn.filereadable(self.file) == 1 + return self._ready() and self.file and vim.fn.filereadable(self.file) == 1 end function M:convert() - local src = self.src - if src:find("^file://") then - src = vim.uri_to_fname(src) - end - -- convert urls and non-png files to png - if not src:find("^https?://") and src:lower():find("%.png$") then - return src - end - if not src:find("^%w%w+://") then - src = vim.fs.normalize(src) - end - local fin = src .. "[0]" - local root = Snacks.image.config.cache - vim.fn.mkdir(root, "p") - src = root .. "/" .. Snacks.util.file_encode(fin) .. ".png" - if vim.fn.filereadable(src) == 1 then - return src - end - local opts = { args = { fin, src } } - have_magick = have_magick == nil and vim.fn.executable("magick") == 1 or have_magick - self._convert = uv.spawn(have_magick and "magick" or "convert", opts, function(code) - self._convert:close() - if code == 0 then - vim.schedule(function() - self:on_ready() - end) - end - end) - return src + local png, ready = Snacks.image.convert.convert(self.src, { + on_done = function(code) + if code == 0 then + vim.schedule(function() + self:on_ready() + end) + else + Snacks.notify.error("Failed to convert image to " .. self.file) + end + end, + }) + self._ready = ready + return png end -- create the image diff --git a/lua/snacks/image/init.lua b/lua/snacks/image/init.lua index 514b4a3b..dc738992 100644 --- a/lua/snacks/image/init.lua +++ b/lua/snacks/image/init.lua @@ -5,10 +5,11 @@ ---@field util snacks.image.util ---@field buf snacks.image.buf ---@field doc snacks.image.doc +---@field convert snacks.image.convert local M = setmetatable({}, { ---@param M snacks.image __index = function(M, k) - if vim.tbl_contains({ "terminal", "image", "placement", "util", "doc", "buf" }, k) then + if vim.tbl_contains({ "terminal", "image", "placement", "util", "doc", "buf", "convert" }, k) then M[k] = require("snacks.image." .. k) end return rawget(M, k) @@ -75,7 +76,11 @@ local defaults = { statuscolumn = "", }, cache = vim.fn.stdpath("cache") .. "/snacks/image", - debug = false, + debug = { + request = false, + convert = false, + placement = false, + }, env = {}, } M.config = Snacks.config.get("image", defaults) @@ -225,6 +230,12 @@ function M.health() end end + if Snacks.health.have_tool({ "pdflatex" }) then + Snacks.health.ok("`pdflatex` is available to render math expressions in `latex` and `markdown` documents") + else + Snacks.health.warn("`pdflatex` is required to render LaTeX math equations") + end + if env.supported then Snacks.health.ok("your terminal supports the kitty graphics protocol") elseif M.config.force then diff --git a/lua/snacks/image/placement.lua b/lua/snacks/image/placement.lua index 82038baa..b4cee49c 100644 --- a/lua/snacks/image/placement.lua +++ b/lua/snacks/image/placement.lua @@ -114,7 +114,7 @@ function M:render_grid(loc) vim.api.nvim_set_hl(0, hl, { fg = self.img.id, sp = self.id, - bg = Snacks.image.config.debug and "#FF007C" or nil, + bg = Snacks.image.config.debug.placement and "#FF007C" or nil, }) local lines = {} ---@type string[] for r = 1, loc.height do @@ -180,7 +180,7 @@ function M:render_fallback(state) end function M:debug(...) - if not Snacks.image.config.debug then + if true or not Snacks.image.config.debug then return end Snacks.debug.inspect({ ... }, self.img.src, self.img.id, self.id) diff --git a/lua/snacks/image/terminal.lua b/lua/snacks/image/terminal.lua index 6aa6e863..46ea8460 100644 --- a/lua/snacks/image/terminal.lua +++ b/lua/snacks/image/terminal.lua @@ -169,7 +169,7 @@ function M.request(opts) if env.transform then data = env.transform(data) end - if Snacks.image.config.debug and opts.m ~= 1 then + if Snacks.image.config.debug.request and opts.m ~= 1 then Snacks.debug.inspect(opts) end io.stdout:write(data) diff --git a/lua/snacks/image/util.lua b/lua/snacks/image/util.lua index e7df18fd..41fbf0b8 100644 --- a/lua/snacks/image/util.lua +++ b/lua/snacks/image/util.lua @@ -30,8 +30,8 @@ end function M.pixels_to_cells(size) local terminal = Snacks.image.terminal.size() return M.norm({ - width = size.width / terminal.cell_width * terminal.scale, - height = size.height / terminal.cell_height * terminal.scale, + width = size.width / terminal.cell_width, + height = size.height / terminal.cell_height, }) end diff --git a/queries/css/images.scm b/queries/css/images.scm index bfcfde54..40f27dbe 100644 --- a/queries/css/images.scm +++ b/queries/css/images.scm @@ -2,5 +2,5 @@ (declaration (call_expression (function_name) @fn (#eq? @fn "url") - (arguments [(plain_value) @image (string_value (string_content) @image)])) -) @anchor + (arguments [(plain_value) @image.src (string_value (string_content) @image.src)])) +) @image diff --git a/queries/html/images.scm b/queries/html/images.scm index dd45239f..d13009c1 100644 --- a/queries/html/images.scm +++ b/queries/html/images.scm @@ -4,14 +4,20 @@ (tag_name) @tag (#eq? @tag "img") (attribute (attribute_name) @attr_name (#eq? @attr_name "src") - (quoted_attribute_value (attribute_value) @image) + (quoted_attribute_value (attribute_value) @image.src) ) ) -) @anchor +) @image + (self_closing_tag (tag_name) @tag (#eq? @tag "img") (attribute (attribute_name) @attr_name (#eq? @attr_name "src") - (quoted_attribute_value (attribute_value) @image) + (quoted_attribute_value (attribute_value) @image.src) ) -) @anchor +) @image + +(element + (start_tag (tag_name) @tag (#eq? @tag "svg")) + (#set! ext "svg") +) @image @image.content diff --git a/queries/javascript/images.scm b/queries/javascript/images.scm index 2fe159e3..88c46876 100644 --- a/queries/javascript/images.scm +++ b/queries/javascript/images.scm @@ -4,15 +4,15 @@ (identifier) @tag (#any-of? @tag "img" "Image") (jsx_attribute (property_identifier) @attr_name (#eq? @attr_name "src") - (string (string_fragment) @image) + (string (string_fragment) @image.src) ) ) -) @anchor +) @image (jsx_self_closing_element (identifier) @tag (#any-of? @tag "img" "Image") (jsx_attribute (property_identifier) @attr_name (#eq? @attr_name "src") - (string (string_fragment) @image) + (string (string_fragment) @image.src) ) -) @anchor +) @image diff --git a/queries/latex/images.scm b/queries/latex/images.scm new file mode 100644 index 00000000..5ea1f3f9 --- /dev/null +++ b/queries/latex/images.scm @@ -0,0 +1,9 @@ +(inline_formula) @image.content @image + +(displayed_equation) @image.content @image +(math_environment) @image.content @image + +(graphics_include + (_ (path) @image.src) +) @image + diff --git a/queries/markdown/images.scm b/queries/markdown/images.scm index a78cf02a..202547e5 100644 --- a/queries/markdown/images.scm +++ b/queries/markdown/images.scm @@ -1 +1,9 @@ ; extends + +(fenced_code_block + (info_string (language) @lang) + (#eq? @lang "math") + (code_fence_content) @image.content + (#set! injection.language "latex") + (#set! image.ext "tex") +) @image diff --git a/queries/markdown/injections.scm b/queries/markdown/injections.scm new file mode 100644 index 00000000..d08aa33b --- /dev/null +++ b/queries/markdown/injections.scm @@ -0,0 +1,8 @@ +; extends + +(fenced_code_block + (info_string (language) @lang) + (#eq? @lang "math") + (code_fence_content) @injection.content + (#set! injection.language "latex") +) diff --git a/queries/markdown_inline/images.scm b/queries/markdown_inline/images.scm index 2cabcf97..632e6232 100644 --- a/queries/markdown_inline/images.scm +++ b/queries/markdown_inline/images.scm @@ -1,9 +1,9 @@ (image [ - (link_destination) @image - (image_description (shortcut_link (link_text) @image)) - (#gsub! @image "|.*" "") ; remove wikilink image options - (#gsub! @image "^<" "") ; remove bracket link - (#gsub! @image "^>" "") - ]) @anchor + (link_destination) @image.src + (image_description (shortcut_link (link_text) @image.src)) + (#gsub! @image.src "|.*" "") ; remove wikilink image options + (#gsub! @image.src "^<" "") ; remove bracket link + (#gsub! @image.src "^>" "") + ]) @image diff --git a/queries/norg/images.scm b/queries/norg/images.scm index 4aee5108..196254be 100644 --- a/queries/norg/images.scm +++ b/queries/norg/images.scm @@ -1,5 +1,5 @@ (infirm_tag (tag_name) @tag (#eq? @tag "image") - (tag_parameters (tag_param) @image) -) @anchor + (tag_parameters (tag_param) @image.src) +) @image diff --git a/queries/scss/images.scm b/queries/scss/images.scm index f8d00dc2..e4ba8ebf 100644 --- a/queries/scss/images.scm +++ b/queries/scss/images.scm @@ -2,10 +2,10 @@ (call_expression (function_name) @fn (#eq? @fn "url") (arguments [ - (plain_value) @image - (string_value) @image + (plain_value) @image.src + (string_value) @image.src ; Remove quotes from the image URL - (#gsub! @image "^['\"]" "") - (#gsub! @image "['\"]$" "") + (#gsub! @image.src "^['\"]" "") + (#gsub! @image.src "['\"]$" "") ])) -) @anchor +) @image diff --git a/tests/image/math.md b/tests/image/math.md new file mode 100644 index 00000000..d5524463 --- /dev/null +++ b/tests/image/math.md @@ -0,0 +1,12 @@ +This sentence uses `$` delimiters to show math inline: $\sqrt{3x-1}+(1+x)^2$ + +This sentence uses delimiters to show math inline: $`\sqrt{3x-1}+(1+x)^2`$ + +**The Cauchy-Schwarz Inequality**\ +$$\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)$$ + +**The Cauchy-Schwarz Inequality** + +```math +\left( \sum_{k=1}^n a_k b_k \right)^2 \leq \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right) +``` diff --git a/tests/image/math.png b/tests/image/math.png new file mode 100644 index 00000000..2c8ccd30 Binary files /dev/null and b/tests/image/math.png differ diff --git a/tests/image/test.aux b/tests/image/test.aux new file mode 100644 index 00000000..b6401217 --- /dev/null +++ b/tests/image/test.aux @@ -0,0 +1,2 @@ +\relax +\gdef \@abspage@last{1} diff --git a/tests/image/test.html b/tests/image/test.html index 00dc1790..a042a290 100644 --- a/tests/image/test.html +++ b/tests/image/test.html @@ -1,4 +1,63 @@ + + + Neovim + + + + + + + + + + + + + + + + + + + + + + + + + +