feat(image): refactor of treesitter queries to support inline image data

This commit is contained in:
Folke Lemaitre 2025-02-17 12:49:38 +01:00
parent 48a3fed3c5
commit 0bf0c6223d
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
22 changed files with 393 additions and 68 deletions

View file

@ -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

View file

@ -5,7 +5,8 @@
---@field sent? boolean image data is sent
---@field placements table<number, snacks.image.Placement> 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<string, snacks.Image>
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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

9
queries/latex/images.scm Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1,8 @@
; extends
(fenced_code_block
(info_string (language) @lang)
(#eq? @lang "math")
(code_fence_content) @injection.content
(#set! injection.language "latex")
)

View file

@ -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

View file

@ -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

View file

@ -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

12
tests/image/math.md Normal file
View file

@ -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)
```

BIN
tests/image/math.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

2
tests/image/test.aux Normal file
View file

@ -0,0 +1,2 @@
\relax
\gdef \@abspage@last{1}

View file

@ -1,4 +1,63 @@
<body>
<a href="/" class="navbar-brand" aria-label="logo">
<svg
xmlns="http://www.w3.org/2000/svg"
role="img"
width="173"
height="50"
viewBox="0 0 742 214"
aria-label="Neovim"
>
<title>Neovim</title>
<defs>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a">
<stop stop-color="#16B0ED" stop-opacity=".8" offset="0%"></stop>
<stop stop-color="#0F59B2" stop-opacity=".837" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b">
<stop stop-color="#7DB643" offset="0%"></stop>
<stop stop-color="#367533" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="c">
<stop stop-color="#88C649" stop-opacity=".8" offset="0%"></stop>
<stop stop-color="#439240" stop-opacity=".84" offset="100%"></stop>
</linearGradient>
</defs>
<g fill="none" fill-rule="evenodd">
<path
d="M.027 45.459L45.224-.173v212.171L.027 166.894V45.459z"
fill="url(#a)"
transform="translate(1 1)"
></path>
<path
d="M129.337 45.89L175.152-.149l-.928 212.146-45.197-45.104.31-121.005z"
fill="url(#b)"
transform="matrix(-1 0 0 1 305 1)"
></path>
<path
d="M45.194-.137L162.7 179.173l-32.882 32.881L12.25 33.141 45.194-.137z"
fill="url(#c)"
transform="translate(1 1)"
></path>
<path
d="M46.234 84.032l-.063 7.063-36.28-53.563 3.36-3.422 32.983 49.922z"
fill-opacity=".13"
fill="#000"
></path>
<g fill="#444">
<path
d="M227 154V64.44h4.655c1.55 0 2.445.75 2.685 2.25l.806 13.502c4.058-5.16 8.786-9.316 14.188-12.466 5.4-3.15 11.413-4.726 18.037-4.726 4.893 0 9.205.781 12.935 2.34 3.729 1.561 6.817 3.811 9.264 6.751 2.448 2.942 4.297 6.48 5.55 10.621 1.253 4.14 1.88 8.821 1.88 14.042V154h-8.504V96.754c0-8.402-1.91-14.987-5.729-19.757-3.82-4.771-9.667-7.156-17.544-7.156-5.851 0-11.28 1.516-16.292 4.545-5.013 3.032-9.489 7.187-13.427 12.467V154H227zM350.624 63c5.066 0 9.755.868 14.069 2.605 4.312 1.738 8.052 4.268 11.219 7.592s5.638 7.412 7.419 12.264C385.11 90.313 386 95.883 386 102.17c0 1.318-.195 2.216-.588 2.696-.393.48-1.01.719-1.851.719h-64.966v1.70c0 6.708.784 12.609 2.353 17.7 1.567 5.09 3.8 9.357 6.695 12.802 2.895 3.445 6.393 6.034 10.495 7.771 4.1 1.738 8.686 2.606 13.752 2.606 4.524 0 8.446-.494 11.762-1.483 3.317-.988 6.108-2.097 8.37-3.324 2.261-1.227 4.056-2.336 5.383-3.324 1.326-.988 2.292-1.482 2.895-1.482.784 0 1.388.3 1.81.898l2.352 2.875c-1.448 1.797-3.362 3.475-5.745 5.031-2.383 1.558-5.038 2.891-7.962 3.998-2.926 1.109-6.062 1.991-9.41 2.65a52.21 52.21 0 01-10.088.989c-6.152 0-11.762-1.064-16.828-3.19-5.067-2.125-9.415-5.225-13.043-9.298-3.63-4.074-6.435-9.06-8.415-14.96C310.99 121.655 310 114.9 310 107.294c0-6.408.92-12.323 2.76-17.744 1.84-5.421 4.493-10.093 7.961-14.016 3.467-3.922 7.72-6.991 12.758-9.209C338.513 64.11 344.229 63 350.624 63zm.573 6c-4.696 0-8.904.702-12.623 2.105-3.721 1.404-6.936 3.421-9.65 6.053-2.713 2.631-4.908 5.79-6.586 9.474S319.55 94.439 319 99h60c0-4.679-.672-8.874-2.013-12.588-1.343-3.712-3.232-6.856-5.67-9.43-2.44-2.571-5.367-4.545-8.782-5.92-3.413-1.374-7.192-2.062-11.338-2.062zM435.546 63c6.526 0 12.368 1.093 17.524 3.28 5.154 2.186 9.5 5.286 13.04 9.298 3.538 4.013 6.238 8.85 8.099 14.51 1.861 5.66 2.791 11.994 2.791 19.002 0 7.008-.932 13.327-2.791 18.957-1.861 5.631-4.561 10.452-8.099 14.465-3.54 4.012-7.886 7.097-13.04 9.254-5.156 2.156-10.998 3.234-17.524 3.234-6.529 0-12.369-1.078-17.525-3.234-5.155-2.157-9.517-5.242-13.085-9.254-3.57-4.013-6.285-8.836-8.145-14.465-1.861-5.63-2.791-11.95-2.791-18.957 0-7.008.93-13.342 2.791-19.002 1.861-5.66 4.576-10.496 8.145-14.51 3.568-4.012 7.93-7.112 13.085-9.299C423.177 64.094 429.017 63 435.546 63zm-.501 86c5.341 0 10.006-.918 13.997-2.757 3.99-1.838 7.32-4.474 9.992-7.909 2.67-3.435 4.664-7.576 5.986-12.428 1.317-4.85 1.98-10.288 1.98-16.316 0-5.965-.66-11.389-1.98-16.27-1.322-4.88-3.316-9.053-5.986-12.519-2.67-3.463-6-6.13-9.992-7.999-3.991-1.867-8.657-2.802-13.997-2.802s-10.008.935-13.997 2.802c-3.991 1.87-7.322 4.536-9.992 8-2.671 3.465-4.68 7.637-6.03 12.518-1.35 4.881-2.026 10.305-2.026 16.27 0 6.026.675 11.465 2.025 16.316 1.35 4.852 3.36 8.993 6.031 12.428 2.67 3.435 6 6.07 9.992 7.91 3.99 1.838 8.656 2.756 13.997 2.756z"
fill="currentColor"
></path>
<path
d="M530.57 152h-20.05L474 60h18.35c1.61 0 2.967.39 4.072 1.166 1.103.778 1.865 1.763 2.283 2.959l17.722 49.138a92.762 92.762 0 012.551 8.429c.686 2.751 1.298 5.5 1.835 8.25.537-2.75 1.148-5.499 1.835-8.25a77.713 77.713 0 012.64-8.429l18.171-49.138c.417-1.196 1.164-2.181 2.238-2.96 1.074-.776 2.356-1.165 3.849-1.165H567l-36.43 92zM572 61h23v92h-23zM610 153V60.443h13.624c2.887 0 4.78 1.354 5.682 4.06l1.443 6.856a52.7 52.7 0 015.097-4.962 32.732 32.732 0 015.683-3.879 30.731 30.731 0 016.496-2.57c2.314-.632 4.855-.948 7.624-.948 5.832 0 10.63 1.579 14.39 4.736 3.758 3.157 6.57 7.352 8.434 12.585 1.444-3.068 3.248-5.698 5.413-7.894 2.165-2.194 4.541-3.984 7.127-5.367a32.848 32.848 0 018.254-3.068 39.597 39.597 0 018.796-.992c5.111 0 9.653.783 13.622 2.345 3.97 1.565 7.307 3.849 10.014 6.857 2.706 3.007 4.766 6.675 6.18 11.005C739.29 83.537 740 88.5 740 94.092V153h-22.284V94.092c0-5.894-1.294-10.329-3.878-13.306-2.587-2.977-6.376-4.465-11.368-4.465-2.286 0-4.404.391-6.358 1.172a15.189 15.189 0 00-5.144 3.383c-1.473 1.474-2.631 3.324-3.474 5.548-.842 2.225-1.263 4.781-1.263 7.668V153h-22.37V94.092c0-6.194-1.249-10.704-3.744-13.532-2.497-2.825-6.18-4.24-11.051-4.24-3.19 0-6.18.798-8.976 2.391-2.799 1.593-5.399 3.775-7.804 6.54V153H610zM572 30h23v19h-23z"
fill="currentColor"
fill-opacity=".8"
></path>
</g>
</g>
</svg>
</a>
<a href="https://github.com/folke/lazy.nvim/releases/latest">
<img
alt="Latest release"

BIN
tests/image/test.pdf Normal file

Binary file not shown.

62
tests/image/test.tex Normal file
View file

@ -0,0 +1,62 @@
\documentclass{article}
\usepackage{graphicx} % For images
\usepackage{amsmath} % For math symbols
\usepackage{amssymb} % For extra math symbols
\begin{document}
\section{Image Tests}
Inline image:
\includegraphics[width=0.5\textwidth]{test.png}
\begin{figure}[h]
\centering
\includegraphics[width=0.7\textwidth]{test.png}
\caption{Test image centered in a figure environment.}
\label{fig:test_image}
\end{figure}
\newpage
\section{Math Tests}
Inline math: $E=mc^2$ and $\int_0^1 x^2 \,dx$
Displayed equation:
\[
f(x) = \sum_{n=0}^{\infty} \frac{x^n}{n!}
\]
Equation environment:
\begin{equation}
a^2 + b^2 = c^2
\label{eq:pythagoras}
\end{equation}
Aligned equations:
\begin{align}
x^2 + y^2 &= r^2 \\
\nabla \cdot \mathbf{E} &= \frac{\rho}{\varepsilon_0}
\end{align}
Multiline equations:
\begin{multline}
a + b + c + d + e + f + g + h + i + j + k = \\
l + m + n + o + p + q + r + s + t + u + v
\end{multline}
\newpage
\section{Testing Referencing}
Referencing an equation: See Eq.~\ref{eq:pythagoras}.
Referencing an image: See Fig.~\ref{fig:test_image}.
\end{document}

5
tests/image/test2.md Normal file
View file

@ -0,0 +1,5 @@
# test
## Wikilinks
!![[test.jpg]]