koreader icon indicating copy to clipboard operation
koreader copied to clipboard

Modifiy Fit to full + zoom to page : stretch so it uses the whole screen

Open Hei02 opened this issue 4 weeks ago • 2 comments

Does your feature request involve difficulty completing a task?

  • I Open a CBZ jpeg/webP with MUpdf, and configure : zoom to "page" or "content" (same effect) / fit to "full" or "width". But it keeps the picture ratio. There seems to be no option to stretch the picture to fill the whole screen.

Actual configuration :

  • Fit to "full" = Full screen with picture original aspect ratio
  • Fit to "width" + zoom to "content" = Full screen with picture original ratio (need scroll).
  • Fit to "width" + zoom to "page" = Full screen with picture original ratio (need scroll).

I would like to have choice to keep ratio or stretch the image : to get a real fullscreen without scrolling on the same picture.

Describe the solution you'd like

  • Modify zoom mode option : "Fit to width" + "zoom to page" = stretch to the screen (without scroll)

Describe alternatives you've considered

Code edit with Gemini AI to get a stretch fit :

  • mupdf.lua
  • readerpaging.lua

Modified configuration :

  • Fit to "full" = Full screen with picture original aspect ratio
  • Fit to "width" + zoom to "content" = Full screen with picture stretched (no scroll).
  • Fit to "width" + zoom to "page" = Full screen with picture stretched (no scroll).

I would like an option to chose between :

  • Fit to "full" = Full screen with picture original aspect ratio
  • Fit to "width" + zoom to "content" = Full screen with picture original ratio (need scroll).
  • Fit to "width" + zoom to "page" = Full screen with picture stretched (no scroll).

Additional context

  • I use a Kobo Libra Colour (7" screen) : 1264x1680.
  • Attached below, modifed configuration :
  1. mupdf.lua
  2. readerpaging.lua

Hei02 avatar Dec 02 '25 19:12 Hei02

mupdf.lua

--[[--
MuPDF API

This is a FFI wrapper for what was a Lua-based API in the past
Some kind of C wrapper is needed for muPDF since muPDF uses
a setjmp/longjmp based approach to error/exception handling.
That's one of the very few things we can't deal with using
LuaJIT's FFI.

@module ffi.mupdf
--]]

local ffi = require("ffi")
local Device = require("device") -- Ajout Gemini pour récupérer la taille écran
local Screen = Device.screen     -- Ajout Gemini
require("ffi/mupdf_h")
require("ffi/posix_h") -- for malloc

local BlitBuffer = require("ffi/blitbuffer")

local C = ffi.C
local W = ffi.loadlib("wrap-mupdf")
local M = W

--- @fixme: Don't make cache_size too low, at least not until we bump MµPDF,
---         as there's a pernicious issue that corrupts its store cache on overcommit on old versions.
---         c.f., https://github.com/koreader/koreader/issues/7627
---         (FZ_STORE_DEFAULT is 256MB, we used to set it to 8MB).
---         And when we bump MµPDF, it'll likely have *more* stuff to store in there,
--          so, don't make that too low, period ;).
--          NOTE: Revisit when we bump MµPDF by doing a few tests with the tracing memory allocators,
--                as even 32MB is likely to be too conservative.
local mupdf = {
    debug_memory = false,
    cache_size = 32*1024*1024,
}
-- this cannot get adapted by the cdecl file because it is a
-- string constant. Must match the actual mupdf API:
local FZ_VERSION = "1.26.10"

local document_mt = { __index = {} }
local page_mt = { __index = {} }

mupdf.debug = function() --[[ no debugging by default ]] end

local function drop_context(ctx)
    local refcount = ffi.cast("int *", M.fz_user_context(ctx))
    refcount[0] = refcount[0] - 1
    if refcount[0] == 0 then
        M.fz_drop_context(ctx)
        C.free(refcount)
    end
end

local function keep_context(ctx)
    local refcount = ffi.cast("int *", M.fz_user_context(ctx))
    refcount[0] = refcount[0] + 1
    return ctx
end

local save_ctx = setmetatable({}, {__mode="kv"})

-- provides an fz_context for mupdf
local function context()
    local ctx = save_ctx[1]
    if ctx then return ctx end

    ctx = M.fz_new_context_imp(
        mupdf.debug_memory and W.mupdf_get_my_alloc_context() or nil,
        nil,
        mupdf.cache_size, FZ_VERSION)

    if ctx == nil then
        error("cannot create fz_context for MuPDF")
    end

    local refcount = ffi.cast("int *", C.malloc(ffi.sizeof("int")))
    M.fz_set_user_context(ctx, refcount)
    refcount[0] = 1

    -- ctx is a cdata<fz_context *>, attach a finalizer to it to release ressources on garbage collection
    ctx = ffi.gc(ctx, drop_context)

    M.fz_install_external_font_funcs(ctx)
    M.fz_register_document_handlers(ctx)

    save_ctx[1] = ctx
    return ctx
end

-- a wrapper for mupdf exception error messages
local function merror(ctx, message)
    error(string.format("%s: %s (%d)", message,
        ffi.string(W.mupdf_error_message(ctx)),
        W.mupdf_error_code(ctx)))
end

local function drop_document(ctx, doc)
    -- Clear the cdata finalizer to avoid a double-free
    ffi.gc(doc, nil)
    M.fz_drop_document(ctx, doc)
    drop_context(ctx)
end

local function drop_page(ctx, page)
    -- Clear the cdata finalizer to avoid a double-free
    ffi.gc(page, nil)
    M.fz_drop_page(ctx, page)
    drop_context(ctx)
end

--[[--
Opens a document.
--]]
function mupdf.openDocument(filename)
    local ctx = context()
    local mupdf_doc = {
        doc = W.mupdf_open_document(ctx, filename),
        filename = filename,
    }

    if mupdf_doc.doc == nil then
        merror(ctx, "MuPDF cannot open file.")
    end

    -- doc is a cdata<fz_document *>, attach a finalizer to it to release ressources on garbage collection
    mupdf_doc.doc = ffi.gc(mupdf_doc.doc, function(doc) drop_document(ctx, doc) end)
    mupdf_doc.ctx = keep_context(ctx)

    setmetatable(mupdf_doc, document_mt)

    if mupdf_doc:getPages() <= 0 then
        merror(ctx, "MuPDF found no pages in file.")
    end

    return mupdf_doc
end

function mupdf.openDocumentFromText(text, magic, html_resource_directory)
    local ctx = context()
    local stream = W.mupdf_open_memory(ctx, ffi.cast("const unsigned char*", text), #text)

    local archive = nil
    if html_resource_directory ~= nil then
        archive = W.mupdf_open_directory(ctx, html_resource_directory)
    end

    local mupdf_doc = {
        doc = W.mupdf_open_document_with_stream_and_dir(ctx, magic, stream, archive),
    }
    W.mupdf_drop_stream(ctx, stream)

    if archive ~= nil then
        W.mupdf_drop_archive(ctx, archive)
    end

    if mupdf_doc.doc == nil then
        merror(ctx, "MuPDF cannot open document from text")
    end

    -- doc is a cdata<fz_document *>, attach a finalizer to it to release ressources on garbage collection
    mupdf_doc.doc = ffi.gc(mupdf_doc.doc, function(doc) drop_document(ctx, doc) end)
    mupdf_doc.ctx = keep_context(ctx)

    setmetatable(mupdf_doc, document_mt)

    return mupdf_doc
end

-- Document functions:

--[[
close the document

this is done automatically by the garbage collector but can be
triggered explicitly
--]]
function document_mt.__index:close()
    if self.doc ~= nil then
        drop_document(self.ctx, self.doc)
        self.doc = nil
        self.ctx = nil
    end
end

--[[
check if the document needs a password for access
--]]
function document_mt.__index:needsPassword()
    return M.fz_needs_password(self.ctx, self.doc) ~= 0
end

--[[
try to authenticate with a password
--]]
function document_mt.__index:authenticatePassword(password)
    if M.fz_authenticate_password(self.ctx, self.doc, password) == 0 then
        return false
    end
    return true
end

--[[
read number of pages in document
--]]
function document_mt.__index:getPages()
    -- cache number of pages
    if self.number_of_pages then return self.number_of_pages end

    local pages = W.mupdf_count_pages(self.ctx, self.doc)
    if pages == -1 then
        merror(self.ctx, "cannot access page tree")
    end

    self.number_of_pages = pages

    return pages
end

function document_mt.__index:isDocumentReflowable()
    if self.is_reflowable then return self.is_reflowable end
    self.is_reflowable = M.fz_is_document_reflowable(self.ctx, self.doc) == 1
    return self.is_reflowable
end

function document_mt.__index:layoutDocument(width, height, em)
    -- Reset the cache.
    self.number_of_pages = nil

    W.mupdf_layout_document(self.ctx, self.doc, width, height, em)
end

function document_mt.__index:setColorRendering(color)
    self.color = color
end

local function toc_walker(toc, outline, depth)
    while outline ~= nil do
        table.insert(toc, {
            page = outline.page.page + 1,
            title = ffi.string(outline.title),
            depth = depth,
        })
        if outline.down then
            toc_walker(toc, outline.down, depth+1)
        end
        outline = outline.next
    end
end

--[[
read table of contents (ToC)

Returns a table like this:
{
    {page=12, depth=1, title="chapter1"},
    {page=54, depth=1, title="chapter2"},
}

Returns an empty table when there is no ToC
--]]
function document_mt.__index:getToc()
    local toc = {}
    local outline = W.mupdf_load_outline(self.ctx, self.doc)
    if outline ~= nil then
        toc_walker(toc, outline, 1)
        M.fz_drop_outline(self.ctx, outline)
    end
    return toc
end

--[[
open a page, return page object
--]]
function document_mt.__index:openPage(number)
    local ctx = self.ctx
    local mupdf_page = {
        page = W.mupdf_load_page(ctx, self.doc, number-1),
        number = number,
        doc = self,
    }

    if mupdf_page.page == nil then
        merror(ctx, "cannot open page #" .. number)
    end

    -- page is a cdata<fz_page *>, attach a finalizer to it to release ressources on garbage collection
    mupdf_page.page = ffi.gc(mupdf_page.page, function(page) drop_page(ctx, page) end)
    mupdf_page.ctx = keep_context(ctx)

    setmetatable(mupdf_page, page_mt)

    return mupdf_page
end

local function getMetadataInfo(ctx, doc, info)
    local bufsize = 255
    local buf = ffi.new("char[?]", bufsize)
    -- `fz_lookup_metadata` return the number of bytes needed
    -- to store the string, **including** the null terminator.
    local res = M.fz_lookup_metadata(ctx, doc, info, buf, bufsize)
    if res > bufsize then
        -- Buffer was too small.
        bufsize = res
        buf = ffi.new("char[?]", bufsize)
        res = M.fz_lookup_metadata(ctx, doc, info, buf, bufsize)
    end
    if res > 1 then
        -- Note: strip the null terminator.
        return ffi.string(buf, res - 1)
    end
    -- Empty string or error (-1).
    return ""
end


--[[
Get metadata, return object
--]]
function document_mt.__index:getMetadata()
    local metadata = {
        title = getMetadataInfo(self.ctx, self.doc, "info:Title"),
        author = getMetadataInfo(self.ctx, self.doc, "info:Author"),
        subject = getMetadataInfo(self.ctx, self.doc, "info:Subject"),
        keywords = getMetadataInfo(self.ctx, self.doc, "info:Keywords"),
        creator = getMetadataInfo(self.ctx, self.doc, "info:Creator"),
        producer = getMetadataInfo(self.ctx, self.doc, "info:Producer"),
        creationDate = getMetadataInfo(self.ctx, self.doc, "info:CreationDate"),
        modDate = getMetadataInfo(self.ctx, self.doc, "info:ModDate")
    }

    return metadata
end

--[[
return currently claimed memory by MuPDF

This will return sensible values only when the debug_memory flag is set
--]]
function document_mt.__index:getCacheSize()
    if mupdf.debug_memory then
        return W.mupdf_get_cache_size()
    else
        return 0
    end
end

function document_mt.__index:cleanCache()
    -- NOP, just for API compatibility
end

--[[
write the document to a new file
--]]
function document_mt.__index:writeDocument(filename)
    local opts = ffi.new("pdf_write_options[1]")
    opts[0].do_incremental = (filename == self.filename ) and 1 or 0
    opts[0].do_ascii = 0
    opts[0].do_garbage = 0
    opts[0].do_linear = 0
    local ok = W.mupdf_pdf_save_document(self.ctx, ffi.cast("pdf_document*", self.doc), filename, opts)
    if ok == nil then merror(self.ctx, "could not write document") end
end


-- Page functions:

--[[
explicitly close the page object

this is done implicitly by garbage collection, too.
--]]
function page_mt.__index:close()
    if self.page ~= nil then
        drop_page(self.ctx, self.page)
        self.page = nil
        self.ctx = nil
    end
end

--[[
calculate page size after applying DrawContext
--]]
function page_mt.__index:getSize(draw_context)
    local bounds = ffi.new("fz_rect")
    local ctm = ffi.new("fz_matrix")

    local zx, zy
    if type(draw_context.zoom) == "table" then
        zx = draw_context.zoom.x
        zy = draw_context.zoom.y
    else
        zx = draw_context.zoom
        zy = draw_context.zoom
    end
    W.mupdf_fz_scale(ctm, zx, zy)
    W.mupdf_fz_pre_rotate(ctm, draw_context.rotate)

    W.mupdf_fz_bound_page(self.ctx, self.page, bounds)
    W.mupdf_fz_transform_rect(bounds, ctm)

    -- NOTE: fz_bound_page returns an internal representation computed @ 72dpi...
    --       It is often superbly mysterious, even for images,
    --       so we do *NOT* want to round it right now,
    --       as it would introduce rounding errors much too early in the pipeline...
    -- NOTE: ReaderZooming uses it to compute the scale factor, where accuracy matters!
    -- NOTE: This is also used in conjunction with getUsedBBox,
    --       which also returns precise, floating point rectangles!
    --[[
    M.fz_round_rect(bbox, bounds)
    return bbox[0].x1-bbox[0].x0, bbox[0].y1-bbox[0].y0
    --]]

    return bounds.x1 - bounds.x0, bounds.y1 - bounds.y0
end

--[[
check which part of the page actually contains content
--]]
function page_mt.__index:getUsedBBox()
    local result = ffi.new("fz_rect")

    local dev = W.mupdf_new_bbox_device(self.ctx, result)
    if dev == nil then merror(self.ctx, "cannot allocate bbox_device") end
    local ok = W.mupdf_run_page(self.ctx, self.page, dev, M.fz_identity, nil)
    M.fz_close_device(self.ctx, dev)
    M.fz_drop_device(self.ctx, dev)
    if ok == nil then merror(self.ctx, "cannot calculate bbox for page") end

    return result.x0, result.y0, result.x1, result.y1
end

local B = string.byte
local function is_unicode_wspace(c)
    return c == 9 or --  TAB
        c == 0x0a or --  HT
        c == 0x0b or --  LF
        c == 0x0c or --  VT
        c == 0x0d or --  FF
        c == 0x20 or --  CR
        c == 0x85 or --  NEL
        c == 0xA0 or --  No break space
        c == 0x1680 or --  Ogham space mark
        c == 0x180E or --  Mongolian Vowel Separator
        c == 0x2000 or --  En quad
        c == 0x2001 or --  Em quad
        c == 0x2002 or --  En space
        c == 0x2003 or --  Em space
        c == 0x2004 or --  Three-per-Em space
        c == 0x2005 or --  Four-per-Em space
        c == 0x2006 or --  Five-per-Em space
        c == 0x2007 or --  Figure space
        c == 0x2008 or --  Punctuation space
        c == 0x2009 or --  Thin space
        c == 0x200A or --  Hair space
        c == 0x2028 or --  Line separator
        c == 0x2029 or --  Paragraph separator
        c == 0x202F or --  Narrow no-break space
        c == 0x205F or --  Medium mathematical space
        c == 0x3000 --  Ideographic space
end
local function is_unicode_bullet(c)
    -- Not all of them are strictly bullets, but will do for our usage here
    return c == 0x2022 or --  Bullet
        c == 0x2023 or --  Triangular bullet
        c == 0x25a0 or --  Black square
        c == 0x25cb or --  White circle
        c == 0x25cf or --  Black circle
        c == 0x25e6 or --  White bullet
        c == 0x2043 or --  Hyphen bullet
        c == 0x2219 or --  Bullet operator
        c == 149 or --  Ascii bullet
        c == B'*'
end

local function skip_starting_bullet(line)
    local ch = line.first_char
    local found_bullet = false

    while ch ~= nil do
        if is_unicode_bullet(ch.c) then
            found_bullet = true
        elseif not is_unicode_wspace(ch.c) then
            break
        end

        ch = ch.next
    end

    if found_bullet then
        return ch
    else
        return line.first_char
    end
end

--[[
get the text of the given page

will return text in a Lua table that is modeled after
djvu.c creates this table.

note that the definition of "line" is somewhat arbitrary
here (for now)

MuPDFs API provides text as single char information
that is collected in "spans". we use a span as a "line"
in Lua output and segment spans into words by looking
for space characters.

will return an empty table if we have no text
--]]
function page_mt.__index:getPageText()
    -- first, we run the page through a special device, the text_device
    local text_page = W.mupdf_new_stext_page_from_page(self.ctx, self.page, nil)
    if text_page == nil then merror(self.ctx, "cannot alloc text_page") end

    -- now we analyze the data returned by the device and bring it
    -- into the format we want to return
    local lines = {}
    local size = 0

    local block = text_page.first_block
    while block ~= nil do
        if block.type == M.FZ_STEXT_BLOCK_TEXT then
            -- a block contains lines, which is our primary return datum
            local mupdf_line = block.u.t.first_line
            while mupdf_line ~= nil do
                local line = {}
                local line_bbox = ffi.new("fz_rect", M.fz_empty_rect)

                local first_char = skip_starting_bullet( mupdf_line )
                local ch = first_char
                local ch_len = 0
                while ch ~= nil do
                    ch = ch.next
                    ch_len = ch_len + 1
                end

                if ch_len > 0 then
                    -- here we will collect UTF-8 chars before making them
                    -- a Lua string:
                    local textbuf = ffi.new("char[?]", ch_len * 4)

                    ch = first_char
                    while ch ~= nil do
                        local textlen = 0
                        local word_bbox = ffi.new("fz_rect", M.fz_empty_rect)
                        while ch ~= nil do
                            if is_unicode_wspace(ch.c) then
                                -- ignore and end word
                                break
                            end
                            textlen = textlen + M.fz_runetochar(textbuf + textlen, ch.c)
                            local char_bbox = ffi.new("fz_rect")
                            W.mupdf_fz_rect_from_quad(char_bbox, ch.quad)
                            W.mupdf_fz_union_rect(word_bbox, char_bbox)
                            W.mupdf_fz_union_rect(line_bbox, char_bbox)
                            if ch.c >= 0x4e00 and ch.c <= 0x9FFF or -- CJK Unified Ideographs
                                ch.c >= 0x2000 and ch.c <= 0x206F or -- General Punctuation
                                ch.c >= 0x3000 and ch.c <= 0x303F or -- CJK Symbols and Punctuation
                                ch.c >= 0x3400 and ch.c <= 0x4DBF or -- CJK Unified Ideographs Extension A
                                ch.c >= 0xF900 and ch.c <= 0xFAFF or -- CJK Compatibility Ideographs
                                ch.c >= 0xFF01 and ch.c <= 0xFFEE or -- Halfwidth and Fullwidth Forms
                                ch.c >= 0x20000 and ch.c <= 0x2A6DF  -- CJK Unified Ideographs Extension B
                            then
                                -- end word
                                break
                            end
                            ch = ch.next
                        end
                        -- add word to line
                        if word_bbox.x0 < word_bbox.x1 and word_bbox.y0 < word_bbox.y1 then
                            table.insert(line, {
                                word = ffi.string(textbuf, textlen),
                                x0 = word_bbox.x0, y0 = word_bbox.y0,
                                x1 = word_bbox.x1, y1 = word_bbox.y1,
                            })
                            size = size + 5 * 8 + textlen
                        end

                        if ch == nil then
                            break
                        end

                        ch = ch.next
                    end

                    if line_bbox.x0 < line_bbox.x1 and line_bbox.y0 < line_bbox.y1 then
                        line.x0, line.y0 = line_bbox.x0, line_bbox.y0
                        line.x1, line.y1 = line_bbox.x1, line_bbox.y1
                        size = size + 5 * 8
                        table.insert(lines, line)
                    end
                end

                mupdf_line = mupdf_line.next
            end
        end

        block = block.next
    end

    -- Rough approximation of size for caching
    lines.size = size

    M.fz_drop_stext_page(self.ctx, text_page)

    return lines
end

--[[
Get a list of the Hyperlinks on a page
--]]
function page_mt.__index:getPageLinks()
    local page_links = W.mupdf_load_links(self.ctx, self.page)
    -- do not error out when page_links == NULL, since there might
    -- simply be no links present.

    local links = {}

    local link = page_links
    while link ~= nil do
        local data = {
            x0 = link.rect.x0, y0 = link.rect.y0,
            x1 = link.rect.x1, y1 = link.rect.y1,
        }
        local pos = ffi.new("float[2]")
        local location = ffi.new("fz_location")
        W.mupdf_fz_resolve_link(self.ctx, self.doc.doc, link.uri, pos, pos+1, location)
        -- `fz_resolve_link` return a location of (-1, -1) for external links.
        if location.chapter == -1 and location.page == -1 then
            data.uri = ffi.string(link.uri)
        else
            data.page = W.mupdf_fz_page_number_from_location(self.ctx, self.doc.doc, location)
        end
        data.pos = {
            x = pos[0], y = pos[1],
        }
        table.insert(links, data)
        link = link.next
    end

    M.fz_drop_link(self.ctx, page_links)

    return links
end

local function run_page(page, pixmap, ctm)
    M.fz_clear_pixmap_with_value(page.ctx, pixmap, 0xff)

    local dev = W.mupdf_new_draw_device(page.ctx, nil, pixmap)
    if dev == nil then merror(page.ctx, "cannot create draw device") end

    local ok = W.mupdf_run_page(page.ctx, page.page, dev, ctm, nil)
    M.fz_close_device(page.ctx, dev)
    M.fz_drop_device(page.ctx, dev)
    if ok == nil then merror(page.ctx, "could not run page") end
end
--[[
render page to blitbuffer

old interface: expects a blitbuffer to render to
--]]
function page_mt.__index:draw(draw_context, blitbuffer, offset_x, offset_y)
    local buffer = self:draw_new(draw_context, blitbuffer:getWidth(), blitbuffer:getHeight(), offset_x, offset_y)
    blitbuffer:blitFrom(buffer)
    buffer:free()
end
--[[
render page to blitbuffer

new interface: creates the blitbuffer with the rendered data and returns that
TODO: make this the used interface
--]]
function page_mt.__index:draw_new(draw_context, width, height, offset_x, offset_y)
    local ctm = ffi.new("fz_matrix")

    -- HACK GEMINI V8: SMART STRETCH (Fit Width ONLY)
    local forced = false
    if Screen then
        local sw = Screen:getWidth()
        local sh = Screen:getHeight()

        -- On detecte l'orientation
        local screen_target_w = sw
        local screen_target_h = sh
        if draw_context.rotate == 90 or draw_context.rotate == 270 then
            screen_target_w = sh
            screen_target_h = sw
        end

        -- CONDITION SMART : Active le stretch uniquement si la largeur demandee = largeur ecran
        local is_fit_width = math.abs(width - screen_target_w) < 5

        if is_fit_width then
            local bounds = ffi.new("fz_rect")
            W.mupdf_fz_bound_page(self.ctx, self.page, bounds)
            local pw = bounds.x1 - bounds.x0
            local ph = bounds.y1 - bounds.y0

            if pw > 0 and ph > 0 then
                 width = screen_target_w
                 height = screen_target_h
                 ctm.a=1; ctm.b=0; ctm.c=0; ctm.d=1; ctm.e=0; ctm.f=0
                 local sx = width / pw
                 local sy = height / ph
                 W.mupdf_fz_scale(ctm, sx, sy)
                 if draw_context.rotate ~= 0 then 
                    W.mupdf_fz_pre_rotate(ctm, draw_context.rotate)
                 end
                 forced = true
            end
        end
    end

    if not forced then
        W.mupdf_fz_scale(ctm, draw_context.zoom, draw_context.zoom)
        W.mupdf_fz_pre_rotate(ctm, draw_context.rotate)
        W.mupdf_fz_pre_translate(ctm, draw_context.offset_x, draw_context.offset_y)
    end

    local bbox = ffi.new("fz_irect", offset_x, offset_y, offset_x + width, offset_y + height)

    local bb = BlitBuffer.new(width, height, self.doc.color and BlitBuffer.TYPE_BBRGB32 or BlitBuffer.TYPE_BB8)

    local colorspace = self.doc.color and M.fz_device_rgb(self.ctx)
        or M.fz_device_gray(self.ctx)
    if mupdf.bgr and self.doc.color then
        colorspace = M.fz_device_bgr(self.ctx)
    end
    local pix = W.mupdf_new_pixmap_with_bbox_and_data(
        self.ctx, colorspace, bbox, nil, self.doc.color and 1 or 0, ffi.cast("unsigned char*", bb.data))
    if pix == nil then merror(self.ctx, "cannot allocate pixmap") end

    run_page(self, pix, ctm)

    if draw_context.gamma >= 0.0 then
        M.fz_gamma_pixmap(self.ctx, pix, draw_context.gamma)
    end

    M.fz_drop_pixmap(self.ctx, pix)

    return bb
end

mupdf.STRIKE_HEIGHT = 0.375
mupdf.UNDERLINE_HEIGHT = 0
mupdf.LINE_THICKNESS = 0.05
mupdf.HIGHLIGHT_COLOR = {1.0, 1.0, 0.0}
mupdf.UNDERLINE_COLOR = {0.0, 0.0, 1.0}
mupdf.STRIKE_OUT_COLOR = {1.0, 0.0, 0.0}

function page_mt.__index:addMarkupAnnotation(points, n, type, bb_color)
    local color = ffi.new("float[3]")
    local alpha = 1.0
    if type == M.PDF_ANNOT_HIGHLIGHT then
        if bb_color then
            color[0] = bb_color.r / 255
            color[1] = bb_color.g / 255
            color[2] = bb_color.b / 255
        else
            color[0] = mupdf.HIGHLIGHT_COLOR[1]
            color[1] = mupdf.HIGHLIGHT_COLOR[2]
            color[2] = mupdf.HIGHLIGHT_COLOR[3]
        end
        alpha = 0.5
    elseif type == M.PDF_ANNOT_UNDERLINE then
        if bb_color then
            color[0] = bb_color.r / 255
            color[1] = bb_color.g / 255
            color[2] = bb_color.b / 255
        else
            color[0] = mupdf.UNDERLINE_COLOR[1]
            color[1] = mupdf.UNDERLINE_COLOR[2]
            color[2] = mupdf.UNDERLINE_COLOR[3]
        end
    elseif type == M.PDF_ANNOT_STRIKE_OUT then
        if bb_color then
            color[0] = bb_color.r / 255
            color[1] = bb_color.g / 255
            color[2] = bb_color.b / 255
        else
            color[0] = mupdf.STRIKE_OUT_COLOR[1]
            color[1] = mupdf.STRIKE_OUT_COLOR[2]
            color[2] = mupdf.STRIKE_OUT_COLOR[3]
        end
    else
        return
    end

    local annot = W.mupdf_pdf_create_annot(self.ctx, ffi.cast("pdf_page*", self.page), type)
    if annot == nil then merror(self.ctx, "could not create annotation") end

    local ok = W.mupdf_pdf_set_annot_quad_points(self.ctx, annot, n, points)
    if ok == nil then merror(self.ctx, "could not set annotation quadpoints") end

    ok = W.mupdf_pdf_set_annot_color(self.ctx, annot, 3, color)
    if ok == nil then merror(self.ctx, "could not set annotation color") end

    ok = W.mupdf_pdf_set_annot_opacity(self.ctx, annot, alpha)
    if ok == nil then merror(self.ctx, "could not set annotation opacity") end

    -- Fetch back MuPDF's stored coordinates of all quadpoints, as they may have been modified/rounded
    -- (we need the exact ones that were saved if we want to be able to find them for deletion/update)
    for i = 0, n-1 do
        W.mupdf_pdf_annot_quad_point(self.ctx, annot, i, points+i)
    end
end

function page_mt.__index:deleteMarkupAnnotation(annot)
    local ok = W.mupdf_pdf_delete_annot(self.ctx, ffi.cast("pdf_page*", self.page), annot)
    if ok == nil then merror(self.ctx, "could not delete markup annotation") end
end

function page_mt.__index:getMarkupAnnotation(points, n)
    local annot = W.mupdf_pdf_first_annot(self.ctx, ffi.cast("pdf_page*", self.page))
    while annot ~= nil do
        local n2 = W.mupdf_pdf_annot_quad_point_count(self.ctx, annot)
        if n == n2 then
            local quadpoint = ffi.new("fz_quad[1]")
            local match = true
            for i = 0, n-1 do
                W.mupdf_pdf_annot_quad_point(self.ctx, annot, i, quadpoint)
                if (points[i].ul.x ~= quadpoint[0].ul.x or
                    points[i].ul.y ~= quadpoint[0].ul.y or
                    points[i].ur.x ~= quadpoint[0].ur.x or
                    points[i].ur.y ~= quadpoint[0].ur.y or
                    points[i].ll.x ~= quadpoint[0].ll.x or
                    points[i].ll.y ~= quadpoint[0].ll.y or
                    points[i].lr.x ~= quadpoint[0].lr.x or
                    points[i].lr.y ~= quadpoint[0].lr.y) then
                    match = false
                    break
                end
            end
            if match then return annot end
        end
        annot = W.mupdf_pdf_next_annot(self.ctx, annot)
    end
    return nil
end

function page_mt.__index:updateMarkupAnnotation(annot, contents)
    local ok = W.mupdf_pdf_set_annot_contents(self.ctx, annot, contents)
    if ok == nil then merror(self.ctx, "could not update markup annot contents") end
end

-- image loading via MuPDF:

--[[--
Renders image data.
--]]
function mupdf.renderImage(data, size, width, height)
    local ctx = context()
    local buffer = W.mupdf_new_buffer_from_shared_data(ctx,
                     ffi.cast("unsigned char*", data), size)
    local image = W.mupdf_new_image_from_buffer(ctx, buffer)
    W.mupdf_drop_buffer(ctx, buffer)
    if image == nil then merror(ctx, "could not load image data") end
    local pixmap = W.mupdf_get_pixmap_from_image(ctx,
                    image, nil, nil, nil, nil)
    M.fz_drop_image(ctx, image)
    if pixmap == nil then
        merror(ctx, "could not create pixmap from image")
    end

    local p_width = M.fz_pixmap_width(ctx, pixmap)
    local p_height = M.fz_pixmap_height(ctx, pixmap)
    -- mupdf_get_pixmap_from_image() may not scale image to the
    -- width and height provided, so check and scale it if needed
    if width and height then
        -- Ensure we pass integer values for width & height to fz_scale_pixmap(),
        -- because it enforces an alpha channel otherwise...
        width = math.floor(width)
        height = math.floor(height)
        if p_width ~= width or p_height ~= height then
            local scaled_pixmap = M.fz_scale_pixmap(ctx, pixmap, 0, 0, width, height, nil)
            M.fz_drop_pixmap(ctx, pixmap)
            if scaled_pixmap == nil then
                merror(ctx, "could not create scaled pixmap from pixmap")
            end
            pixmap = scaled_pixmap
            p_width = M.fz_pixmap_width(ctx, pixmap)
            p_height = M.fz_pixmap_height(ctx, pixmap)
        end
    end
    local bbtype
    local ncomp = M.fz_pixmap_components(ctx, pixmap)
    if ncomp == 1 then bbtype = BlitBuffer.TYPE_BB8
    elseif ncomp == 2 then bbtype = BlitBuffer.TYPE_BB8A
    elseif ncomp == 3 then bbtype = BlitBuffer.TYPE_BBRGB24
    elseif ncomp == 4 then bbtype = BlitBuffer.TYPE_BBRGB32
    else error("unsupported number of color components")
    end
    -- Handle RGB->BGR conversion for Kobos when needed
    local bb
    if mupdf.bgr and ncomp >= 3 then
        local bgr_pixmap = W.mupdf_convert_pixmap(ctx, pixmap, M.fz_device_bgr(ctx), nil, nil, M.fz_default_color_params, (ncomp == 4 and 1 or 0))
        if pixmap == nil then
            merror(ctx, "could not convert pixmap to BGR")
        end
        M.fz_drop_pixmap(ctx, pixmap)

        local p = M.fz_pixmap_samples(ctx, bgr_pixmap)
        bb = BlitBuffer.new(p_width, p_height, bbtype, p):copy()
        M.fz_drop_pixmap(ctx, bgr_pixmap)
    else
        local p = M.fz_pixmap_samples(ctx, pixmap)
        bb = BlitBuffer.new(p_width, p_height, bbtype, p):copy()
        M.fz_drop_pixmap(ctx, pixmap)
    end
    return bb
end

--- Renders image file.
function mupdf.renderImageFile(filename, width, height)
    local file = io.open(filename, "rb")
    if not file then error("could not open image file") end
    local data = file:read("*a")
    file:close()
    return mupdf.renderImage(data, #data, width, height)
end

--[[--
Scales a blitbuffer.

MµPDF's scaling is of much better quality than the very naive implementation in blitbuffer.lua.
(see fz_scale_pixmap_cached() in mupdf/source/fitz/draw-scale-simple.c).
Same arguments as BlitBuffer:scale() for easy replacement.

Unlike BlitBuffer:scale(), this *ignores* the blitbuffer's rotation
(i.e., where possible, we simply wrap the BlitBuffer's data in a fitz pixmap,
with no data copy, so the buffer's *native* memory layout is followed).
If you actually want to preserve the rotation, you'll have to fudge
with the width & height arguments and tweak the returned buffer's rotation flag,
or go through a temporary copy to ensure that the buffer's memory is laid out accordingly.
--]]
function mupdf.scaleBlitBuffer(bb, width, height)
    -- We need first to convert our BlitBuffer to a pixmap
    local bbtype = bb:getType()
    local colorspace
    local converted_bb
    local alpha
    local stride = bb.stride
    local ctx = context()
    -- MuPDF should know how to handle *most* of our BB types,
    -- special snowflakes excluded (4bpp & RGB565),
    -- in which case we feed it a temporary copy in the closest format it'll understand.
    if bbtype == BlitBuffer.TYPE_BB8 then
        colorspace = M.fz_device_gray(ctx)
        alpha = 0
    elseif bbtype == BlitBuffer.TYPE_BB8A then
        colorspace = M.fz_device_gray(ctx)
        alpha = 1
    elseif bbtype == BlitBuffer.TYPE_BBRGB24 then
        if mupdf.bgr then
            colorspace = M.fz_device_bgr(ctx)
        else
            colorspace = M.fz_device_rgb(ctx)
        end
        alpha = 0
    elseif bbtype == BlitBuffer.TYPE_BBRGB32 then
        if mupdf.bgr then
            colorspace = M.fz_device_bgr(ctx)
        else
            colorspace = M.fz_device_rgb(ctx)
        end
        alpha = 1
    elseif bbtype == BlitBuffer.TYPE_BB4 then
        converted_bb = BlitBuffer.new(bb.w, bb.h, BlitBuffer.TYPE_BB8)
        converted_bb:blitFrom(bb, 0, 0, 0, 0, bb.w, bb.h)
        bb = converted_bb -- we don't free() the provided bb, but we'll have to free our converted_bb
        colorspace = M.fz_device_gray(ctx)
        alpha = 0
        stride = bb.w
    else
        converted_bb = BlitBuffer.new(bb.w, bb.h, BlitBuffer.TYPE_BBRGB32)
        converted_bb:blitFrom(bb, 0, 0, 0, 0, bb.w, bb.h)
        bb = converted_bb -- we don't free() the provided bb, but we'll have to free our converted_bb
        if mupdf.bgr then
            colorspace = M.fz_device_bgr(ctx)
        else
            colorspace = M.fz_device_rgb(ctx)
        end
        alpha = 1
    end
    -- We can now create a pixmap from this bb of correct type
    local pixmap = W.mupdf_new_pixmap_with_data(ctx, colorspace,
                    bb.w, bb.h, nil, alpha, stride, ffi.cast("unsigned char*", bb.data))
    if pixmap == nil then
        if converted_bb then converted_bb:free() end -- free our home made bb
        merror(ctx, "could not create pixmap from blitbuffer")
    end
    -- We can now scale the pixmap
    -- Better to ensure we give integer width and height, to avoid a black 1-pixel line at right and bottom of image.
    -- Also, fz_scale_pixmap enforces an alpha channel if w or h are floats...
    local scaled_pixmap = M.fz_scale_pixmap(ctx, pixmap, 0, 0, math.floor(width), math.floor(height), nil)
    M.fz_drop_pixmap(ctx, pixmap) -- free our original pixmap
    if scaled_pixmap == nil then
        if converted_bb then converted_bb:free() end -- free our home made bb
        merror(ctx, "could not create scaled pixmap from pixmap")
    end
    local p_width = M.fz_pixmap_width(ctx, scaled_pixmap)
    local p_height = M.fz_pixmap_height(ctx, scaled_pixmap)
    -- And convert the pixmap back to a BlitBuffer
    bbtype = nil
    local ncomp = M.fz_pixmap_components(ctx, scaled_pixmap)
    if ncomp == 1 then bbtype = BlitBuffer.TYPE_BB8
    elseif ncomp == 2 then bbtype = BlitBuffer.TYPE_BB8A
    elseif ncomp == 3 then bbtype = BlitBuffer.TYPE_BBRGB24
    elseif ncomp == 4 then bbtype = BlitBuffer.TYPE_BBRGB32
    else
        if converted_bb then converted_bb:free() end -- free our home made bb
        error("unsupported number of color components")
    end
    local p = M.fz_pixmap_samples(ctx, scaled_pixmap)
    bb = BlitBuffer.new(p_width, p_height, bbtype, p):copy()
    M.fz_drop_pixmap(ctx, scaled_pixmap) -- free our scaled pixmap
    if converted_bb then converted_bb:free() end -- free our home made bb
    return bb
end

-- k2pdfopt interfacing

-- will lazily load ffi/koptcontext.lua in order to interface k2pdfopt
local cached_k2pdfopt
local function get_k2pdfopt()
    if cached_k2pdfopt then return cached_k2pdfopt end

    local koptcontext = require("ffi/koptcontext")
    cached_k2pdfopt = koptcontext.k2pdfopt
    return cached_k2pdfopt
end

--[[
the following function is a reimplementation of what can be found
in libk2pdfopt/willuslib/bmpmupdf.c
k2pdfopt supports only 8bit and 24bit "bitmaps" - and mupdf will give
only 8bit+8bit alpha or 24bit+8bit alpha pixmaps. So we need to convert
what we get from mupdf.
--]]
local function bmpmupdf_pixmap_to_bmp(bmp, pixmap)
    local k2pdfopt = get_k2pdfopt()
    local ctx = context()

    bmp.width = M.fz_pixmap_width(ctx, pixmap)
    bmp.height = M.fz_pixmap_height(ctx, pixmap)
    local ncomp = M.fz_pixmap_components(ctx, pixmap)
    local p = M.fz_pixmap_samples(ctx, pixmap)
    if ncomp == 2 or ncomp == 4 then
        k2pdfopt.pixmap_to_bmp(bmp, p, ncomp)
    else
        error("unsupported pixmap format for conversion to bmp")
    end
end

local function render_for_kopt(bmp, page, scale, bounds)
    local k2pdfopt = get_k2pdfopt()

    local bbox = ffi.new("fz_irect")
    local ctm = ffi.new("fz_matrix")
    W.mupdf_fz_scale(ctm, scale, scale)
    W.mupdf_fz_transform_rect(bounds, ctm)
    W.mupdf_fz_round_rect(bbox, bounds)

    local colorspace = page.doc.color and M.fz_device_rgb(page.ctx)
        or M.fz_device_gray(page.ctx)
    if mupdf.bgr and page.doc.color then
        colorspace = M.fz_device_bgr(page.ctx)
    end
    local pix = W.mupdf_new_pixmap_with_bbox(page.ctx, colorspace, bbox, nil, 1)
    if pix == nil then merror(page.ctx, "could not allocate pixmap") end

    run_page(page, pix, ctm)

    k2pdfopt.bmp_init(bmp)

    bmpmupdf_pixmap_to_bmp(bmp, pix)

    M.fz_drop_pixmap(page.ctx, pix)
end

function page_mt.__index:getPagePix(kopt_context)
    local bounds = ffi.new("fz_rect", kopt_context.bbox.x0, kopt_context.bbox.y0, kopt_context.bbox.x1, kopt_context.bbox.y1)

    render_for_kopt(kopt_context.src, self, kopt_context.zoom, bounds)

    kopt_context.page_width = kopt_context.src.width
    kopt_context.page_height = kopt_context.src.height
end

function page_mt.__index:toBmp(bmp, dpi)
    local bounds = ffi.new("fz_rect")
    W.mupdf_fz_bound_page(self.ctx, self.page, bounds)
    render_for_kopt(bmp, self, dpi/72, bounds)
end

return mupdf

Hei02 avatar Dec 03 '25 14:12 Hei02

readerpaging.lua

local BD = require("ui/bidi")
local Device = require("device")
local Event = require("ui/event")
local Geom = require("ui/geometry")
local InputContainer = require("ui/widget/container/inputcontainer")
local Math = require("optmath")
local UIManager = require("ui/uimanager")
local bit = require("bit")
local logger = require("logger")
local util = require("util")
local time = require("ui/time")
local _ = require("gettext")
local Input = Device.input
local Screen = Device.screen

local function copyPageState(page_state)
    return {
        page = page_state.page,
        zoom = page_state.zoom,
        rotation = page_state.rotation,
        gamma = page_state.gamma,
        offset = page_state.offset:copy(),
        visible_area = page_state.visible_area:copy(),
        page_area = page_state.page_area:copy(),
    }
end


local ReaderPaging = InputContainer:extend{
    pan_rate = 30,  -- default 30 ops, will be adjusted in readerui
    current_page = 0,
    number_of_pages = 0,
    visible_area = nil,
    page_area = nil,
    overlap = Screen:scaleBySize(G_defaults:readSetting("DOVERLAPPIXELS")),

    page_flipping_mode = false,
    bookmark_flipping_mode = false,
    flip_steps = {0, 1, 2, 5, 10, 20, 50, 100},
}

function ReaderPaging:init()
    self:registerKeyEvents()
    self.pan_interval = time.s(1 / self.pan_rate)
    self.number_of_pages = self.ui.document.info.number_of_pages

    -- delegate gesture listener to readerui, NOP our own
    self.ges_events = nil
end

function ReaderPaging:onGesture() end

function ReaderPaging:registerKeyEvents()
    local nextKey = BD.mirroredUILayout() and "Left" or "Right"
    local prevKey = BD.mirroredUILayout() and "Right" or "Left"
    if Device:hasDPad() and Device:useDPadAsActionKeys() then
        if G_reader_settings:isTrue("left_right_keys_turn_pages") then
            self.key_events.GotoNextPage = { { { "RPgFwd", "LPgFwd", nextKey, " " } }, event = "GotoViewRel", args = 1, }
            self.key_events.GotoPrevPage = { { { "RPgBack", "LPgBack", prevKey } }, event = "GotoViewRel", args = -1, }
        elseif G_reader_settings:nilOrFalse("left_right_keys_turn_pages") then
            self.key_events.GotoNextChapter = { { nextKey }, event = "GotoNextChapter", args = 1, }
            self.key_events.GotoPrevChapter = { { prevKey }, event = "GotoPrevChapter", args = -1, }
            self.key_events.GotoNextPage = { { { "RPgFwd", "LPgFwd", " " } }, event = "GotoViewRel", args = 1, }
            self.key_events.GotoPrevPage = { { { "RPgBack", "LPgBack" } }, event = "GotoViewRel", args = -1, }
        end
    elseif Device:hasKeys() then
        self.key_events.GotoNextPage = { { { "RPgFwd", "LPgFwd", not Device:hasFewKeys() and nextKey } }, event = "GotoViewRel", args = 1, }
        self.key_events.GotoPrevPage = { { { "RPgBack", "LPgBack", not Device:hasFewKeys() and prevKey } }, event = "GotoViewRel", args = -1, }
        self.key_events.GotoNextPos = { { "Down" }, event = "GotoPosRel", args = 1, }
        self.key_events.GotoPrevPos = { { "Up" }, event = "GotoPosRel", args = -1, }
    end
    if Device:hasKeyboard() and not Device.k3_alt_plus_key_kernel_translated then
        self.key_events.GotoFirst = { { "1" }, event = "GotoPercent", args = 0,   }
        self.key_events.Goto11    = { { "2" }, event = "GotoPercent", args = 11,  }
        self.key_events.Goto22    = { { "3" }, event = "GotoPercent", args = 22,  }
        self.key_events.Goto33    = { { "4" }, event = "GotoPercent", args = 33,  }
        self.key_events.Goto44    = { { "5" }, event = "GotoPercent", args = 44,  }
        self.key_events.Goto55    = { { "6" }, event = "GotoPercent", args = 55,  }
        self.key_events.Goto66    = { { "7" }, event = "GotoPercent", args = 66,  }
        self.key_events.Goto77    = { { "8" }, event = "GotoPercent", args = 77,  }
        self.key_events.Goto88    = { { "9" }, event = "GotoPercent", args = 88,  }
        self.key_events.Goto99    = { { "0" }, event = "GotoPercent", args = 100, }
    end
end

ReaderPaging.onPhysicalKeyboardConnected = ReaderPaging.registerKeyEvents

function ReaderPaging:onReaderReady()
    self:setupTouchZones()
     -- Statistics plugin updates the footer later, if enabled
    if not (self.ui.statistics and self.ui.statistics.settings.is_enabled) then
        self.view.footer:onUpdateFooter()
    end
end

function ReaderPaging:setupTouchZones()
    if not Device:isTouchDevice() then return end

    local forward_zone, backward_zone = self.view:getTapZones()

    self.ui:registerTouchZones({
        {
            id = "tap_forward",
            ges = "tap",
            screen_zone = forward_zone,
            handler = function()
                if G_reader_settings:nilOrFalse("page_turns_disable_tap") then
                    return self:onGotoViewRel(1)
                end
            end,
        },
        {
            id = "tap_backward",
            ges = "tap",
            screen_zone = backward_zone,
            handler = function()
                if G_reader_settings:nilOrFalse("page_turns_disable_tap") then
                    return self:onGotoViewRel(-1)
                end
            end,
        },
        {
            id = "paging_swipe",
            ges = "swipe",
            screen_zone = {
                ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1,
            },
            handler = function(ges) return self:onSwipe(nil, ges) end,
        },
        {
            id = "paging_pan",
            ges = "pan",
            screen_zone = {
                ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1,
            },
            handler = function(ges) return self:onPan(nil, ges) end,
        },
        {
            id = "paging_pan_release",
            ges = "pan_release",
            screen_zone = {
                ratio_x = 0, ratio_y = 0, ratio_w = 1, ratio_h = 1,
            },
            handler = function(ges) return self:onPanRelease(nil, ges) end,
        },
    })
end

function ReaderPaging:onReadSettings(config)
    self.page_positions = config:readSetting("page_positions") or {}
    self:_gotoPage(config:readSetting("last_page") or 1)
    self.flipping_zoom_mode = config:readSetting("flipping_zoom_mode") or "page"
    self.flipping_scroll_mode = config:isTrue("flipping_scroll_mode")
end

function ReaderPaging:onSaveSettings()
    --- @todo only save current_page page position
    self.ui.doc_settings:saveSetting("page_positions", self.page_positions)
    self.ui.doc_settings:saveSetting("last_page", self:getTopPage())
    self.ui.doc_settings:saveSetting("percent_finished", self:getLastPercent())
    self.ui.doc_settings:saveSetting("flipping_zoom_mode", self.flipping_zoom_mode)
    self.ui.doc_settings:saveSetting("flipping_scroll_mode", self.flipping_scroll_mode)
end

function ReaderPaging:getLastProgress()
    return self:getTopPage()
end

function ReaderPaging:getLastPercent()
    if self.current_page > 0 and self.number_of_pages > 0 then
        return self.current_page/self.number_of_pages
    end
end

function ReaderPaging:onColorRenderingUpdate()
    self.ui.document:updateColorRendering()
    UIManager:setDirty(self.view.dialog, "partial")
end

--[[
Set reading position on certain page
Page position is a fractional number ranging from 0 to 1, indicating the read
percentage on certain page. With the position information on each page whenever
users change font size, page margin or line spacing or close and reopen the
book, the page view will be roughly the same.
--]]
function ReaderPaging:setPagePosition(page, pos)
    logger.dbg("set page position", pos)
    self.page_positions[page] = pos ~= 0 and pos or nil
    self.ui:handleEvent(Event:new("PagePositionUpdated"))
end

--[[
Get reading position on certain page
--]]
function ReaderPaging:getPagePosition(page)
    -- Page number ought to be integer, somehow I notice that with
    -- fractional page number the reader runs silently well, but the
    -- number won't fit to retrieve page position.
    page = math.floor(page)
    logger.dbg("get page position", self.page_positions[page])
    return self.page_positions[page] or 0
end

function ReaderPaging:onTogglePageFlipping()
    if self.bookmark_flipping_mode then
        -- do nothing if we're in bookmark flipping mode
        return
    end
    self.view.flipping_visible = not self.view.flipping_visible
    self.page_flipping_mode = self.view.flipping_visible
    self.flipping_page = self.current_page

    if self.page_flipping_mode then
        self:updateOriginalPage(self.current_page)
        self:enterFlippingMode()
    else
        self:updateOriginalPage(nil)
        self:exitFlippingMode()
    end
    self.ui:handleEvent(Event:new("SetHinting", not self.page_flipping_mode))
    self.ui:handleEvent(Event:new("ReZoom"))
    UIManager:setDirty(self.view.dialog, "partial")
end

function ReaderPaging:onToggleBookmarkFlipping()
    self.bookmark_flipping_mode = not self.bookmark_flipping_mode

    if self.bookmark_flipping_mode then
        self.orig_flipping_mode = self.view.flipping_visible
        self.view.flipping_visible = true
        self.bm_flipping_orig_page = self.current_page
        self:enterFlippingMode()
    else
        self.view.flipping_visible = self.orig_flipping_mode
        self:exitFlippingMode()
        self:_gotoPage(self.bm_flipping_orig_page)
    end
    self.ui:handleEvent(Event:new("SetHinting", not self.bookmark_flipping_mode))
    self.ui:handleEvent(Event:new("ReZoom"))
    UIManager:setDirty(self.view.dialog, "partial")
end

function ReaderPaging:enterFlippingMode()
    self.orig_reflow_mode = self.view.document.configurable.text_wrap
    self.orig_scroll_mode = self.view.page_scroll
    self.orig_zoom_mode = self.view.zoom_mode
    logger.dbg("store zoom mode", self.orig_zoom_mode)
    self.view.document.configurable.text_wrap = 0
    self.view.page_scroll = self.flipping_scroll_mode
    Input.disable_double_tap = false
    self.ui:handleEvent(Event:new("EnterFlippingMode", self.flipping_zoom_mode))
end

function ReaderPaging:exitFlippingMode()
    Input.disable_double_tap = true
    self.view.document.configurable.text_wrap = self.orig_reflow_mode
    self.view.page_scroll = self.orig_scroll_mode
    self.flipping_zoom_mode = self.view.zoom_mode
    self.flipping_scroll_mode = self.view.page_scroll
    logger.dbg("restore zoom mode", self.orig_zoom_mode)
    self.ui:handleEvent(Event:new("ExitFlippingMode", self.orig_zoom_mode))
end

function ReaderPaging:updateOriginalPage(page)
    self.original_page = page
end

function ReaderPaging:updateFlippingPage(page)
    self.flipping_page = page
end

function ReaderPaging:pageFlipping(flipping_page, flipping_ges)
    local whole = self.number_of_pages
    local steps = #self.flip_steps
    local stp_proportion = flipping_ges.distance / Screen:getWidth()
    local abs_proportion = flipping_ges.distance / Screen:getHeight()
    local direction = BD.flipDirectionIfMirroredUILayout(flipping_ges.direction)
    if direction == "east" then
        self:_gotoPage(flipping_page - self.flip_steps[math.ceil(steps*stp_proportion)])
    elseif direction == "west" then
        self:_gotoPage(flipping_page + self.flip_steps[math.ceil(steps*stp_proportion)])
    elseif direction == "south" then
        self:_gotoPage(flipping_page - math.floor(whole*abs_proportion))
    elseif direction == "north" then
        self:_gotoPage(flipping_page + math.floor(whole*abs_proportion))
    end
    UIManager:setDirty(self.view.dialog, "partial")
end

function ReaderPaging:bookmarkFlipping(flipping_page, flipping_ges)
    local direction = BD.flipDirectionIfMirroredUILayout(flipping_ges.direction)
    if direction == "east" then
        self.ui:handleEvent(Event:new("GotoPreviousBookmark", flipping_page))
    elseif direction == "west" then
        self.ui:handleEvent(Event:new("GotoNextBookmark", flipping_page))
    end
    UIManager:setDirty(self.view.dialog, "partial")
end

function ReaderPaging:enterSkimMode()
    if self.view.document.configurable.text_wrap ~= 0 or self.view.page_scroll or self.view.zoom_mode ~= "page" then
        self.skim_backup = {
            text_wrap    = self.view.document.configurable.text_wrap,
            page_scroll  = self.view.page_scroll,
            zoom_mode    = self.view.zoom_mode,
            current_page = self.current_page,
            location     = self:getBookLocation(),
            visible_area = self.visible_area,
        }
        self.view.document.configurable.text_wrap = 0
        self.view.page_scroll = false
        self.ui.zooming:onSetZoomMode("page")
        self.ui.zooming:onReZoom()
    end
end

function ReaderPaging:exitSkimMode()
    if self.skim_backup then
        self.view.document.configurable.text_wrap = self.skim_backup.text_wrap
        self.view.page_scroll = self.skim_backup.page_scroll
        self.ui.zooming:onSetZoomMode(self.skim_backup.zoom_mode)
        self.ui.zooming:onReZoom()
        if self.current_page == self.skim_backup.current_page then
            -- if SkimToWidget is closed on the start page, restore exact location
            self.current_page = 0 -- do not emit extra PageUpdate event
            self:onRestoreBookLocation(self.skim_backup.location)
        end
        self.visible_area = self.skim_backup.visible_area
        self.skim_backup = nil
    end
end

function ReaderPaging:onScrollSettingsUpdated(scroll_method, inertial_scroll_enabled, scroll_activation_delay_ms)
    self.scroll_method = scroll_method
    self.scroll_activation_delay = time.ms(scroll_activation_delay_ms)
    if inertial_scroll_enabled then
        self.ui.scrolling:setInertialScrollCallbacks(
            function(distance) -- do_scroll_callback
                if not self.ui.document then
                    return false
                end
                UIManager.currently_scrolling = true
                local top_page, top_position = self:getTopPage(), self:getTopPosition()
                self:onPanningRel(distance)
                return not (top_page == self:getTopPage() and top_position == self:getTopPosition())
            end,
            function() -- scroll_done_callback
                UIManager.currently_scrolling = false
                UIManager:setDirty(self.view.dialog, "partial")
            end
        )
    else
        self.ui.scrolling:setInertialScrollCallbacks(nil, nil)
    end
end

function ReaderPaging:onSwipe(_, ges)
    if self._pan_has_scrolled then
        -- We did some panning but released after a short amount of time,
        -- so this gesture ended up being a Swipe - and this swipe was
        -- not handled by the other modules (so, not opening the menus).
        -- Do as :onPanRelease() and ignore this swipe.
        self:onPanRelease() -- no arg, so we know there we come from here
        return true
    else
        self._pan_started = false
        UIManager.currently_scrolling = false
        self._pan_page_states_to_restore = nil
    end
    local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
    if self.bookmark_flipping_mode then
        self:bookmarkFlipping(self.current_page, ges)
        return true
    elseif self.page_flipping_mode and self.original_page then
        self:_gotoPage(self.original_page)
        return true
    elseif direction == "west" then
        if G_reader_settings:nilOrFalse("page_turns_disable_swipe") then
            if self.view.inverse_reading_order then
                self:onGotoViewRel(-1)
            else
                self:onGotoViewRel(1)
            end
            return true
        end
    elseif direction == "east" then
        if G_reader_settings:nilOrFalse("page_turns_disable_swipe") then
            if self.view.inverse_reading_order then
                self:onGotoViewRel(1)
            else
                self:onGotoViewRel(-1)
            end
            return true
        end
    end
end

function ReaderPaging:onPan(_, ges)
    if self.bookmark_flipping_mode then
        return true
    elseif self.page_flipping_mode then
        if self.view.zoom_mode == "page" then
            self:pageFlipping(self.flipping_page, ges)
        else
            self.view:PanningStart(-ges.relative.x, -ges.relative.y)
        end
    elseif ges.direction == "north" or ges.direction == "south" then
        if ges.mousewheel_direction and not self.view.page_scroll then
            -- Mouse wheel generates a Pan event: in page mode, move one
            -- page per event. Scroll mode is handled in the 'else' branch
            -- and use the wheeled distance.
            self:onGotoViewRel(-1 * ges.mousewheel_direction)
        elseif self.view.page_scroll then
            if not self._pan_started then
                self._pan_started = true
                -- Re-init state variables
                self._pan_has_scrolled = false
                self._pan_prev_relative_y = 0
                self._pan_to_scroll_later = 0
                self._pan_real_last_time = 0
                if ges.mousewheel_direction then
                    self._pan_activation_time = false
                else
                    self._pan_activation_time = ges.time + self.scroll_activation_delay
                end
                -- We will restore the previous position if this pan
                -- ends up being a swipe or a multiswipe
                -- Somehow, accumulating the distances scrolled in a self._pan_dist_to_restore
                -- so we can scroll these back may not always put us back to the original
                -- position (possibly because of these page_states?). It's safer
                -- to remember the original page_states and restore that. We can keep
                -- a reference to the original table as onPanningRel() will have this
                -- table replaced.
                self._pan_page_states_to_restore = self.view.page_states
            end
            local scroll_now = false
            if self._pan_activation_time and ges.time >= self._pan_activation_time then
                self._pan_activation_time = false -- We can go on, no need to check again
            end
            if not self._pan_activation_time and ges.time - self._pan_real_last_time >= self.pan_interval then
                scroll_now = true
                self._pan_real_last_time = ges.time
            end
            local scroll_dist = 0
            if self.scroll_method == self.ui.scrolling.SCROLL_METHOD_CLASSIC then
                -- Scroll by the distance the finger moved since last pan event,
                -- having the document follows the finger
                scroll_dist = self._pan_prev_relative_y - ges.relative.y
                self._pan_prev_relative_y = ges.relative.y
                if not self._pan_has_scrolled then
                    -- Avoid checking this for each pan, no need once we have scrolled
                    if self.ui.scrolling:cancelInertialScroll() or self.ui.scrolling:cancelledByTouch() then
                        -- If this pan or its initial touch did cancel some inertial scrolling,
                        -- ignore activation delay to allow continuous scrolling
                        self._pan_activation_time = false
                        scroll_now = true
                        self._pan_real_last_time = ges.time
                    end
                end
                self.ui.scrolling:accountManualScroll(scroll_dist, ges.time)
            elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_TURBO then
                -- Legacy scrolling "buggy" behaviour, that can actually be nice
                -- Scroll by the distance from the initial finger position, this distance
                -- controlling the speed of the scrolling)
                if scroll_now then
                    scroll_dist = -ges.relative.y
                end
                -- We don't accumulate in _pan_to_scroll_later
            elseif self.scroll_method == self.ui.scrolling.SCROLL_METHOD_ON_RELEASE then
                self._pan_to_scroll_later = -ges.relative.y
                if scroll_now then
                    self._pan_has_scrolled = true -- so we really apply it later
                end
                scroll_dist = 0
                scroll_now = false
            end
            if scroll_now then
                local dist = self._pan_to_scroll_later + scroll_dist
                self._pan_to_scroll_later = 0
                if dist ~= 0 then
                    self._pan_has_scrolled = true
                    UIManager.currently_scrolling = true
                    self:onPanningRel(dist)
                end
            else
                self._pan_to_scroll_later = self._pan_to_scroll_later + scroll_dist
            end
        end
    end
    return true
end

function ReaderPaging:onPanRelease(_, ges)
    if self.page_flipping_mode then
        if self.view.zoom_mode == "page" then
            self:updateFlippingPage(self.current_page)
        else
            self.view:PanningStop()
        end
    else
        if self._pan_has_scrolled and self._pan_to_scroll_later ~= 0 then
            self:onPanningRel(self._pan_to_scroll_later)
        end
        self._pan_started = false
        self._pan_page_states_to_restore = nil
        UIManager.currently_scrolling = false
        if self._pan_has_scrolled then
            self._pan_has_scrolled = false
            -- Don't do any inertial scrolling if pan events come from
            -- a mousewheel (which may have itself some inertia)
            if (ges and ges.from_mousewheel) or not self.ui.scrolling:startInertialScroll() then
                UIManager:setDirty(self.view.dialog, "partial")
            end
        end
    end
end

function ReaderPaging:onHandledAsSwipe()
    if self._pan_started then
        -- Restore original position as this pan we've started handling
        -- has ended up being a multiswipe or handled as a swipe to open
        -- top or bottom menus
        if self._pan_has_scrolled then
            self.view.page_states = self._pan_page_states_to_restore
            self:_gotoPage(self.view.page_states[#self.view.page_states].page, "scrolling")
            UIManager:setDirty(self.view.dialog, "ui")
        end
        self._pan_page_states_to_restore = nil
        self._pan_started = false
        self._pan_has_scrolled = false
        UIManager.currently_scrolling = false
    end
    return true
end
function ReaderPaging:onZoomModeUpdate(new_mode)
    -- we need to remember zoom mode to handle page turn event
    self.zoom_mode = new_mode
end

function ReaderPaging:onPageUpdate(new_page_no, orig_mode)
    self.current_page = new_page_no
    if self.view.page_scroll and orig_mode ~= "scrolling" then
        self.ui:handleEvent(Event:new("InitScrollPageStates", orig_mode))
    end
end

function ReaderPaging:onViewRecalculate(visible_area, page_area)
    -- we need to remember areas to handle page turn event
    self.visible_area = visible_area:copy()
    self.page_area = page_area
end

function ReaderPaging:onGotoPercent(percent)
    logger.dbg("goto document offset in percent:", percent)
    local dest = math.floor(self.number_of_pages * percent * (1/100))
    if dest < 1 then dest = 1 end
    if dest > self.number_of_pages then
        dest = self.number_of_pages
    end
    self:_gotoPage(dest)
    return true
end

function ReaderPaging:onGotoViewRel(diff, no_page_turn)
    -- When called via a key event, the second arg is a key object (table), not used here.
    no_page_turn = no_page_turn == true and true or nil
    -- ReaderSearch calls with no_page_turn = true.
    -- In that case, don't turn page if it would happen, and return ret=nil.
    local ret
    if self.view.page_scroll then
        ret = self:onScrollPageRel(diff, no_page_turn)
    else
        ret = self:onGotoPageRel(diff, no_page_turn)
    end
    self:setPagePosition(self:getTopPage(), self:getTopPosition())
    return ret
end

function ReaderPaging:onGotoPosRel(diff)
    if self.view.page_scroll then
        self:onPanningRel(100*diff)
    else
        self:onGotoPageRel(diff)
    end
    self:setPagePosition(self:getTopPage(), self:getTopPosition())
    return true
end

function ReaderPaging:onPanningRel(diff)
    if self.view.page_scroll then
        self:onScrollPanRel(diff)
    end
    self:setPagePosition(self:getTopPage(), self:getTopPosition())
    return true
end

-- Used by ReaderBack & ReaderLink.
function ReaderPaging:getBookLocation()
    local ctx = self.view:getViewContext()
    if ctx then
        -- We need a copy, as we're getting references to
        -- objects ReaderPaging/ReaderView may still modify
        local current_location = util.tableDeepCopy(ctx)
        return current_location
    end
end

function ReaderPaging:onRestoreBookLocation(saved_location)
    if not saved_location or not saved_location[1] then
        return
    end
    -- We need a copy, as we will assign this to ReaderView.state
    -- which when modified would change our instance on ReaderLink.location_stack
    local ctx = util.tableDeepCopy(saved_location)
    if self.view.page_scroll then
        if self.view:restoreViewContext(ctx) then
            self:_gotoPage(saved_location[1].page, "scrolling")
        else
            -- If context is unusable (not from scroll mode), trigger
            -- this to go at least to its page and redraw it
            self.ui:handleEvent(Event:new("PageUpdate", saved_location[1].page))
        end
    else
        -- gotoPage may emit PageUpdate event, which will trigger recalculate
        -- in ReaderView and resets the view context. So we need to call
        -- restoreViewContext after gotoPage.
        -- But if we're restoring to the same page, it will not emit
        -- PageUpdate event - so we need to do it for a correct redrawing
        local send_PageUpdate = saved_location[1].page == self.current_page
        self:_gotoPage(saved_location[1].page)
        if not self.view:restoreViewContext(ctx) then
            -- If context is unusable (not from page mode), also
            -- send PageUpdate event to go to its page and redraw it
            send_PageUpdate = true
        end
        if send_PageUpdate then
            self.ui:handleEvent(Event:new("PageUpdate", saved_location[1].page))
        end
    end
    self:setPagePosition(self:getTopPage(), self:getTopPosition())
    -- In some cases (same page, different offset), doing the above
    -- might not redraw the screen. Ensure it is.
    UIManager:setDirty(self.view.dialog, "partial")
    return true
end

--[[
Get read percentage on current page.
--]]
function ReaderPaging:getTopPosition()
    if self.view.page_scroll then
        local state = self.view.page_states[1]
        return (state.visible_area.y - state.page_area.y)/state.page_area.h
    else
        return 0
    end
end

--[[
Get page number of the page drawn at the very top part of the screen.
--]]
function ReaderPaging:getTopPage()
    if self.view.page_scroll then
        local state = self.view.page_states[1]
        return state and state.page or self.current_page
    else
        return self.current_page
    end
end

function ReaderPaging:onInitScrollPageStates(orig_mode)
    logger.dbg("init scroll page states", orig_mode)
    if self.view.page_scroll and self.view.state.page then
        self.orig_page = self.current_page
        self.view.page_states = {}
        local blank_area = Geom:new()
        blank_area:setSizeTo(self.view.visible_area)
        while blank_area.h > 0 do
            local offset = Geom:new()
            -- calculate position in current page
            if self.current_page == self.orig_page then
                local page_area = self.view:getPageArea(
                    self.view.state.page,
                    self.view.state.zoom,
                    self.view.state.rotation)
                offset.y = page_area.h * self:getPagePosition(self.current_page)
            end
            local state = self:getNextPageState(blank_area, offset)
            table.insert(self.view.page_states, state)
            if blank_area.h > 0 then
                blank_area.h = blank_area.h - self.view.page_gap.height
            end
            if blank_area.h > 0 then
                local next_page = self.ui.document:getNextPage(self.current_page)
                if next_page == 0 then break end -- end of document reached
                self:_gotoPage(next_page, "scrolling")
            end
        end
        self:_gotoPage(self.orig_page, "scrolling")
    end
    return true
end

function ReaderPaging:onUpdateScrollPageRotation(rotation)
    for _, state in ipairs(self.view.page_states) do
        state.rotation = rotation
    end
    return true
end

function ReaderPaging:onUpdateScrollPageGamma(gamma)
    for _, state in ipairs(self.view.page_states) do
        state.gamma = gamma
    end
    return true
end

function ReaderPaging:getNextPageState(blank_area, image_offset)
    local page_area = self.view:getPageArea(
        self.view.state.page,
        self.view.state.zoom,
        self.view.state.rotation)
    local visible_area = Geom:new{x = 0, y = 0}
    visible_area.w, visible_area.h = blank_area.w, blank_area.h
    visible_area.x, visible_area.y = page_area.x, page_area.y
    visible_area = visible_area:shrinkInside(page_area, image_offset.x, image_offset.y)
    -- shrink blank area by the height of visible area
    blank_area.h = blank_area.h - visible_area.h
    local page_offset = Geom:new{x = self.view.state.offset.x, y = 0}
    if blank_area.w > page_area.w then
        page_offset:offsetBy((blank_area.w - page_area.w) / 2, 0)
    end
    return {
        page = self.view.state.page,
        zoom = self.view.state.zoom,
        rotation = self.view.state.rotation,
        gamma = self.view.state.gamma,
        offset = page_offset,
        visible_area = visible_area,
        page_area = page_area,
    }
end

function ReaderPaging:getPrevPageState(blank_area, image_offset)
    local page_area = self.view:getPageArea(
        self.view.state.page,
        self.view.state.zoom,
        self.view.state.rotation)
    local visible_area = Geom:new{x = 0, y = 0}
    visible_area.w, visible_area.h = blank_area.w, blank_area.h
    visible_area.x = page_area.x
    visible_area.y = page_area.y + page_area.h - visible_area.h
    visible_area = visible_area:shrinkInside(page_area, image_offset.x, image_offset.y)
    -- shrink blank area by the height of visible area
    blank_area.h = blank_area.h - visible_area.h
    local page_offset = Geom:new{x = self.view.state.offset.x, y = 0}
    if blank_area.w > page_area.w then
        page_offset:offsetBy((blank_area.w - page_area.w) / 2, 0)
    end
    return {
        page = self.view.state.page,
        zoom = self.view.state.zoom,
        rotation = self.view.state.rotation,
        gamma = self.view.state.gamma,
        offset = page_offset,
        visible_area = visible_area,
        page_area = page_area,
    }
end

function ReaderPaging:updateTopPageState(state, blank_area, offset)
    local visible_area = Geom:new{
        x = state.visible_area.x,
        y = state.visible_area.y,
        w = blank_area.w,
        h = blank_area.h,
    }
    if self.ui.document:getNextPage(state.page) == 0 then -- last page
        visible_area:offsetWithin(state.page_area, offset.x, offset.y)
    else
        visible_area = visible_area:shrinkInside(state.page_area, offset.x, offset.y)
    end
    -- shrink blank area by the height of visible area
    blank_area.h = blank_area.h - visible_area.h
    state.visible_area = visible_area
end

function ReaderPaging:updateBottomPageState(state, blank_area, offset)
    local visible_area = Geom:new{
        x = state.page_area.x,
        y = state.visible_area.y + state.visible_area.h - blank_area.h,
        w = blank_area.w,
        h = blank_area.h,
    }
    if self.ui.document:getPrevPage(state.page) == 0 then -- first page
        visible_area:offsetWithin(state.page_area, offset.x, offset.y)
    else
        visible_area = visible_area:shrinkInside(state.page_area, offset.x, offset.y)
    end
    -- shrink blank area by the height of visible area
    blank_area.h = blank_area.h - visible_area.h
    state.visible_area = visible_area
end

function ReaderPaging:genPageStatesFromTop(top_page_state, blank_area, offset)
    -- Offset should always be greater than 0
    -- otherwise if offset is less than 0 the height of blank area will be
    -- larger than 0 even if page area is much larger than visible area,
    -- which will trigger the drawing of next page leaving part of current
    -- page undrawn. This should also be true for generating from bottom.
    if offset.y < 0 then offset.y = 0 end
    self:updateTopPageState(top_page_state, blank_area, offset)
    local page_states = {}
    if top_page_state.visible_area.h > 0 then
        -- offset does not scroll pass top_page_state
        table.insert(page_states, top_page_state)
    end
    local state
    local current_page = top_page_state.page
    while blank_area.h > 0 do
        blank_area.h = blank_area.h - self.view.page_gap.height
        if blank_area.h > 0 then
            current_page = self.ui.document:getNextPage(current_page)
            if current_page == 0 then break end -- end of document reached
            self:_gotoPage(current_page, "scrolling")
            state = self:getNextPageState(blank_area, Geom:new())
            table.insert(page_states, state)
        end
    end
    return page_states
end

function ReaderPaging:genPageStatesFromBottom(bottom_page_state, blank_area, offset)
    -- scroll up offset should always be less than 0
    if offset.y > 0 then offset.y = 0 end
    -- find out number of pages need to be removed from current view
    self:updateBottomPageState(bottom_page_state, blank_area, offset)
    local page_states = {}
    if bottom_page_state.visible_area.h > 0 then
        table.insert(page_states, bottom_page_state)
    end
    -- fill up current view from bottom to top
    local state
    local current_page = bottom_page_state.page
    while blank_area.h > 0 do
        blank_area.h = blank_area.h - self.view.page_gap.height
        if blank_area.h > 0 then
            current_page = self.ui.document:getPrevPage(current_page)
            if current_page == 0 then break end -- start of document reached
            self:_gotoPage(current_page, "scrolling")
            state = self:getPrevPageState(blank_area, Geom:new())
            table.insert(page_states, 1, state)
        end
    end
    if current_page == 0 then
        -- We reached the start of document: we may have truncated too much
        -- of the bottom page while scrolling up.
        -- Re-generate everything with first page starting at top
        offset = Geom:new{x = 0, y = 0}
        blank_area:setSizeTo(self.view.visible_area)
        local first_page_state = page_states[1]
        first_page_state.visible_area.y = 0 -- anchor first page at top
        return self:genPageStatesFromTop(first_page_state, blank_area, offset)
    end
    return page_states
end

function ReaderPaging:onScrollPanRel(diff)
    if diff == 0 then return true end
    logger.dbg("pan relative height:", diff)
    local offset = Geom:new{x = 0, y = diff}
    local blank_area = Geom:new()
    blank_area:setSizeTo(self.view.visible_area)
    local new_page_states
    if diff > 0 then
        -- pan to scroll down
        local first_page_state = copyPageState(self.view.page_states[1])
        new_page_states = self:genPageStatesFromTop(
            first_page_state, blank_area, offset)
    elseif diff < 0 then
        local last_page_state = copyPageState(
            self.view.page_states[#self.view.page_states])
        new_page_states = self:genPageStatesFromBottom(
            last_page_state, blank_area, offset)
    end
    if #new_page_states == 0 then
        -- if we are already at the top of first page or bottom of the last
        -- page, new_page_states will be empty, in this case, nothing to update
        return true
    end
    self.view.page_states = new_page_states
    -- update current pageno to the very last part in current view
    self:_gotoPage(self.view.page_states[#self.view.page_states].page,
                   "scrolling")
    UIManager:setDirty(self.view.dialog, "partial")
    return true
end

function ReaderPaging:onScrollPageRel(page_diff, no_page_turn)
    if no_page_turn then return end -- see ReaderPaging:onGotoViewRel
    if page_diff == 0 then return true end
    if page_diff > 1 or page_diff < -1  then
        -- More than 1 page, don't bother with how far we've scrolled.
        self:onGotoRelativePage(Math.round(page_diff))
        return true
    elseif page_diff > 0 then
        -- page down, last page should be moved to top
        local last_page_state = table.remove(self.view.page_states)
        local last_visible_area = last_page_state.visible_area
        if self.ui.document:getNextPage(last_page_state.page) == 0 and
                last_visible_area.y + last_visible_area.h >= last_page_state.page_area.h then
            table.insert(self.view.page_states, last_page_state)
            self.ui:handleEvent(Event:new("EndOfBook"))
            return true
        end

        local blank_area = Geom:new()
        blank_area:setSizeTo(self.view.visible_area)
        local overlap = self.overlap
        local offset = Geom:new{
            x = 0,
            y = last_visible_area.h - overlap
        }
        self.view.page_states = self:genPageStatesFromTop(last_page_state, blank_area, offset)
    elseif page_diff < 0 then
        -- page up, first page should be moved to bottom
        local blank_area = Geom:new()
        blank_area:setSizeTo(self.view.visible_area)
        local overlap = self.overlap
        local first_page_state = table.remove(self.view.page_states, 1)
        local offset = Geom:new{
            x = 0,
            y = -first_page_state.visible_area.h + overlap
        }
        self.view.page_states = self:genPageStatesFromBottom(
            first_page_state, blank_area, offset)
    end
    -- update current pageno to the very last part in current view
    self:_gotoPage(self.view.page_states[#self.view.page_states].page, "scrolling")
    UIManager:setDirty(self.view.dialog, "partial")
    return true
end

function ReaderPaging:onGotoPageRel(diff, no_page_turn)
    -- HACK GEMINI: FORCE PAGE TURN FOR IMAGES (NO SCROLL)
    -- Si ce n'est pas du texte redistribuable (donc c'est une image/manga)
    if not self.ui.document.info.is_reflowable then
        -- On calcule la page cible directement
        local new_page = self.current_page + diff
        if new_page >= 1 and new_page <= self.number_of_pages then
             if no_page_turn then return true end
             self:_gotoPage(new_page)
             return true
        end
    end
    -- FIN HACK GEMINI (Si on continue, on tombe dans la logique standard)

    logger.dbg("goto relative page:", diff)
    local new_va = self.visible_area:copy()
    local x_pan_off, y_pan_off = 0, 0
    local right_to_left = self.ui.document.configurable.writing_direction and self.ui.document.configurable.writing_direction > 0
    local bottom_to_top = self.ui.zooming.zoom_bottom_to_top
    local h_progress = 1 - self.ui.zooming.zoom_overlap_h * (1/100)
    local v_progress = 1 - self.ui.zooming.zoom_overlap_v * (1/100)
    local old_va = self.visible_area
    local old_page = self.current_page
    local x, y, w, h = "x", "y", "w", "h"
    local x_diff = diff
    local y_diff = diff

    -- Adjust directions according to settings
    if self.ui.zooming.zoom_direction_vertical then  -- invert axes
        y, x, h, w = x, y, w, h
        h_progress, v_progress = v_progress, h_progress
        if right_to_left then
            x_diff, y_diff = -x_diff, -y_diff
        end
        if bottom_to_top then
            x_diff = -x_diff
        end
    elseif bottom_to_top then
        y_diff = -y_diff
    end
    if right_to_left then
        x_diff = -x_diff
    end

    if self.zoom_mode ~= "free" then
        x_pan_off = Math.roundAwayFromZero(self.visible_area[w] * h_progress * x_diff)
        y_pan_off = Math.roundAwayFromZero(self.visible_area[h] * v_progress * y_diff)
    end

    -- Auxiliary functions to (as much as possible) keep things clear
    -- If going backwards (diff < 0) "end" is equivalent to "beginning", "next" to "previous";
    -- in column mode, "line" is equivalent to "column".
    local function at_end(axis)
        -- returns true if we're at the end of line (axis = x) or page (axis = y)
        local len, _diff
        if axis == x then
            len, _diff = w, x_diff
        else
            len, _diff = h, y_diff
        end
        return old_va[axis] + old_va[len] + _diff > self.page_area[axis] + self.page_area[len]
            or old_va[axis] + _diff < self.page_area[axis]
    end
    local function goto_end(axis, _diff)
        -- updates view area to the end of line (axis = x) or page (axis = y)
        local len = axis == x and w or h
        _diff = _diff or (axis == x and x_diff or y_diff)
        new_va[axis] = _diff > 0
                    and old_va[axis] + self.page_area[len] - old_va[len]
                    or self.page_area[axis]
    end
    local function goto_next_line()
        new_va[y] = old_va[y] + y_pan_off
        goto_end(x, -x_diff)
    end
    local function goto_next_page()
        local new_page
        if self.ui.document:hasHiddenFlows() then
            local forward = diff > 0
            local pdiff = forward and math.ceil(diff) or math.ceil(-diff)
            new_page = self.current_page
            for i=1, pdiff do
                local test_page = forward and self.ui.document:getNextPage(new_page)
                                           or self.ui.document:getPrevPage(new_page)
                if test_page == 0 then -- start or end of document reached
                    if forward then
                        new_page = self.number_of_pages + 1 -- to trigger EndOfBook below
                    else
                        new_page = 0
                    end
                    break
                end
                new_page = test_page
            end
        else
            new_page = self.current_page + diff
        end
        if new_page > self.number_of_pages then
            if no_page_turn then return true end
            self.ui:handleEvent(Event:new("EndOfBook"))
            goto_end(y)
            goto_end(x)
        elseif new_page > 0 then
            if no_page_turn then return true end
            -- Be sure that the new and old view areas are reset so that no value is carried over to next page.
            -- Without this, we would have panned_y = new_va.y - old_va.y > 0, and panned_y will be added to the next page's y direction.
            -- This occurs when the current page has a y > 0 position (for example, a cropped page) and can fit the whole page height,
            -- while the next page needs scrolling in the height.
            self:_gotoPage(new_page)
            new_va = self.visible_area:copy()
            old_va = self.visible_area
            goto_end(y, -y_diff)
            goto_end(x, -x_diff)
        else
            goto_end(x)
        end
    end

    -- Move the view area towards line end
    new_va[x] = old_va[x] + x_pan_off
    new_va[y] = old_va[y]

    local prev_page = self.current_page

    -- Handle cases when the view area gets out of page boundaries
    local would_turn_page
    if not self.page_area:contains(new_va) then
        if not at_end(x) then
            goto_end(x)
        else
            goto_next_line()
            if not self.page_area:contains(new_va) then
                if not at_end(y) then
                    goto_end(y)
                else
                    would_turn_page = goto_next_page()
                end
            end
        end
    end
    if no_page_turn and would_turn_page then return end -- see ReaderPaging:onGotoViewRel

    if self.current_page == prev_page then
        -- Page number haven't changed when panning inside a page,
        -- but time may: keep the footer updated
        self.view.footer:onUpdateFooter(self.view.footer_visible)
    end

    -- signal panning update
    local panned_x, panned_y = math.floor(new_va.x - old_va.x), math.floor(new_va.y - old_va.y)
    self.view:PanningUpdate(panned_x, panned_y)

    -- Update dim area in ReaderView
    if self.view.page_overlap_enable then
        if self.current_page ~= old_page then
            self.view.dim_area:clear()
        else
            -- We're post PanningUpdate, recompute via self.visible_area instead of new_va for accuracy, it'll have been updated via ViewRecalculate
            panned_x, panned_y = math.floor(self.visible_area.x - old_va.x), math.floor(self.visible_area.y - old_va.y)

            self.view.dim_area.h = self.visible_area.h - math.abs(panned_y)
            self.view.dim_area.w = self.visible_area.w - math.abs(panned_x)
            if panned_y < 0 then
                self.view.dim_area.y = self.visible_area.h - self.view.dim_area.h
            else
                self.view.dim_area.y = 0
            end
            if panned_x < 0 then
                self.view.dim_area.x = self.visible_area.w - self.view.dim_area.w
            else
                self.view.dim_area.x = 0
            end
        end
    end

    return true
end

function ReaderPaging:onRedrawCurrentPage()
    self.ui:handleEvent(Event:new("PageUpdate", self.current_page))
    return true
end

-- wrapper for bounds checking
function ReaderPaging:_gotoPage(number, orig_mode)
    if number == self.current_page or not number then
        -- update footer even if we stay on the same page (like when
        -- viewing the bottom part of a page from a top part view)
        self.view.footer:onUpdateFooter(self.view.footer_visible)
        return true
    end
    if number > self.number_of_pages then
        logger.warn("page number too high: "..number.."!")
        number = self.number_of_pages
    elseif number < 1 then
        logger.warn("page number too low: "..number.."!")
        number = 1
    end
    -- this is an event to allow other controllers to be aware of this change
    self.ui:handleEvent(Event:new("PageUpdate", number, orig_mode))
    return true
end

function ReaderPaging:onGotoPage(number, pos)
    self:setPagePosition(number, 0)
    self:_gotoPage(number)
    if pos then
        local rect_p = Geom:new{ x = pos.x or 0, y = pos.y or 0 }
        local rect_s = Geom:new(rect_p):copy()
        rect_s:transformByScale(self.view.state.zoom)
        if self.view.page_scroll then
            self:onScrollPanRel(rect_s.y - self.view.page_area.y)
        else
            self.view:PanningUpdate(rect_s.x - self.view.visible_area.x, rect_s.y - self.view.visible_area.y)
        end
    elseif number == self.current_page then
        -- gotoPage emits this event only if the page changes
        self.ui:handleEvent(Event:new("PageUpdate", self.current_page))
    end
    return true
end

function ReaderPaging:onGotoRelativePage(number)
    local new_page = self.current_page
    local test_page = new_page
    local forward = number > 0
    for i=1, math.abs(number) do
        test_page = forward and self.ui.document:getNextPage(test_page)
                             or self.ui.document:getPrevPage(test_page)
        if test_page == 0 then -- start or end of document reached
            break
        end
        new_page = test_page
    end
    self:_gotoPage(new_page)
    return true
end

function ReaderPaging:onGotoPercentage(percentage)
    if percentage < 0 then percentage = 0 end
    if percentage > 1 then percentage = 1 end
    self:_gotoPage(math.floor(percentage*self.number_of_pages))
    return true
end

-- These might need additional work to behave fine in scroll
-- mode, and other zoom modes than Fit page
function ReaderPaging:onGotoNextChapter()
    local pageno = self.current_page
    local new_page = self.ui.toc:getNextChapter(pageno)
    if new_page then
        self.ui.link:addCurrentLocationToStack()
        self:onGotoPage(new_page)
    end
    return true
end

function ReaderPaging:onGotoPrevChapter()
    local pageno = self.current_page
    local new_page = self.ui.toc:getPreviousChapter(pageno)
    if new_page then
        self.ui.link:addCurrentLocationToStack()
        self:onGotoPage(new_page)
    end
    return true
end

function ReaderPaging:onReflowUpdated()
    self.ui:handleEvent(Event:new("RedrawCurrentPage"))
    self.ui:handleEvent(Event:new("RestoreZoomMode"))
    self.ui:handleEvent(Event:new("InitScrollPageStates"))
end

function ReaderPaging:onToggleReflow()
    self.view.document.configurable.text_wrap = bit.bxor(self.view.document.configurable.text_wrap, 1)
    self:onReflowUpdated()
end

return ReaderPaging

Hei02 avatar Dec 03 '25 14:12 Hei02