2
0
mirror of https://github.com/koreader/koreader synced 2024-11-10 01:10:34 +00:00
koreader/frontend/apps/reader/modules/readerthumbnail.lua
poire-z bc16b32395 Add Book map and Page browser features
- Book map: shows a map of content, including TOC,
  boomarks, read pages, non-linear flows...
- Page browser: shows thumbnails of pages.

- ReaderThumbnail: new Reader module that provides
  a service for generating thumbnails of book pages.
  It makes available these 2 new fullscreen widgets.
- ReaderBookmark, ReaderLink, Statistics: add methods
  to return new views of bookmarks, previous locations
  and read pages, that are needed by BookMapWidget.
- ReaderToc: compute TOC max_depth.
- ReaderBookmark, ReaderHighlight: send events on
  bookmark add/update/remove so thumbnails of the
  pages impacted can be trashed.
2022-01-04 21:59:37 +01:00

508 lines
20 KiB
Lua

local Blitbuffer = require("ffi/blitbuffer")
local Cache = require("cache")
local Device = require("device")
local Geom = require("ui/geometry")
local InputContainer = require("ui/widget/container/inputcontainer")
local Persist = require("persist")
local RenderImage = require("ui/renderimage")
local TileCacheItem = require("document/tilecacheitem")
local UIManager = require("ui/uimanager")
local Screen = Device.screen
local ffiutil = require("ffi/util")
local logger = require("logger")
local util = require("util")
local _ = require("gettext")
-- This ReaderThumbnail module provides a service for generating thumbnails
-- of book pages.
-- It handles launching via the menu or Dispatcher/Gestures two fullscreen
-- widgets related to showing pages and thumbnails that will make use of
-- its services: BookMap and PageBrowser.
local ReaderThumbnail = InputContainer:new{}
function ReaderThumbnail:init()
if not Device:isTouchDevice() then
-- The BookMap and PageBrowser widgets depend too much on gestures,
-- making them work with keys would be hard and very limited, so
-- just don't make them available.
return
end
self.ui.menu:registerToMainMenu(self)
-- Use LuaJIT fast buffer.encode()/decode() when serializing BlitBuffer
-- for exchange between subprocess and parent.
self.codec = Persist.getCodec("luajit")
self:setupColor()
self.thumbnails_requests = {}
self.current_target_size_tag = nil
-- Ensure no multiple executions, and nextTick() the scheduleIn()
-- so we get a chance to process events in-between refreshes and
-- this can be interrupted (otherwise, something scheduleIn(0.1),
-- if a screen refresh is then done and taking longer than 0.1s,
-- would be executed immediately, without emptying any input event).
local schedule_step = 0
self._ensureTileGeneration_action = function(restart)
if restart then
UIManager:unschedule(self._ensureTileGeneration_action)
schedule_step = 0
end
if schedule_step == 0 then
schedule_step = 1
UIManager:nextTick(self._ensureTileGeneration_action)
elseif schedule_step == 1 then
schedule_step = 2
UIManager:scheduleIn(0.1, self._ensureTileGeneration_action)
else
schedule_step = 0
self:ensureTileGeneration()
end
end
end
function ReaderThumbnail:addToMainMenu(menu_items)
menu_items.book_map = {
text = _("Book map"),
callback = function()
self:onShowBookMap()
end,
}
menu_items.page_browser = {
text = _("Page browser"),
callback = function()
self:onShowPageBrowser()
end,
}
end
function ReaderThumbnail:onShowBookMap()
local BookMapWidget = require("ui/widget/bookmapwidget")
UIManager:show(BookMapWidget:new{
ui = self.ui,
})
return true
end
function ReaderThumbnail:onShowPageBrowser()
local PageBrowserWidget = require("ui/widget/pagebrowserwidget")
UIManager:show(PageBrowserWidget:new{
ui = self.ui,
})
return true
end
-- This is made a module local so we can keep track of pids
-- to collect across multiple Reader instantiations
local pids_to_collect = {}
function ReaderThumbnail:collectPids()
if #pids_to_collect == 0 then
return false
end
for i=#pids_to_collect, 1, -1 do
if ffiutil.isSubProcessDone(pids_to_collect[i]) then
table.remove(pids_to_collect, i)
end
end
return #pids_to_collect > 0
end
function ReaderThumbnail:setupColor()
self.bb_type = self.ui.document.render_color and self.ui.document.color_bb_type or Blitbuffer.TYPE_BB8
end
function ReaderThumbnail:setupCache()
if not self.tile_cache then
-- We want to allow browsing at least N pages worth of thumbnails
-- without cache trashing. A little more than N pages (because inter
-- thumbnail margins) will fit in N * screen size.
-- With N=5, this should use from 5 to 15 Mb on a classic eInk device.
local N = 5
local max_bytes = math.ceil(N * Screen:getWidth() * Screen:getHeight() * Blitbuffer.TYPE_TO_BPP[self.bb_type] / 8)
-- We don't really care about limiting any number of slots, so allow
-- for at least 5 pages of 10x10 tiles
local avg_itemsize = math.ceil(max_bytes / 500)
self.tile_cache = Cache:new{
size = max_bytes,
avg_itemsize = avg_itemsize, -- will make slots=500
enable_eviction_cb = true,
}
end
end
function ReaderThumbnail:logCacheSize()
logger.info(string.format("Thumbnails cache: %d/%d (%s/%s)",
self.tile_cache.cache.used_slots(),
self.tile_cache.slots,
util.getFriendlySize(self.tile_cache.cache.used_size()),
util.getFriendlySize(self.tile_cache.size)))
end
function ReaderThumbnail:resetCache()
if self.tile_cache then
self.tile_cache:clear()
self.tile_cache = nil
end
end
function ReaderThumbnail:removeFromCache(hash_subs, remove_only_non_matching)
-- Remove from cache all tiles matching any hash from hash_subs.
-- IF only_non_matching=true, keep those matching and remove all others.
if not self.tile_cache then
return
end
if type(hash_subs) ~= "table" then
hash_subs = { hash_subs }
end
local nb_removed, size_removed = 0, 0
local to_remove = {}
for thash, tile in self.tile_cache.cache:pairs() do
local remove = remove_only_non_matching
for _, h in ipairs(hash_subs) do
if thash:find(h, 1, true) then -- plain text match (no pattern needed)
remove = not remove
break
end
end
if remove then
to_remove[thash] = true
nb_removed = nb_removed + 1
size_removed = size_removed + tile.size
end
end
for thash, _ in pairs(to_remove) do
self.tile_cache.cache:delete(thash)
logger.dbg("removed cached thumbnail", thash)
end
return nb_removed, size_removed
end
function ReaderThumbnail:resetCachedPagesForBookmarks(...)
-- Multiple bookmarks may be provided
local start_page, end_page
for _, bm in ipairs({...}) do
if self.ui.rolling then
-- Look at all properties that may be xpointers
for _, k in ipairs({"page", "pos0", "pos1"}) do
if bm[k] and type(bm[k]) == "string" then
local p = self.ui.document:getPageFromXPointer(bm[k])
if not start_page or p < start_page then
start_page = p
end
if not end_page or p > end_page then
end_page = p
end
end
end
else
if bm.page and type(bm.page) == "number" then
local p = bm.page
if not start_page or p < start_page then
start_page = p
end
if not end_page or p > end_page then
end_page = p
end
end
end
end
if start_page and end_page then
local hash_subs_to_remove = {}
for p=start_page, end_page do
table.insert(hash_subs_to_remove, string.format("p%d-", p))
end
self:removeFromCache(hash_subs_to_remove)
end
end
function ReaderThumbnail:tidyCache()
if self.current_target_size_tag then
-- Remove all thumbnails generated for an older target size
self:removeFromCache("-"..self.current_target_size_tag, true)
end
end
function ReaderThumbnail:cancelPageThumbnailRequests(batch_id)
if batch_id then
self.thumbnails_requests[batch_id] = nil
else
self.thumbnails_requests = {}
end
if self.req_in_progress and (not batch_id or self.req_in_progress.batch_id == batch_id) then
-- Kill any reference to the module cancelling it
self.req_in_progress.when_generated_callback = nil
end
end
function ReaderThumbnail:getPageThumbnail(page, width, height, batch_id, when_generated_callback)
self:setupCache()
self.current_target_size_tag = string.format("w%d_h%d", width, height)
local hash = string.format("p%d-%s", page, self.current_target_size_tag)
local tile = self.tile_cache and self.tile_cache:check(hash)
if tile then
-- Cached: call callback and we're done.
when_generated_callback(tile, batch_id, false)
return false -- not delayed
end
if not self.thumbnails_requests[batch_id] then
self.thumbnails_requests[batch_id] = {}
end
table.insert(self.thumbnails_requests[batch_id], {
batch_id = batch_id,
hash = hash,
page = page,
width = width,
height = height,
when_generated_callback = when_generated_callback,
})
-- Start tile generation, avoid multiple ones
self._ensureTileGeneration_action(true)
return true -- delayed
end
function ReaderThumbnail:ensureTileGeneration()
local has_pids_still_to_collect = self:collectPids()
local still_in_progress = false
if self.req_in_progress then
local pid_still_to_collect
still_in_progress, pid_still_to_collect = self:checkTileGeneration(self.req_in_progress)
if pid_still_to_collect then
has_pids_still_to_collect = true
end
end
if not still_in_progress then
self.req_in_progress = nil
while true do
local req_id, requests = next(self.thumbnails_requests)
if not req_id then -- no more requests
break
end
local req = table.remove(requests, 1)
if #requests == 0 then
self.thumbnails_requests[req_id] = nil
end
if req.when_generated_callback then -- not cancelled since queued
-- It might have been generated and cached by a previous batch
local tile = self.tile_cache and self.tile_cache:check(req.hash)
if tile then
req.when_generated_callback(tile, req.batch_id, true)
else
if self:startTileGeneration(req) then
self.req_in_progress = req
break
else
-- Failure starting it: let requester know in case it cares, and forget it
req.when_generated_callback(nil, req.batch_id, true)
end
end
end
end
end
if self.req_in_progress or has_pids_still_to_collect or next(self.thumbnails_requests) then
self._ensureTileGeneration_action()
end
end
function ReaderThumbnail:startTileGeneration(request)
local pid, parent_read_fd = ffiutil.runInSubProcess(function(pid, child_write_fd)
-- Get page image as if drawn on the screen
local bb = self:_getPageImage(request.page)
-- Scale it to fit in the requested size
local scale_factor = math.min(request.width / bb:getWidth(), request.height / bb:getHeight())
local target_w = math.floor(bb:getWidth() * scale_factor)
local target_h = math.floor(bb:getHeight() * scale_factor)
-- local TimeVal = require("ui/timeval")
-- local start_tv = TimeVal:now()
local tile = TileCacheItem:new{
bb = RenderImage:scaleBlitBuffer(bb, target_w, target_h, true),
pageno = request.page,
}
tile.size = tonumber(tile.bb.stride) * tile.bb.h
-- logger.info("tile size", tile.bb.w, tile.bb.h, "=>", tile.size)
-- logger.info(string.format(" scaling took %.3f seconds, %d bpp", TimeVal:getDuration(start_tv), tile.bb:getBpp()))
-- bb:free() -- no need to spend time freeing, we're dying soon anyway!
ffiutil.writeToFD(child_write_fd, self.codec.serialize(tile:totable()), true)
end, true) -- with_pipe = true
if pid then
-- Store these in the request object itself
request.pid = pid
request.parent_read_fd = parent_read_fd
return true
end
logger.warn("PageBrowserWidget thumbnail start failure:", parent_read_fd)
return false
end
function ReaderThumbnail:checkTileGeneration(request)
local pid, parent_read_fd = request.pid, request.parent_read_fd
local stuff_to_read = ffiutil.getNonBlockingReadSize(parent_read_fd) ~= 0
local subprocess_done = ffiutil.isSubProcessDone(pid)
logger.dbg("subprocess_done:", subprocess_done, " stuff_to_read:", stuff_to_read)
if stuff_to_read then
-- local TimeVal = require("ui/timeval")
-- local start_tv = TimeVal:now()
local result, err = self.codec.deserialize(ffiutil.readAllFromFD(parent_read_fd))
if result then
local tile = TileCacheItem:new{}
tile:fromtable(result)
if self.tile_cache then
self.tile_cache:insert(request.hash, tile)
end
if request.when_generated_callback then -- not cancelled
request.when_generated_callback(tile, request.batch_id, true)
end
else
logger.warn("PageBrowserWidget thumbnail deserialize() failed:", err)
if request.when_generated_callback then -- not cancelled
request.when_generated_callback(nil, request.batch_id, true)
end
end
-- logger.info(string.format(" parsing result from subprocess took %.3f seconds", TimeVal:getDuration(start_tv)))
if not subprocess_done then
table.insert(pids_to_collect, pid)
return false, true
end
return false
elseif subprocess_done then
-- subprocess_done: process exited with no output
ffiutil.readAllFromFD(parent_read_fd) -- close our fd
return false
end
logger.dbg("process not yet done, will check again soon")
return true
end
function ReaderThumbnail:_getPageImage(page)
-- This is run in a subprocess: we can tweak all document settings
-- to get an adequate image of the page.
-- No need to worry about the final state of things: this subprocess
-- will die just after drawing the page, and all will be forgotten,
-- without impact on the parent process.
-- Be sure to limit our impact on the disk-saved book state
self.ui.saveSettings = function() end -- Be sure nothing is flushed
self.ui.statistics = nil -- Don't update statistics for pages we visit
-- By default, our target page size is the current screen size
local target_w, target_h = Screen:getWidth(), Screen:getHeight()
-- This was all mostly chosen by experimenting.
-- Be sure to call the innermost methods enough to get what we want, and
-- not upper event handlers that may trigger other unneeded events and stuff.
-- Especially, be sure to not trigger any paint on the screen buffer, or
-- any processing of input events.
-- No need to worry about UIManager:scheduleIn() or :nextTick(), as
-- we will die before the callback gets a chance to be run.
-- Common to ReaderRolling and ReaderPaging
self.ui.view.footer_visible = false -- We want no footer on page image
if self.ui.view.highlight.lighten_factor < 0.3 then
self.ui.view.highlight.lighten_factor = 0.3 -- make lighten highlight a bit darker
end
if self.ui.rolling then
-- CRE documents: pages all have the aspect ratio of our screen (alt top status bar
-- will be croped out after drawing), we will show them just as rendered.
self.ui.view:onSetViewMode("page") -- Get out of scroll mode
if self.ui.font.gamma_index < 30 then -- Increase font gamma (if not already increased),
self.ui.document:setGammaIndex(30) -- as downscaling will make text grayer
end
self.ui.document:setImageScaling(false) -- No need for smooth scaling as all will be downscaled
self.ui.document:setNightmodeImages(false) -- We don't invert page images even if nightmode set: keep images as-is
self.ui.view.state.page = page -- Be on requested page
self.ui.document:gotoPage(page) -- Current xpointer needs to be updated for some of what follows
self.ui.bookmark:onPageUpdate(page) -- Update dogear state for this page
self.ui.pagemap:onPageUpdate(page) -- Update pagemap labels for this page
end
if self.ui.paging then
-- With PDF/DJVU/Pics, we will show the native page (no reflow, no crop, no zoom
-- to columns...). This makes thumbnail generation faster, and will allow the user
-- to get an overview of the book native pages to better decide which option will
-- be best to use for the book.
-- We also want to get a thumbnail with the aspect ratio of the native page
-- (so we don't get a native landscape page smallish and centered with blank above
-- and below in a portrait thumbnail, if the screen is in portrait mode).
self.ui.view.hinting = false -- Disable hinting
self.ui.view.page_scroll = false -- Get out of scroll mode
self.ui.view.flipping_visible = false -- No page flipping icon
self.ui.document.configurable.text_wrap = false -- Get out of reflow mode
self.ui.document.configurable.trim_page = 3 -- Page crop: none
-- self.ui.document.configurable.trim_page = 1 -- Page crop: auto (very slower)
self.ui.document.configurable.auto_straighten = 0 -- No auto straighten
-- We can let dewatermark if the user has enabled it, it helps
-- limiting annoying eInk refreshes of light gray areas
-- self.ui.document.configurable.page_opt = 0 -- No dewatermark
-- We won't touch the contrast (to try making text less gray), as it applies on
-- images that could get too dark.
-- Get native page dimensions, and update our target bb dimensions so it gets the
-- same aspect ratio (we don't use native dimensions as is, as they may get huge)
local dimen = self.ui.document:getPageDimensions(page, 1, 0)
local scale_factor = math.min(target_w / dimen.w, target_h / dimen.h)
target_w = math.floor(dimen.w * scale_factor)
target_h = math.floor(dimen.h * scale_factor)
dimen = Geom:new{ w=target_w, h=target_h }
-- logger.info("getPageImage", page, dimen, "=>", target_w, target_h, scale_factor)
-- This seems to do it all well:
-- local Event = require("ui/event")
-- self.ui:handleEvent(Event:new("SetDimensions", dimen))
-- self.ui.view.dogear[1].dimen.w = dimen.w -- (hack... its code uses the Screen width)
-- self.ui:handleEvent(Event:new("PageUpdate", page))
-- self.ui:handleEvent(Event:new("SetZoomMode", "page"))
-- Trying to do as little as needed, knowing the internals:
self.ui.view:onSetDimensions(dimen)
self.ui.view:onBBoxUpdate(nil) -- drop any bbox, draw native page
self.ui.view.state.page = page
self.ui.view.state.zoom = scale_factor
self.ui.view.state.rotation = 0
self.ui.view:recalculate()
self.ui.view.dogear[1].dimen.w = dimen.w -- (hack... its code uses the Screen width)
self.ui.bookmark:onPageUpdate(page) -- Update dogear state for this page
end
-- Draw the page on a new BB with the targetted size
local bb = Blitbuffer.new(target_w, target_h, self.bb_type)
self.ui.view:paintTo(bb, 0, 0)
if self.ui.rolling then
-- Crop out the top alt status bar if enabled
local header_height = self.ui.document:getHeaderHeight()
if header_height > 0 then
bb = bb:viewport(0, header_height, bb.w, bb.h - header_height)
end
end
return bb
end
function ReaderThumbnail:onCloseDocument()
self:cancelPageThumbnailRequests()
if self.tile_cache then
self:logCacheSize()
self.tile_cache:clear()
self.tile_cache = nil
end
end
function ReaderThumbnail:onColorRenderingUpdate()
self:setupColor()
self:resetCache()
end
-- CRE: emitted after a re-rendering
ReaderThumbnail.onTocReset = ReaderThumbnail.resetCache
-- Emitted When adding/removing/updating bookmarks and highlights
ReaderThumbnail.onBookmarkAdded = ReaderThumbnail.resetCachedPagesForBookmarks
ReaderThumbnail.onBookmarkRemoved = ReaderThumbnail.resetCachedPagesForBookmarks
ReaderThumbnail.onBookmarkUpdated = ReaderThumbnail.resetCachedPagesForBookmarks
return ReaderThumbnail