feat(image): images are now properly scaled based on device DPI and image DPI. Closing #1257

This commit is contained in:
Folke Lemaitre 2025-02-18 16:31:37 +01:00
parent 2b52d89508
commit 004050c435
No known key found for this signature in database
GPG key ID: 41F8B1FBACAE2040
9 changed files with 439 additions and 192 deletions

View file

@ -2,32 +2,357 @@ local Spawn = require("snacks.util.spawn")
---@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
local have_tectonic ---@type boolean
---@class snacks.image.Info
---@field format string
---@field size snacks.image.Size
---@field dpi snacks.image.Size
---@class snacks.image.convert.Config
---@field src string
---@class snacks.image.meta
---@field src string
---@field info? snacks.image.Info
---@field [string] string|number|boolean
---@class snacks.image.Proc
---@field cmd string
---@field cwd? string
---@field available? boolean
---@field args (number|string)[]
---@class snacks.image.step
---@field name string
---@field file string
---@field ft string
---@field cmd snacks.image.cmd
---@field meta snacks.image.meta
---@field done? boolean
---@field err? string
---@field proc? snacks.spawn.Proc
---@class snacks.image.cmd
---@field cmd (fun(step: snacks.image.step):(snacks.image.Proc|snacks.image.Proc[])?)|snacks.image.Proc|snacks.image.Proc[]
---@field ft? string
---@field file? fun(convert: snacks.image.Convert, meta: snacks.image.meta): string
---@field available? boolean
---@field depends? string[]
---@field on_done? fun(step: snacks.image.step)
---@field pipe? boolean
---@type table<string, snacks.image.cmd>
local commands = {
url = {
cmd = {
{
cmd = "curl",
args = { "-L", "-o", "{file}", "{src}" },
},
{
cmd = "wget",
args = { "-O", "{file}", "{src}" },
},
},
file = function(convert, ctx)
local src = M.norm(ctx.src)
return M.is_uri(src) and convert:tmpfile("data") or src
end,
},
cache = {
file = function(convert, ctx)
return convert:tmpfile(convert:ft(ctx.src))
end,
cmd = function(step)
uv.fs_copyfile(step.meta.src, step.file)
end,
},
tex = {
ft = "pdf",
cmd = {
{
cwd = "{dirname}",
cmd = "tectonic",
args = { "--outdir", "{cache}", "{src}" },
},
{
cmd = "pdflatex",
cwd = "{dirname}",
args = { "-output-directory={cache}", "-interaction=nonstopmode", "{src}" },
},
},
on_done = function(step)
local pdf = Snacks.image.config.cache .. "/" .. vim.fs.basename(step.meta.src):gsub("%.tex$", ".pdf")
if uv.fs_stat(pdf) then
uv.fs_rename(pdf, step.file)
end
end,
},
mmd = {
ft = "png",
cmd = {
cmd = "mmdc",
args = { "-i", "{src}", "-o", "{file}", "-b", "transparent", "-t", "{bg}", "-s", "{scale}" },
},
},
identify = {
pipe = false,
file = function(convert, ctx)
return convert:tmpfile(convert:ft() .. ".info")
end,
cmd = {
{
cmd = "magick",
args = { "identify", "-format", "%m %[fx:w]x%[fx:h] %xx%y", "{src}[0]" },
},
{
cmd = "identify",
args = { "-format", "%m %[fx:w]x%[fx:h] %xx%y", "{src}[0]" },
},
},
on_done = function(step)
local file = step.file
if step.proc then
local fd = assert(io.open(file, "w"), "Failed to open file: " .. file)
fd:write(step.proc:out())
fd:close()
end
local fd = assert(io.open(file, "r"), "Failed to open file: " .. file)
local info = vim.trim(fd:read("*a"))
fd:close()
local format, w, h, x, y = info:match("^(%w+)%s+(%d+)x(%d+)%s+(%d+%.?%d*)x(%d+%.?%d*)$")
if not format then
dd(info)
end
step.meta.info = {
format = format:lower(),
size = { width = tonumber(w) or 0, height = tonumber(h) or 0 },
dpi = { width = tonumber(x) or 0, height = tonumber(y) or 0 },
}
end,
},
convert = {
depends = { "identify" },
ft = "png",
cmd = function(step)
local formats = vim.deepcopy(Snacks.image.config.magick or {})
local args = formats.default or { "{src}[0]" }
local info = step.meta.info
local fts = { vim.fs.basename(step.file):match("%.([^%.]+)%.png") } ---@type string[]
if info then
local vector = vim.tbl_contains({ "pdf", "svg", "eps", "ai", "mvg" }, info.format)
if vector then
args = { "-density", 300, "{src}[0]" }
end
if info.format then
fts[#fts + 1] = info.format
end
end
for _, ft in ipairs(fts) do
local fmt = formats[ft]
if fmt then
args = fmt
break
end
end
args[#args + 1] = "{file}"
return {
{ cmd = "magick", args = args },
{ cmd = "convert", args = args },
}
end,
},
}
---@class snacks.image.Convert
---@field opts snacks.image.convert.Config
---@field src string
---@field file string
---@field prefix string
---@field meta snacks.image.meta
---@field steps snacks.image.step[]
---@field _done? boolean
---@field _err? string
---@field tpl_data table<string, string>
local Convert = {}
Convert.__index = Convert
---@param opts snacks.image.convert.Config
function Convert.new(opts)
local self = setmetatable({}, Convert)
opts.src = M.norm(opts.src)
self.opts = opts
self.src = opts.src
local base = vim.fn.fnamemodify(opts.src, ":t:r")
if M.is_uri(self.opts.src) then
base = self.opts.src:gsub("%?.*", ""):match("^%w%w+://(.*)$") or base
end
self.prefix = vim.fn.sha256(self.opts.src):sub(1, 8) .. "-" .. base:gsub("[^%w%.]+", "-")
self.meta = { src = opts.src }
self.steps = {}
self.tpl_data = {
cache = Snacks.image.config.cache,
bg = vim.o.background,
scale = tostring(Snacks.image.terminal.size().scale or 1),
}
self:resolve()
return self
end
function Convert:current()
for _, step in ipairs(self.steps) do
if not step.done then
return step
end
end
end
function Convert:ready()
return self:done() and not self:error()
end
function Convert:done()
return self._done or false
end
function Convert:error()
return self._err
end
function Convert:tmpfile(ft)
return Snacks.image.config.cache .. "/" .. self.prefix .. "." .. ft
end
---@param target string
function Convert:_resolve(target)
local cmd = assert(commands[target], "No command for target: " .. target)
assert(cmd.file or cmd.ft, "No file or ft for target: " .. target)
for _, dep in ipairs(cmd.depends or {}) do
self:_resolve(dep)
end
local file = cmd.file and cmd.file(self, self.meta) or self:tmpfile(cmd.ft)
---@type snacks.image.step
local step = {
name = target,
file = file,
ft = self:ft(file),
meta = self.meta,
done = uv.fs_stat(file) ~= nil,
cmd = cmd,
}
if cmd.pipe ~= false then
self.meta = setmetatable({ src = file }, { __index = self.meta })
end
table.insert(self.steps, step)
end
---@param src? string
---@return string
function Convert:ft(src)
return vim.fn.fnamemodify(src or self.meta.src, ":e"):lower()
end
function Convert:resolve()
self:_resolve("url")
while self:ft() ~= "png" do
local ft = self:ft()
local target = commands[ft] and ft or "convert"
if self:_resolve(target) then
break
end
end
self:_resolve("identify")
self.file = self.meta.src
end
---@param cb fun(convert: snacks.image.Convert)
function Convert:run(cb)
if #self.steps == 0 then
self._done = true
return cb(self)
end
local s = 0
local next ---@type fun()
---@param step snacks.image.step
---@param err? string
local function done(step, err)
step.done = true
if err then
step.err = err
Snacks.notify.error("Conversion of " .. step.name .. " failed:\n" .. err)
self._err = err
self._done = true
return cb(self)
end
if step.cmd.on_done then
step.cmd.on_done(step)
end
if s == #self.steps then
self._done = true
return cb(self)
end
next()
end
next = function()
s = s + 1
assert(s <= #self.steps, "No more steps")
local step = self.steps[s]
if step.done then
return done(step)
end
local cmd = step.cmd.cmd
if type(cmd) == "function" then
local ok, c = pcall(cmd, step)
if ok and c then
cmd = c
else
return done(step, not ok and (c or "error") or nil)
end
end
local cmds = cmd.cmd and { cmd } or cmd
---@cast cmds snacks.image.Proc[]
for _, c in ipairs(cmds) do
if c.available == nil then
c.available = vim.fn.executable(c.cmd) == 1
end
if c.available then
local args = vim.deepcopy(c.args)
local data = vim.tbl_extend("keep", {
file = step.file,
basename = vim.fs.basename(step.file),
name = vim.fn.fnamemodify(step.file, ":t:r"),
dirname = vim.fs.dirname(step.meta.src),
src = step.meta.src,
}, self.tpl_data)
for a, arg in ipairs(args) do
if type(arg) == "string" then
args[a] = Snacks.picker.util.tpl(arg, data)
end
end
step.proc = Spawn.new({
debug = Snacks.image.config.debug.convert,
cwd = c.cwd and Snacks.picker.util.tpl(c.cwd, data) or nil,
cmd = c.cmd,
args = args,
on_exit = function(proc, err)
local out = vim.trim(proc:out() .. "\n" .. proc:err())
done(step, err and out or nil)
end,
})
return
end
return done(step, "No command available")
end
end
next()
end
---@param src string
function M.is_url(src)
@ -42,132 +367,17 @@ end
---@param src string
function M.norm(src)
if src:find("^file://") then
return vim.fs.normalize(vim.uri_to_fname(src))
src = vim.uri_to_fname(src)
end
if not M.is_uri(src) then
src = vim.fs.normalize(vim.fn.fnamemodify(src, ":p"))
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 ... snacks.spawn.Config
function M.generate(file, ...)
local opts = Snacks.config.merge(...)
opts = Snacks.config.merge(opts, { debug = Snacks.image.config.debug.convert })
if vim.fn.filereadable(file) == 1 then
return
end
return Spawn.new(opts)
end
---@param src string
---@param dest string
---@param ...? snacks.spawn.Config
function M.magick(src, dest, ...)
local opts = Snacks.config.merge(...)
local args = opts.args or { src .. "[0]" } ---@type string[]
for a, arg in ipairs(args) do
if arg == "src" then
args[a] = src .. "[0]"
end
end
args[#args + 1] = dest
have_magick = have_magick == nil and vim.fn.executable("magick") == 1 or have_magick
local cmd = have_magick and "magick" or "convert"
if Snacks.util.is_win and cmd == "convert" then
return
end
return M.generate(dest, opts, {
cmd = cmd,
args = args,
})
end
---@param src string
---@param dest string
---@param ... snacks.spawn.Config
function M.tex2pdf(src, dest, ...)
local opts = Snacks.config.merge(...)
have_tectonic = have_tectonic == nil and vim.fn.executable("tectonic") == 1 or have_tectonic
local dir = vim.fn.fnamemodify(dest, ":h")
local cmd, args = "pdflatex", { "-output-directory=" .. dir, src }
if have_tectonic then
cmd, args = "tectonic", { "--outdir", dir, src }
end
return M.generate(dest, opts, { cmd = cmd, args = args })
end
---@param src string
---@param opts? snacks.spawn.Multi
---@return string png, snacks.spawn.Proc?
function M.convert(src, opts)
local png = M.tmpfile(src, "png")
src = M.norm(src)
local ext = vim.fn.fnamemodify(src, ":e"):lower()
if not M.is_uri(src) then
src = vim.fs.normalize(src)
png = M.tmpfile(src, "png")
if ext == "png" then
return src
elseif ext == "tex" then
local pdf = src:gsub("%.tex$", ".pdf")
local procs = {} ---@type snacks.spawn.Proc[]
procs[#procs + 1] = M.tex2pdf(src, pdf, vim.deepcopy(opts), { run = false })
procs[#procs + 1] = M.magick(pdf, png, vim.deepcopy(opts), {
run = false,
args = { "-density", 300, "src", "-trim" },
})
return png, Spawn.multi(procs, opts)
elseif ext == "mmd" then
return png,
M.generate(png, vim.deepcopy(opts), {
cmd = "mmdc",
args = {
"-i",
src,
"-o",
png,
"-b",
"transparent",
"-t",
vim.o.background,
"-s",
Snacks.image.terminal.size().scale,
},
})
end
end
opts.args = {
-- "-density",
-- 128,
"src",
"-scale",
"200%",
}
if ext == "pdf" then
opts.args = {
"-density",
144,
"src",
"-background",
"white",
"-alpha",
"remove",
"-trim",
}
end
return png, M.magick(src, png, opts)
---@param opts snacks.image.convert.Config
function M.convert(opts)
return Convert.new(opts)
end
return M

View file

@ -19,8 +19,11 @@ M.transforms = {
img.src = line:sub(col + 1)
end,
latex = function(img, ctx)
if not img.content then
return
end
local fg = Snacks.util.color({ "@markup.math.latex", "Special", "Normal" }) or "#000000"
img.ext = "tex"
img.ext = "math.tex"
local content = vim.trim(img.content or "")
content = content:gsub("^%$+`?", ""):gsub("`?%$+$", "")
content = content:gsub("^\\[%[%(]", ""):gsub("\\[%]%)]$", "")
@ -31,7 +34,7 @@ M.transforms = {
\documentclass[preview,border=2pt,varwidth]{standalone}
\usepackage{xcolor, amsmath, amssymb}
\begin{document}
{ \large \color[HTML]{%s}
{ \Large \color[HTML]{%s}
%s}
\end{document}
]]):format(fg:upper():sub(2), content)
@ -122,7 +125,7 @@ function M.find(buf, from, to)
local range = vim.treesitter.get_range(ctx.pos.node, buf, ctx.pos.meta)
---@type snacks.image.match
local img = {
ext = meta.ext,
ext = meta["image.ext"],
id = ctx.pos.node:id(),
pos = {
range[1] == range[4] and (range[1] + 1) or (range[4] + 1),

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 _proc? snacks.spawn.Proc
---@field info? snacks.image.Info
---@field _convert? snacks.image.Convert
local M = {}
M.__index = M
@ -39,9 +40,7 @@ function M.new(src)
self.placements = {}
self.augroup = vim.api.nvim_create_augroup("snacks.image." .. self.id, { clear = true })
if self._proc then
self._proc:run()
end
self:run()
if self:ready() then
self:on_ready()
end
@ -51,6 +50,7 @@ end
function M:on_ready()
if not self.sent then
self.info = self._convert and self._convert.meta.info or nil
self:send()
end
end
@ -62,41 +62,44 @@ function M:on_send()
end
function M:failed()
if self._proc and self._proc:running() then
if self._convert and not self._convert:done() then
return false
end
if self._proc and self._proc:failed() then
if self._convert and self._convert:error() then
return true
end
return self.file and vim.fn.filereadable(self.file) == 0
end
function M:ready()
if self._proc and self._proc:running() then
if self._convert and not self._convert:done() then
return false
end
return self.file and vim.fn.filereadable(self.file) == 1
end
function M:run()
if not self._convert then
return
end
self._convert:run(function(convert)
if convert:error() then
vim.schedule(function()
for _, p in pairs(self.placements) do
p:error()
end
end)
else
vim.schedule(function()
self:on_ready()
end)
end
end)
end
function M:convert()
local png, proc = Snacks.image.convert.convert(self.src, {
run = false,
on_exit = function(procs, err)
if err then
vim.schedule(function()
for _, p in pairs(self.placements) do
p:error()
end
end)
else
vim.schedule(function()
self:on_ready()
end)
end
end,
})
self._proc = proc
return png
self._convert = Snacks.image.convert.convert({ src = self.src })
return self._convert.file
end
-- create the image

View file

@ -44,6 +44,7 @@ M.meta = {
--- Return the absolute path or url to the image.
--- When `nil`, the path is resolved relative to the file.
---@field resolve? fun(file: string, src: string): string?
---@field magick? table<string, (string|number)[]>
local defaults = {
formats = {
"png",
@ -99,6 +100,11 @@ local defaults = {
placement = false,
},
env = {},
magick = {
default = { "{src}[0]", "-scale", "1920x1080>" },
math = { "-density", 600, "{src}[0]", "-trim" },
pdf = { "-density", 300, "{src}[0]", "-background", "white", "-alpha", "remove", "-trim" },
},
}
M.config = Snacks.config.get("image", defaults)

View file

@ -99,10 +99,23 @@ function M:error()
return
end
local msg = "# Image Conversion Failed:\n\n"
local proc = self.img._proc
if proc then
msg = msg .. Snacks.debug.cmd({ cmd = proc.opts.cmd, args = proc.opts.args, cwd = proc.opts.cwd, notify = false })
msg = msg .. "\n\n# Output\n" .. proc:out() .. "\n\n# Error\n" .. proc:err()
local convert = self.img._convert
if convert then
for _, step in ipairs(convert.steps) do
if step.err then
msg = msg .. "## " .. step.name .. "\n\n" .. step.err .. "\n\n"
if step.proc then
msg = msg
.. Snacks.debug.cmd({
cmd = step.proc.opts.cmd,
args = step.proc.opts.args,
cwd = step.proc.opts.cwd,
notify = false,
})
msg = msg .. "\n\n# Output\n" .. vim.trim(step.proc:out() .. "\n" .. step.proc:err()) .. "\n"
end
end
end
end
local lines = vim.split(msg, "\n")
vim.bo[self.buf].modifiable = true
@ -127,14 +140,17 @@ function M:progress()
vim.schedule_wrap(function()
if self:ready() or self.img:failed() or not vim.api.nvim_buf_is_valid(self.buf) then
timer:stop()
return timer:close()
if not timer:is_closing() then
timer:close()
end
return
end
vim.api.nvim_buf_clear_namespace(self.buf, self.ns, 0, -1)
vim.api.nvim_buf_set_extmark(self.buf, self.ns, 0, 0, {
virt_text = {
{ Snacks.util.spinner(), "SnacksImageSpinner" },
{ " " },
{ self.img._proc.opts.cmd .. " loading …", "SnacksImageLoading" },
{ self.img._convert:current().name .. " loading …", "SnacksImageLoading" },
},
})
end)
@ -263,7 +279,7 @@ function M:state()
width = minmax(self.opts.width or width, self.opts.min_width, self.opts.max_width)
height = minmax(self.opts.height or height, self.opts.min_height, self.opts.max_height)
local size = Snacks.image.util.fit(self.img.file, { width = width, height = height }, { full = not self.opts.inline })
local size = Snacks.image.util.fit(self.img.file, { width = width, height = height }, { info = self.img.info })
local pos = self.opts.pos or { 1, 0 }
---@class snacks.image.State

View file

@ -46,20 +46,27 @@ end
---@param file string
---@param cells snacks.image.Size size in rows x columns
---@param opts? { full?: boolean }
---@param opts? { full?: boolean, info?: snacks.image.Info }
function M.fit(file, cells, opts)
opts = opts or {}
local img_pixels = M.dim(file)
local img_pixels ---@type snacks.image.Size
if opts.info then
local terminal = Snacks.image.terminal.size()
img_pixels.height = opts.info.size.height / opts.info.dpi.height * 96 * terminal.scale
img_pixels.width = opts.info.size.width / opts.info.dpi.width * 96 * terminal.scale
else
img_pixels = M.dim(file)
end
local img_cells = M.pixels_to_cells(img_pixels)
local ret = vim.deepcopy(cells)
if not opts.full then
if img_cells.width <= cells.width and img_cells.height <= cells.height then
return img_cells
end
ret.width = math.min(cells.width, img_cells.width)
ret.height = math.min(cells.height, img_cells.height)
-- if not opts.full then
if img_cells.width <= cells.width and img_cells.height <= cells.height then
return img_cells
end
ret.width = math.min(cells.width, img_cells.width)
ret.height = math.min(cells.height, img_cells.height)
-- end
local scale = ret.width / ret.height
local img_scale = img_cells.width / img_cells.height

View file

@ -76,7 +76,9 @@ function Proc:run()
self.stderr = assert(uv.new_pipe())
self.data = { [self.stdout] = {}, [self.stderr] = {} }
if self.opts.debug then
Snacks.debug.cmd({ cmd = self.opts.cmd, args = self.opts.args, cwd = self.opts.cwd })
vim.schedule(function()
Snacks.debug.cmd({ cmd = self.opts.cmd, args = self.opts.args, cwd = self.opts.cwd })
end)
end
local opts = vim.tbl_deep_extend("force", self.opts, {
stdio = { nil, self.stdout, self.stderr },
@ -147,7 +149,7 @@ function Proc:on_exit()
end
end
check:stop()
check:close()
close(check)
close(self.stdout)
close(self.stderr)
if self.opts.on_exit then

View file

@ -19,5 +19,5 @@
(element
(start_tag (tag_name) @tag (#eq? @tag "svg"))
(#set! ext "svg")
(#set! image.ext "svg")
) @image @image.content

View file

@ -13,5 +13,5 @@
(#eq? @lang "mermaid")
(code_fence_content) @image.content
(#set! injection.language "mermaid")
(#set! ext "mmd")
(#set! image.ext "mmd")
) @image