From 57d0673ce1a92d0fd3bc9430a1c282288c76110d Mon Sep 17 00:00:00 2001 From: Guldoman Date: Mon, 16 Jan 2023 05:51:23 +0100 Subject: Add `svg_screenshot` --- manifest.json | 7 + plugins/svg_screenshot.lua | 345 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 plugins/svg_screenshot.lua diff --git a/manifest.json b/manifest.json index 2d94556..2920ea6 100644 --- a/manifest.json +++ b/manifest.json @@ -1109,6 +1109,13 @@ "id": "statusclock", "mod_version": "3" }, + { + "description": "Takes an SVG screenshot. Only browsers seem to support the generated SVG properly.", + "version": "0.1", + "path": "plugins/svg_screenshot.lua", + "id": "svg_screenshot", + "mod_version": "3" + }, { "description": "Switch between open tabs by searching by name", "version": "0.1", diff --git a/plugins/svg_screenshot.lua b/plugins/svg_screenshot.lua new file mode 100644 index 0000000..87da77c --- /dev/null +++ b/plugins/svg_screenshot.lua @@ -0,0 +1,345 @@ +-- mod-version:3 + +--[[ + base64 -- v1.5.3 public domain Lua base64 encoder/decoder + no warranty implied; use at your own risk + Needs bit32.extract function. If not present it's implemented using BitOp + or Lua 5.3 native bit operators. For Lua 5.1 fallbacks to pure Lua + implementation inspired by Rici Lake's post: + http://ricilake.blogspot.co.uk/2007/10/iterating-bits-in-lua.html + author: Ilya Kolbin (iskolbin@gmail.com) + url: github.com/iskolbin/lbase64 + COMPATIBILITY + Lua 5.1+, LuaJIT + LICENSE + See end of file for license information. +--]] + +-- This utility has been altered to remove unused functionality + +--[[ +------------------------------------------------------------------------------ +License for the base64 utility +------------------------------------------------------------------------------ +MIT License +Copyright (c) 2018 Ilya Kolbin +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +------------------------------------------------------------------------------ +--]] + +local base64 = {} + +local extract = function( v, from, width ) + return ( v >> from ) & ((1 << width) - 1) +end + + +function base64.makeencoder( s62, s63, spad ) + local encoder = {} + for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J', + 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y', + 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n', + 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2', + '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do + encoder[b64code] = char:byte() + end + return encoder +end + +local DEFAULT_ENCODER = base64.makeencoder() + +local char, concat = string.char, table.concat + +function base64.encode( str, encoder, usecaching ) + encoder = encoder or DEFAULT_ENCODER + local t, k, n = {}, 1, #str + local lastn = n % 3 + local cache = {} + for i = 1, n-lastn, 3 do + local a, b, c = str:byte( i, i+2 ) + local v = a*0x10000 + b*0x100 + c + local s + if usecaching then + s = cache[v] + if not s then + s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) + cache[v] = s + end + else + s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)]) + end + t[k] = s + k = k + 1 + end + if lastn == 2 then + local a, b = str:byte( n-1, n ) + local v = a*0x10000 + b*0x100 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64]) + elseif lastn == 1 then + local v = str:byte( n )*0x10000 + t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64]) + end + return concat( t ) +end + +-------------------------------------------------------------------------------- + +local core = require "core" +local common = require "core.common" +local keymap = require "core.keymap" +local command = require "core.command" +local style = require "core.style" + +-- TODO: what about the vertical location of text? (svg uses the baseline) +-- TODO: add the overrides only when screenshotting to avoid overhead +-- TODO: complete the font table + +local start_screenshot = false +local screenshotting = false +local draw_data = {} +local known_fonts = {} +local known_colors = {} +local current_clip = "" +local known_clips = {} + +local function is_color(t) + if type(t) ~= "table" then return false end + if #t ~=4 then return false end + for i=1,4 do + if type(t[i]) ~= "number" then return false end + end + return true +end + +local function get_color(color) + return "rgba(" .. table.concat(color, ",") .. ")" +end + +local function get_fill_color(color) + if known_colors[color] then + return "var(--lxl_".. known_colors[color] .. ")" + end + + local fill_color = get_color(color) + -- Try to find a known color with the same values + for k, v in pairs(known_colors) do + if get_color(k) == fill_color then + -- Save the color with the name of the found color + known_colors[color] = v + return get_fill_color(k) + end + end + -- Try to find the color with a different opacity + local opaque_color = {table.unpack(color)} + opaque_color[4] = 255 + local opaque_fill_color = get_color(opaque_color) + for k, _ in pairs(known_colors) do + if get_color(k) == opaque_fill_color then + -- Hacky way to reuse the defined color with a custom opacity + return get_fill_color(k) .. '" opacity="' .. color[4]/255 + end + end + -- Logging warning next frame to avoid drawing it in the screenshot + core.add_thread(function() + core.warn("Unknown color: %s", common.serialize(color)) + end) + return fill_color +end + +local old_begin_frame = renderer.begin_frame +function renderer.begin_frame(...) + if start_screenshot then + start_screenshot = false + screenshotting = true + known_fonts = {} + current_clip = "" + known_clips = {} + -- `shape-rendering="crispEdges"` is needed to avoid antialisaing issues like + -- spaces between rects + -- `font-variant-ligatures: none;` is needed because we don't support ligatures, + -- so the svg shouldn't too + table.insert(draw_data, string.format([[ + + +]], core.root_view.size.x, core.root_view.size.y, core.root_view.size.x, core.root_view.size.y)) + -- Extract known colors + known_colors = {} + local colors = {} + for k, v in pairs(style) do + if is_color(v) then + known_colors[v] = k + table.insert(colors, string.format([[ +--lxl_%s: %s; +]], k, get_color(v))) + end + end + for k, v in pairs(style.syntax) do + if is_color(v) then + known_colors[v] = k + table.insert(colors, string.format([[ +--lxl_%s: %s; +]], k, get_color(v))) + end + end + table.insert(draw_data, "") + -- Needed because we close it when we first set a clip + table.insert(draw_data, "") + end + return old_begin_frame(...) +end + +local old_end_frame = renderer.end_frame +function renderer.end_frame(...) + local res = old_end_frame(...) + if screenshotting then + screenshotting = false + -- Needed to close the last clip + table.insert(draw_data, "") + table.insert(draw_data, string.format("")) + core.command_view:enter("Choose a name", { + validate = function(text) return #text > 0 end, + submit = function(name) + -- Add extension if needed + name = string.gsub(name, "%.[sS][vV][gG]", "") .. ".svg" + local fp = assert( io.open(name, "wb") ) + fp:write(table.concat(draw_data, "\n")) + fp:close() + end + }) + end + return res +end + +-- Used by our renderer to round coordinates +local function rect_to_grid(x, y, w, h) + local x1, y1, x2, y2 = math.floor(x + .5), math.floor(y + .5), + math.floor(x + w + .5), math.floor(y + h + .5) + return x1, y1, x2 - x1, y2 - y1 +end + +local old_draw_rect = renderer.draw_rect +function renderer.draw_rect(x, y, width, height, color, ...) + if screenshotting then + local _x, _y, _w, _h = rect_to_grid(x, y, width, height) + local fill_color = get_fill_color(color) + table.insert(draw_data, + string.format([[]], + _x, _y, _w, _h, fill_color)) + end + return old_draw_rect(x, y, width, height, color, ...) +end + +local function get_font_style(font) + local path = font:get_path() + -- Only consider the first font in a fontgroup + if type(path) == "table" then path = path[1] end + local fp = assert( io.open(path, "rb") ) + local font_content = fp:read("a") + fp:close() + local name, extension = string.match(common.basename(path), "(.*)%.(.-)$") + local encoded_font = base64.encode(font_content) + -- TODO: We need a table of extensions -> mime-type + -- For now we just assume TrueType + return name, string.format([[ +]], name, extension, encoded_font, "truetype") +end + +local old_draw_text = renderer.draw_text +function renderer.draw_text(font, text, x, y, color, ...) + if screenshotting then + local font_path = font:get_path() + -- Only consider the first font in a fontgroup + if type(font_path) == "table" then font_path = font_path[1] end + if not known_fonts[font_path] then + local name, encoded_font = get_font_style(font) + known_fonts[font_path] = name + -- FIXME: We might want to keep all of those and add them all at the start, + -- before concatenating the draw_data + table.insert(draw_data, encoded_font) + end + local fill_color = get_fill_color(color) + -- Split at spaces, because multiple spaces get removed by svg renderers + for s, e in string.gmatch(text, "()%S+()") do + local partial_text = string.sub(text, s, e - 1) + partial_text = partial_text:gsub("%]%]>", "]]]]>") -- escape eventual CDATA end token in the text + partial_text = partial_text:gsub("%]", "]]>] + +]=], x + offset, math.floor(y + font:get_height() * 0.8), known_fonts[font_path], + math.floor(font:get_size()), fill_color, partial_text)) + end + end + return old_draw_text(font, text, x, y, color, ...) +end + +local old_set_clip_rect = renderer.set_clip_rect +function renderer.set_clip_rect(x, y, width, height, ...) + if screenshotting then + local _x, _y, _w, _h = rect_to_grid(x, y, width, height) + current_clip = string.format("%d_%d_%d_%d", _x, _y, _w, _h) + -- Close last clip + table.insert(draw_data, "") + -- Ideally we don't need this, but just use the ` + +]], current_clip, _x, _y, _w, _h)) + end + +-- table.insert(draw_data, string.format([[ +-- +-- ]], _x, _y, _w, _h, -_w)) + + table.insert(draw_data, string.format([[ + +]], current_clip)) + end + return old_set_clip_rect(x, y, width, height, ...) +end + +command.add(nil, { + ["screenshot:svg-screenshot"] = function() + start_screenshot = true + end +}) + +keymap.add({ + ["ctrl+f12"] = "screenshot:svg-screenshot" +}) -- cgit v1.2.3