--[[-- Pictures for the decor widgets: vector art, pixel sprites and single glyphs. Vector art is one SVG per picture, drawn by KOReader's own SVG renderer (NanoSVG: paths, circles, ellipses, lines, rects and arcs) at exactly the size it is shown at, then kept in memory by content, so a page flip or a tile refresh blits it again instead of rendering. SVG is only ever a file to NanoSVG, so each render goes through one scratch file that is overwritten. Pixel sprites are runs of solid rectangles at a whole or half step of scale, so they stay sharp on every screen, in colour on a colour screen and in grey on any other. A glyph is one character rendered by FreeType at an exact pixel size and drawn as its ink alone, without the line box around it, so a large letter can fill a tile. ]] local Blitbuffer = require("ffi/blitbuffer") local Device = require("device") local Geom = require("ui/geometry") local RenderImage = require("ui/renderimage") local Widget = require("ui/widget/widget") local bit = require("bit") local logger = require("logger") local Screen = Device.screen local BoardArt = {} -- ------------------------------------------------------------------ svg local CACHE_MAX = 32 local svg_cache, svg_order = {}, {} local scratch_path = nil local function scratchPath() if not scratch_path then local dir = require("datastorage"):getDataDir() .. "/cache/bookorbit" require("util").makePath(dir) scratch_path = dir .. "/board-art.svg" end return scratch_path end local function hashOf(str) local ok, sha2 = pcall(require, "ffi/sha2") if ok and sha2 and sha2.md5 then return sha2.md5(str) end return str end local function rendered(svg, w, h) local key = hashOf(svg) .. "|" .. w .. "x" .. h local hit = svg_cache[key] if hit then return hit end local path = scratchPath() local f = io.open(path, "w") if not f then return nil end f:write(svg) f:close() local ok, bb = pcall(RenderImage.renderSVGImageFile, RenderImage, path, w, h) if not ok or not bb then logger.warn("BookOrbit: board art did not render:", ok and "no image" or bb) return nil end hit = { bb = bb } svg_cache[key] = hit table.insert(svg_order, key) while #svg_order > CACHE_MAX do local old = table.remove(svg_order, 1) local entry = svg_cache[old] if entry and entry.bb then entry.bb:free() end svg_cache[old] = nil end return hit end function BoardArt.clearCache() for _key, entry in pairs(svg_cache) do if entry.bb then entry.bb:free() end end svg_cache, svg_order = {}, {} end local ArtWidget = Widget:extend{ svg = nil } function ArtWidget:getSize() return self.dimen end function ArtWidget:paintTo(bb, x, y) self.dimen.x, self.dimen.y = x, y local img = rendered(self.svg, self.dimen.w, self.dimen.h) if not img then return end -- NanoSVG hands back straight alpha. if Screen.sw_dithering then bb:ditheralphablitFrom(img.bb, x, y, 0, 0, self.dimen.w, self.dimen.h) else bb:alphablitFrom(img.bb, x, y, 0, 0, self.dimen.w, self.dimen.h) end end -- A picture of exactly w x h pixels from SVG markup drawn in pixel units. function BoardArt.svg(w, h, body) w, h = math.max(1, math.floor(w + 0.5)), math.max(1, math.floor(h + 0.5)) local svg = '' .. body .. '' return ArtWidget:new{ svg = svg, dimen = Geom:new{ w = w, h = h } } end -- Numbers in SVG source, at two decimals. function BoardArt.n(v) local s = string.format("%.2f", v) s = s:gsub("0+$", ""):gsub("%.$", "") if s == "-0" then return "0" end return s end -- --------------------------------------------------------------- sprites -- Where cell i starts at scale k. Whole pixels, so a half step (1.5x) -- alternates one- and two-pixel cells instead of blurring them. local function edge(i, k) return math.floor(i * k + 1e-6) end BoardArt.spriteEdge = edge -- Rows become runs of one character once; a repaint only fills rectangles. local runs_of = setmetatable({}, { __mode = "k" }) local function runs(rows) local out = runs_of[rows] if out then return out end out = {} for row_index, line in ipairs(rows) do local col, len = 1, #line while col <= len do local ch = line:sub(col, col) local run_end = col while run_end < len and line:sub(run_end + 1, run_end + 1) == ch do run_end = run_end + 1 end if ch ~= " " then table.insert(out, { row_index - 1, col - 1, run_end, ch }) end col = run_end + 1 end end runs_of[rows] = out return out end -- Each palette character's ink on this screen: its colour on a colour screen, -- its grey otherwise. Night mode inverts the whole screen, so the ink is -- inverted first and the pet keeps its own colours, as pictures do. local inks_of = setmetatable({}, { __mode = "k" }) local function inks(palette) local colour, night = Screen:isColorEnabled() and true or false, Screen.night_mode and true or false local hit = inks_of[palette] if hit and hit.colour == colour and hit.night == night then return hit.inks, colour end local out = {} for ch, entry in pairs(palette) do if colour then local rgb = entry[1] local r, g, b = bit.rshift(rgb, 16), bit.band(bit.rshift(rgb, 8), 0xFF), bit.band(rgb, 0xFF) if night then r, g, b = 255 - r, 255 - g, 255 - b end out[ch] = Blitbuffer.ColorRGB32(r, g, b, 0xFF) else out[ch] = Blitbuffer.Color8(night and 255 - entry[2] or entry[2]) end end inks_of[palette] = { colour = colour, night = night, inks = out } return out, colour end local SpriteWidget = Widget:extend{ rows = nil, palette = nil, k = 1 } function SpriteWidget:getSize() return self.dimen end function SpriteWidget:paintTo(bb, x, y) self.dimen.x, self.dimen.y = x, y local k = self.k local ink_of, colour = inks(self.palette) for _i, run in ipairs(runs(self.rows)) do local ink = ink_of[run[4]] if ink then local top, left = edge(run[1], k), edge(run[2], k) local w, h = edge(run[3], k) - left, edge(run[1] + 1, k) - top if colour then bb:paintRectRGB32(x + left, y + top, w, h, ink) else bb:paintRect(x + left, y + top, w, h, ink) end end end end -- A sprite of character rows at scale k (whole or half steps), inked from a -- palette of { colour, grey } per character (see bookorbit_pets). function BoardArt.sprite(rows, k, palette) return SpriteWidget:new{ rows = rows, k = k, palette = palette, dimen = Geom:new{ w = edge(#rows[1], k), h = edge(#rows, k) } } end -- ---------------------------------------------------------------- glyphs local font_paths = {} local function fontPath(file) if font_paths[file] ~= nil then return font_paths[file] or nil end local found = false local ok, FontList = pcall(require, "fontlist") if ok and FontList then local escaped = file:gsub("[%-%.]", "%%%0") for _i, path in ipairs(FontList:getFontList() or {}) do if path:find(escaped .. "$") then found = path break end end end font_paths[file] = found return found or nil end function BoardArt.hasFont(file) return fontPath(file) ~= nil end local glyph_cache, glyph_order = {}, {} local function renderGlyph(file, codepoint, size) local key = file .. "|" .. codepoint .. "|" .. size local hit = glyph_cache[key] if hit then return hit end local path = fontPath(file) if not path then return nil end local Freetype = require("ffi/freetype") local ok, ftsize = pcall(Freetype.newFaceSize, path, size) if not ok or not ftsize then return nil end -- A font without the character would draw its empty box instead. local has_ok, has = pcall(ftsize.hasGlyph, ftsize, codepoint) if not has_ok or not has then pcall(ftsize.done, ftsize) return nil end local rok, glyph = pcall(ftsize.renderGlyph, ftsize, codepoint) pcall(ftsize.done, ftsize) if not rok or not glyph or not glyph.bb then return nil end glyph_cache[key] = glyph table.insert(glyph_order, key) while #glyph_order > 16 do local old = table.remove(glyph_order, 1) local entry = glyph_cache[old] if entry and entry.bb then entry.bb:free() end glyph_cache[old] = nil end return glyph end -- Looks its glyph up again when painted: the cache may have let it go since. local GlyphWidget = Widget:extend{ file = nil, codepoint = nil, size = nil, color = nil } function GlyphWidget:getSize() return self.dimen end function GlyphWidget:paintTo(bb, x, y) self.dimen.x, self.dimen.y = x, y local g = renderGlyph(self.file, self.codepoint, self.size) if not g then return end local color = self.color or Blitbuffer.COLOR_BLACK -- The plain colour blit keeps only gray; an RGB colour takes the RGB one. if Blitbuffer.isColor8 and not Blitbuffer.isColor8(color) and bb.colorblitFromRGB32 then bb:colorblitFromRGB32(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color) else bb:colorblitFrom(g.bb, x, y, 0, 0, g.bb:getWidth(), g.bb:getHeight(), color) end end -- One character's bitmap at a pixel size: { bb, l, t }, or nil. Drawn by -- callers that clip it themselves (the odometer's turning wheel). Look it up -- again at paint time rather than keeping it. BoardArt.glyphBitmap = renderGlyph -- The ink of one character, as large as fits max_w x max_h. Nil when the -- font is not on this device or has no such character. function BoardArt.glyph(file, codepoint, max_w, max_h) if max_w < 1 or max_h < 1 then return nil end local probe_size = 200 local probe = renderGlyph(file, codepoint, probe_size) if not probe then return nil end local pw, ph = probe.bb:getWidth(), probe.bb:getHeight() if pw < 1 or ph < 1 then return nil end local size = math.max(4, math.floor(probe_size * math.min(max_w / pw, max_h / ph))) local glyph = renderGlyph(file, codepoint, size) -- Rounding in the outline can overshoot by a pixel or two. while glyph and size > 4 and (glyph.bb:getWidth() > max_w or glyph.bb:getHeight() > max_h) do size = size - 1 glyph = renderGlyph(file, codepoint, size) end if not glyph then return nil end return GlyphWidget:new{ file = file, codepoint = codepoint, size = size, dimen = Geom:new{ w = glyph.bb:getWidth(), h = glyph.bb:getHeight() } } end return BoardArt