--[[--
ReaderView module handles all the screen painting for document browsing.
]]

local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local Device = require("device")
local Font = require("ui/font")
local Geom = require("ui/geometry")
local Event = require("ui/event")
local IconWidget = require("ui/widget/iconwidget")
local InfoMessage = require("ui/widget/infomessage")
local Notification = require("ui/widget/notification")
local OverlapGroup = require("ui/widget/overlapgroup")
local ReaderDogear = require("apps/reader/modules/readerdogear")
local ReaderFlipping = require("apps/reader/modules/readerflipping")
local ReaderFooter = require("apps/reader/modules/readerfooter")
local TextWidget = require("ui/widget/textwidget")
local UIManager = require("ui/uimanager")
local dbg = require("dbg")
local logger = require("logger")
local optionsutil = require("ui/data/optionsutil")
local Size = require("ui/size")
local time = require("ui/time")
local util = require("util")
local _ = require("gettext")
local Screen = Device.screen
local T = require("ffi/util").template

local ReaderView = OverlapGroup:extend{
    document = nil,
    view_modules = nil, -- array

    -- single page state
    state = nil, -- table
    outer_page_color = Blitbuffer.gray(G_defaults:readSetting("DOUTER_PAGE_COLOR") * (1/15)),
    -- highlight with "lighten" or "underscore" or "strikeout" or "invert"
    highlight = nil, -- table
    highlight_visible = true,
    note_mark_line_w = 3, -- side line thickness
    note_mark_sign = nil,
    note_mark_pos_x1 = nil, -- page 1
    note_mark_pos_x2 = nil, -- page 2 in two-page mode
    -- PDF/DjVu continuous paging
    page_scroll = nil,
    page_bgcolor = Blitbuffer.gray(G_defaults:readSetting("DBACKGROUND_COLOR") * (1/15)),
    page_states = nil, -- table
    -- properties of the gap drawn between each page in scroll mode:
    page_gap = nil, -- table
    -- DjVu page rendering mode (used in djvu.c:drawPage())
    render_mode = nil, -- default to COLOR, will be set in onReadSettings()
    -- Crengine view mode
    view_mode = G_defaults:readSetting("DCREREADER_VIEW_MODE"), -- default to page mode
    hinting = true,
    emitHintPageEvent = nil,

    -- visible area within current viewing page
    visible_area = nil,
    -- dimen for current viewing page
    page_area = nil,
    -- dimen for area to dim
    dim_area = nil,
    -- has footer
    footer_visible = nil,
    -- has dogear
    dogear_visible = false,
    -- in flipping state
    flipping_visible = false,
    -- to ensure periodic flush of settings
    settings_last_save_time = nil,
    -- might be directly updated by readerpaging/readerrolling when
    -- they handle some panning/scrolling, to request "fast" refreshes
    currently_scrolling = false,

    -- image content stats of the current page, if supported by the Document engine
    img_count = nil,
    img_coverage = nil,
}

function ReaderView:init()
    self.view_modules = {}

    self.state = {
        page = nil,
        pos = 0,
        zoom = 1.0,
        rotation = 0,
        gamma = 1.0,
        offset = nil,
        bbox = nil,
    }
    self.highlight = {
        page_boxes = {},  -- hashmap; boxes in pages, used to draw visited pages in "page" view modes
        visible_boxes = nil, -- array; visible boxes in the current page, used by ReaderHighlight:onTap()
        lighten_factor = G_reader_settings:readSetting("highlight_lighten_factor", 0.2),
        note_mark = G_reader_settings:readSetting("highlight_note_marker"),
        temp_drawer = "invert",
        temp = {},
        saved_drawer = "lighten",
        -- NOTE: Unfortunately, yellow tends to look like absolute ass on Kaleido panels...
        saved_color = Screen:isColorEnabled() and "yellow" or "gray",
        indicator = nil, -- geom: non-touch highlight position indicator: {x = 50, y=50}
    }
    self.page_states = {}
    self.page_gap = {
        -- color (0 = white, 8 = gray, 15 = black)
        color = Blitbuffer.gray((G_reader_settings:readSetting("page_gap_color") or 8) * (1/15)),
    }
    self.visible_area = Geom:new{x = 0, y = 0, w = 0, h = 0}
    self.page_area = Geom:new{x = 0, y = 0, w = 0, h = 0}
    self.dim_area = Geom:new{x = 0, y = 0, w = 0, h = 0}

    -- Zero-init for sanity
    self.img_count = 0
    self.img_coverage = 0

    self:addWidgets()
    self.emitHintPageEvent = function()
        self.ui:handleEvent(Event:new("HintPage", self.hinting))
    end

    -- We've subclassed OverlapGroup, go through its init, because it does some funky stuff with self.dimen...
    OverlapGroup.init(self)
end

function ReaderView:addWidgets()
    self.dogear = ReaderDogear:new{
        view = self,
        ui = self.ui,
    }
    self.footer = ReaderFooter:new{
        view = self,
        ui = self.ui,
    }
    self.flipping = ReaderFlipping:new{
        view = self,
        ui = self.ui,
    }
    local arrow_size = Screen:scaleBySize(16)
    self.arrow = IconWidget:new{
        icon = "control.expand.alpha",
        width = arrow_size,
        height = arrow_size,
        alpha = true, -- Keep the alpha layer intact, the fill opacity is set at 75%
    }

    self[1] = self.dogear
    self[2] = self.footer
    self[3] = self.flipping
end

--[[--
Register a view UI widget module for document browsing.

@tparam string name module name, registered widget can be accessed by readerui.view.view_modules[name].
@tparam ui.widget.widget.Widget widget paintable widget, i.e. has a paintTo method.

@usage
local ImageWidget = require("ui/widget/imagewidget")
local dummy_image = ImageWidget:new{
    file = "resources/koreader.png",
}
-- the image will be painted on all book pages
readerui.view:registerViewModule('dummy_image', dummy_image)
]]
function ReaderView:registerViewModule(name, widget)
    if not widget.paintTo then
        print(name .. " view module does not have paintTo method!")
        return
    end
    widget.view = self
    widget.ui = self.ui
    self.view_modules[name] = widget
end

function ReaderView:resetLayout()
    for _, widget in ipairs(self) do
        widget:resetLayout()
    end
    for _, m in pairs(self.view_modules) do
        if m.resetLayout then m:resetLayout() end
    end
end

function ReaderView:paintTo(bb, x, y)
    dbg:v("readerview painting", self.visible_area, "to", x, y)
    if self.page_scroll then
        self:drawPageBackground(bb, x, y)
    else
        self:drawPageSurround(bb, x, y)
    end

    -- draw page content
    if self.ui.paging then
        if self.page_scroll then
            self:drawScrollPages(bb, x, y)
        else
            self:drawSinglePage(bb, x, y)
        end
    else
        if self.view_mode == "page" then
            self:drawPageView(bb, x, y)
        elseif self.view_mode == "scroll" then
            self:drawScrollView(bb, x, y)
        end
        local should_repaint = self.ui.rolling:handlePartialRerendering()
        if should_repaint then
            -- ReaderRolling may have repositionned on another page containing
            -- the xpointer of the top of the original page: recalling this is
            -- all there is to do.
            self:paintTo(bb, x, y)
            return
        end
    end

    -- mark last read area of overlapped pages
    if not self.dim_area:isEmpty() and self:isOverlapAllowed() then
        if self.page_overlap_style == "dim" then
            -- NOTE: "dim", as in make black text fainter, e.g., lighten the rect
            bb:lightenRect(self.dim_area.x, self.dim_area.y, self.dim_area.w, self.dim_area.h)
        else
            -- Paint at the proper y origin depending on whether we paged forward (dim_area.y == 0) or backward
            local paint_y = self.dim_area.y == 0 and self.dim_area.h or self.dim_area.y
            if self.page_overlap_style == "arrow" then
                local center_offset = bit.rshift(self.arrow.height, 1)
                self.arrow:paintTo(bb, 0, paint_y - center_offset)
            elseif self.page_overlap_style == "line" then
                bb:paintRect(0, paint_y, self.dim_area.w, Size.line.medium, Blitbuffer.COLOR_DARK_GRAY)
            elseif self.page_overlap_style == "dashed_line" then
                for i = 0, self.dim_area.w - 20, 20 do
                    bb:paintRect(i, paint_y, 14, Size.line.medium, Blitbuffer.COLOR_DARK_GRAY)
                end
            end
        end
    end

    -- draw saved highlight (will return true if any of them are in color)
    local colorful
    if self.highlight_visible then
        colorful = self:drawSavedHighlight(bb, x, y)
    end
    -- draw temporary highlight
    if self.highlight.temp then
        self:drawTempHighlight(bb, x, y)
    end
    -- draw highlight position indicator for non-touch
    if self.highlight.indicator then
        self:drawHighlightIndicator(bb, x, y)
    end
    -- paint dogear
    if self.dogear_visible then
        self.dogear:paintTo(bb, x, y)
    end
    -- paint footer
    if self.footer_visible then
        self.footer:paintTo(bb, x, y)
    end
    -- paint top left corner indicator
    self.flipping:paintTo(bb, x, y)
    -- paint view modules
    for _, m in pairs(self.view_modules) do
        m:paintTo(bb, x, y)
    end
    -- stop activity indicator
    self.ui:handleEvent(Event:new("StopActivityIndicator"))

    -- Most pages should not require dithering, but the dithering flag is also used to engage Kaleido waveform modes,
    -- so we'll set the flag to true if any of our drawn highlights were in color.
    self.dialog.dithered = colorful
    -- For KOpt, let the user choose.
    if self.ui.paging then
        if self.document.hw_dithering then
            self.dialog.dithered = true
        end
    else
        -- Whereas for CRe,
        -- If we're attempting to show a large enough amount of image data, request dithering (without triggering another repaint ;)).
        local img_count, img_coverage = self.document:getDrawnImagesStatistics()
        -- We also want to deal with paging *away* from image content, which would have adverse effect on ghosting.
        local coverage_diff = math.abs(img_coverage - self.img_coverage)
        -- Which is why we remember the stats of the *previous* page.
        self.img_count, self.img_coverage = img_count, img_coverage
        if img_coverage >= 0.075 or coverage_diff >= 0.075 then
            -- Request dithering on the actual page with image content
            if img_coverage >= 0.075 then
                self.dialog.dithered = true
            end
            -- Request a flashing update while we're at it, but only if it's the first time we're painting it
            if self.state.drawn == false and G_reader_settings:nilOrTrue("refresh_on_pages_with_images") then
                UIManager:setDirty(nil, "full")
            end
        end
        self.state.drawn = true
    end
end

--[[
Given coordinates on the screen return position in original page
]]--
function ReaderView:screenToPageTransform(pos)
    if self.ui.paging then
        if self.page_scroll then
            return self:getScrollPagePosition(pos)
        else
            return self:getSinglePagePosition(pos)
        end
    else
        pos.page = self.document:getCurrentPage()
        return pos
    end
end

--[[
Given rectangle in original page return rectangle on the screen
]]--
function ReaderView:pageToScreenTransform(page, rect)
    if self.ui.paging then
        if self.page_scroll then
            return self:getScrollPageRect(page, rect)
        else
            return self:getSinglePageRect(rect)
        end
    else
        return rect
    end
end

--[[
Get page area on screen for a given page number
--]]
function ReaderView:getScreenPageArea(page)
    if self.ui.paging then
        local area = Geom:new{x = 0, y = 0}
        if self.page_scroll then
            for _, state in ipairs(self.page_states) do
                if page ~= state.page then
                    area.y = area.y + state.visible_area.h + state.offset.y
                    area.y = area.y + self.page_gap.height
                else
                    area.x = state.offset.x
                    area.w = state.visible_area.w
                    area.h = state.visible_area.h
                    return area
                end
            end
        else
            area.x = self.state.offset.x
            area.y = self.state.offset.y
            area.w = self.visible_area.w
            area.h = self.visible_area.h
            return area
        end
    else
        return self.dimen
    end
end

function ReaderView:drawPageBackground(bb, x, y)
    bb:paintRect(x, y, self.dimen.w, self.dimen.h, self.page_bgcolor)
end

function ReaderView:drawPageSurround(bb, x, y)
    if self.dimen.h > self.visible_area.h then
        bb:paintRect(x, y, self.dimen.w, self.state.offset.y, self.outer_page_color)
        local bottom_margin = y + self.visible_area.h + self.state.offset.y
        bb:paintRect(x, bottom_margin, self.dimen.w, self.state.offset.y +
            self.footer:getHeight(), self.outer_page_color)
    end
    if self.dimen.w > self.visible_area.w then
        bb:paintRect(x, y, self.state.offset.x, self.dimen.h, self.outer_page_color)
        bb:paintRect(x + self.dimen.w - self.state.offset.x - 1, y,
            self.state.offset.x + 1, self.dimen.h, self.outer_page_color)
    end
end

function ReaderView:drawScrollPages(bb, x, y)
    local pos = Geom:new{x = x , y = y}
    for page, state in ipairs(self.page_states) do
        self.document:drawPage(
            bb,
            pos.x + state.offset.x,
            pos.y + state.offset.y,
            state.visible_area,
            state.page,
            state.zoom,
            state.rotation,
            state.gamma)
        pos.y = pos.y + state.visible_area.h
        -- draw page gap if not the last part
        if page ~= #self.page_states then
            self:drawPageGap(bb, pos.x, pos.y)
            pos.y = pos.y + self.page_gap.height
        end
    end
    UIManager:nextTick(self.emitHintPageEvent)
end

function ReaderView:getCurrentPageList()
    local pages = {}
    if self.ui.paging then
        if self.page_scroll then
            for _, state in ipairs(self.page_states) do
                table.insert(pages, state.page)
            end
        else
            table.insert(pages, self.state.page)
        end
    end
    return pages
end

function ReaderView:getScrollPagePosition(pos)
    local x_p, y_p
    local x_s, y_s = pos.x, pos.y
    for _, state in ipairs(self.page_states) do
        if y_s < state.visible_area.h + state.offset.y then
            y_p = (state.visible_area.y + y_s - state.offset.y) / state.zoom
            x_p = (state.visible_area.x + x_s - state.offset.x) / state.zoom
            return {
                x = x_p,
                y = y_p,
                page = state.page,
                zoom = state.zoom,
                rotation = state.rotation,
            }
        else
            y_s = y_s - state.visible_area.h - self.page_gap.height
        end
    end
end

function ReaderView:getScrollPageRect(page, rect_p)
    local rect_s = Geom:new()
    for _, state in ipairs(self.page_states) do
        local trans_p = Geom:new(rect_p):copy()
        trans_p:transformByScale(state.zoom, state.zoom)
        if page == state.page and state.visible_area:intersectWith(trans_p) then
            rect_s.x = rect_s.x + state.offset.x + trans_p.x - state.visible_area.x
            rect_s.y = rect_s.y + state.offset.y + trans_p.y - state.visible_area.y
            rect_s.w = trans_p.w
            rect_s.h = trans_p.h
            return rect_s
        end
        rect_s.y = rect_s.y + state.visible_area.h + self.page_gap.height
    end
end

function ReaderView:drawPageGap(bb, x, y)
    bb:paintRect(x, y, self.dimen.w, self.page_gap.height, self.page_gap.color)
end

function ReaderView:drawSinglePage(bb, x, y)
    self.document:drawPage(
        bb,
        x + self.state.offset.x,
        y + self.state.offset.y,
        self.visible_area,
        self.state.page,
        self.state.zoom,
        self.state.rotation,
        self.state.gamma)
    UIManager:nextTick(self.emitHintPageEvent)
end

function ReaderView:getSinglePagePosition(pos)
    local x_s, y_s = pos.x, pos.y
    return {
        x = (self.visible_area.x + x_s - self.state.offset.x) / self.state.zoom,
        y = (self.visible_area.y + y_s - self.state.offset.y) / self.state.zoom,
        page = self.state.page,
        zoom = self.state.zoom,
        rotation = self.state.rotation,
    }
end

function ReaderView:getSinglePageRect(rect_p)
    local rect_s = Geom:new()
    local trans_p = Geom:new(rect_p):copy()
    trans_p:transformByScale(self.state.zoom, self.state.zoom)
    if self.visible_area:intersectWith(trans_p) then
        rect_s.x = self.state.offset.x + trans_p.x - self.visible_area.x
        rect_s.y = self.state.offset.y + trans_p.y - self.visible_area.y
        rect_s.w = trans_p.w
        rect_s.h = trans_p.h
        return rect_s
    end
end

function ReaderView:drawPageView(bb, x, y)
    self.document:drawCurrentViewByPage(
        bb,
        x + self.state.offset.x,
        y + self.state.offset.y,
        self.visible_area,
        self.state.page)
end

function ReaderView:drawScrollView(bb, x, y)
    self.document:drawCurrentViewByPos(
        bb,
        x + self.state.offset.x,
        y + self.state.offset.y,
        self.visible_area,
        self.state.pos)
end

function ReaderView:drawHighlightIndicator(bb, x, y)
    local rect = self.highlight.indicator
    -- paint big cross line +
    bb:paintRect(
        rect.x,
        rect.y + rect.h / 2 - Size.border.thick / 2,
        rect.w,
        Size.border.thick
    )
    bb:paintRect(
        rect.x + rect.w / 2 - Size.border.thick / 2,
        rect.y,
        Size.border.thick,
        rect.h
    )
end

function ReaderView:drawTempHighlight(bb, x, y)
    for page, boxes in pairs(self.highlight.temp) do
        for i = 1, #boxes do
            local rect = self:pageToScreenTransform(page, boxes[i])
            if rect then
                self:drawHighlightRect(bb, x, y, rect, self.highlight.temp_drawer)
            end
        end
    end
end

function ReaderView:drawSavedHighlight(bb, x, y)
    self.highlight.visible_boxes = {}
    if #self.ui.annotation.annotations > 0 then
        if self.ui.paging then
            return self:drawPageSavedHighlight(bb, x, y)
        else
            return self:drawXPointerSavedHighlight(bb, x, y)
        end
    end
end

function ReaderView:drawPageSavedHighlight(bb, x, y)
    local do_cache = not self.page_scroll and self.document.configurable.text_wrap == 0
    local colorful
    local pages = self:getCurrentPageList()
    for _, page in ipairs(pages) do
        if self.highlight.page_boxes[page] ~= nil then -- cached
            for _, box in ipairs(self.highlight.page_boxes[page]) do
                local rect = self:pageToScreenTransform(page, box.rect)
                if rect then
                    table.insert(self.highlight.visible_boxes, box)
                    self:drawHighlightRect(bb, x, y, rect, box.drawer, box.color, box.draw_mark)
                    if box.colorful then
                        colorful = true
                    end
                end
            end
        else -- not cached
            if do_cache then
                self.highlight.page_boxes[page] = {}
            end
            local items, idx_offset = self.ui.highlight:getPageSavedHighlights(page)
            for index, item in ipairs(items) do
                local boxes = self.document:getPageBoxesFromPositions(page, item.pos0, item.pos1)
                if boxes then
                    local drawer = item.drawer
                    local color = item.color and Blitbuffer.colorFromName(item.color)
                    if not colorful and color and not Blitbuffer.isColor8(color) then
                        colorful = true
                    end
                    local draw_note_mark = item.note and true or nil
                    for _, box in ipairs(boxes) do
                        local rect = self:pageToScreenTransform(page, box)
                        if rect then
                            local hl_box = {
                                index     = item.parent or (index + idx_offset), -- index in annotations
                                rect      = box,
                                drawer    = drawer,
                                color     = color,
                                draw_mark = draw_note_mark,
                                colorful  = colorful,
                            }
                            if do_cache then
                                table.insert(self.highlight.page_boxes[page], hl_box)
                            end
                            table.insert(self.highlight.visible_boxes, hl_box)
                            self:drawHighlightRect(bb, x, y, rect, drawer, color, draw_note_mark)
                            draw_note_mark = draw_note_mark and false -- side mark in the first line only
                        else
                            -- some boxes are not displayed in the currently visible part of the page,
                            -- the page boxes cannot be cached
                            do_cache = false
                            self.highlight.page_boxes[page] = nil
                        end
                    end
                end
            end
        end
    end
    return colorful
end

function ReaderView:drawXPointerSavedHighlight(bb, x, y)
    local do_cache = self.view_mode == "page"
    local colorful
    local page = self.document:getCurrentPage()
    if self.highlight.page_boxes[page] ~= nil then -- cached
        for _, box in ipairs(self.highlight.page_boxes[page]) do
            table.insert(self.highlight.visible_boxes, box)
            self:drawHighlightRect(bb, x, y, box.rect, box.drawer, box.color, box.draw_mark)
            if box.colorful then
                colorful = true
            end
        end
    else -- not cached
        if do_cache then
            self.highlight.page_boxes[page] = {}
        end
        -- Even in page mode, it's safer to use pos and ui.dimen.h
        -- than pages' xpointers pos, even if ui.dimen.h is a bit
        -- larger than pages' heights
        local cur_view_top = self.document:getCurrentPos()
        local cur_view_bottom
        if self.view_mode == "page" and self.document:getVisiblePageCount() > 1 then
            cur_view_bottom = cur_view_top + 2 * self.ui.dimen.h
        else
            cur_view_bottom = cur_view_top + self.ui.dimen.h
        end
        for index, item in ipairs(self.ui.annotation.annotations) do
            if item.drawer then
                -- document:getScreenBoxesFromPositions() is expensive, so we
                -- first check if this item is on current page
                local start_pos = self.document:getPosFromXPointer(item.pos0)
                if start_pos > cur_view_bottom then break end -- this and all next highlights are after the current page
                local end_pos = self.document:getPosFromXPointer(item.pos1)
                if end_pos >= cur_view_top then
                    local boxes = self.document:getScreenBoxesFromPositions(item.pos0, item.pos1, true) -- get_segments=true
                    if boxes then
                        local drawer = item.drawer
                        local color = item.color and Blitbuffer.colorFromName(item.color)
                        if not colorful and color and not Blitbuffer.isColor8(color) then
                            colorful = true
                        end
                        local draw_note_mark = item.note and true or nil
                        for _, box in ipairs(boxes) do
                            if box.h ~= 0 then
                                local hl_box = {
                                    index     = index,
                                    rect      = box,
                                    drawer    = drawer,
                                    color     = color,
                                    draw_mark = draw_note_mark,
                                    colorful  = colorful,
                                }
                                if do_cache then
                                    table.insert(self.highlight.page_boxes[page], hl_box)
                                end
                                table.insert(self.highlight.visible_boxes, hl_box)
                                self:drawHighlightRect(bb, x, y, box, drawer, color, draw_note_mark)
                                draw_note_mark = draw_note_mark and false -- side mark in the first line only
                            end
                        end
                    end
                end
            end
        end
    end
    return colorful
end

function ReaderView:drawHighlightRect(bb, _x, _y, rect, drawer, color, draw_note_mark)
    local x, y, w, h = rect.x, rect.y, rect.w, rect.h
    if drawer == "lighten" or drawer == "invert" then
        local pct = G_reader_settings:readSetting("highlight_height_pct")
        if pct ~= nil then
            h = math.floor(h * pct / 100)
            y = y + math.ceil((rect.h - h) / 2)
        end
    end
    if drawer == "lighten" then
        if not color then
            bb:darkenRect(x, y, w, h, self.highlight.lighten_factor)
        else
            if bb:getInverse() == 1 then
                -- MUL doesn't really work on a black background, so, switch to OVER if we're in software nightmode...
                -- NOTE: If we do *not* invert the color here, it *will* get inverted by the blitter given that the target bb is inverted.
                --       While not particularly pretty, this (roughly) matches with hardware nightmode, *and* how MuPDF renders highlights...
                --       But it's *really* not pretty (https://github.com/koreader/koreader/pull/11044#issuecomment-1902886069), so we'll fix it ;p.
                local c = Blitbuffer.ColorRGB32(color.r, color.g, color.b, 0xFF * self.highlight.lighten_factor):invert()
                bb:blendRectRGB32(x, y, w, h, c)
            else
                bb:multiplyRectRGB(x, y, w, h, color)
            end
        end
    elseif drawer == "underscore" then
        if not color then
            color = Blitbuffer.COLOR_GRAY_4
        end
        if Blitbuffer.isColor8(color) then
            bb:paintRect(x, y + h - 1, w, Size.line.thick, color)
        else
            bb:paintRectRGB32(x, y + h - 1, w, Size.line.thick, color)
        end
    elseif drawer == "strikeout" then
        if not color then
            color = Blitbuffer.COLOR_BLACK
        end
        local line_y = y + math.floor(h / 2) + 1
        if self.ui.paging then
            line_y = line_y + 2
        end
        if Blitbuffer.isColor8(color) then
            bb:paintRect(x, line_y, w, Size.line.medium, color)
        else
            bb:paintRectRGB32(x, line_y, w, Size.line.medium, color)
        end
    elseif drawer == "invert" then
        bb:invertRect(x, y, w, h)
    end
    if self.highlight.note_mark ~= nil and draw_note_mark ~= nil then
        color = color or Blitbuffer.COLOR_BLACK
        if self.highlight.note_mark == "underline" then
            -- With most annotation styles, we'd risk making this invisible if we used the same color,
            -- so, always draw this in black.
            bb:paintRect(x, y + h - 1, w, Size.line.medium, Blitbuffer.COLOR_BLACK)
        else
            local note_mark_pos_x
            if self.ui.paging or
                    (self.document:getVisiblePageCount() == 1) or -- one-page mode
                    (x < Screen:getWidth() / 2) then -- page 1 in two-page mode
                note_mark_pos_x = self.note_mark_pos_x1
            else
                note_mark_pos_x = self.note_mark_pos_x2
            end
            if self.highlight.note_mark == "sideline" then
                if Blitbuffer.isColor8(color) then
                    bb:paintRect(note_mark_pos_x, y, self.note_mark_line_w, rect.h, color)
                else
                    bb:paintRectRGB32(note_mark_pos_x, y, self.note_mark_line_w, rect.h, color)
                end
            elseif self.highlight.note_mark == "sidemark" then
                if draw_note_mark then
                    self.note_mark_sign:paintTo(bb, note_mark_pos_x, y)
                end
            end
        end
    end
end

function ReaderView:getPageArea(page, zoom, rotation)
    if self.use_bbox then
        return self.document:getUsedBBoxDimensions(page, zoom, rotation)
    else
        return self.document:getPageDimensions(page, zoom, rotation)
    end
end

--[[
This method is supposed to be only used by ReaderPaging
--]]
function ReaderView:recalculate()
    -- Start by resetting the dithering flag early, so it doesn't carry over from the previous page.
    self.dialog.dithered = nil

    if self.ui.paging and self.state.page then
        self.page_area = self:getPageArea(
            self.state.page,
            self.state.zoom,
            self.state.rotation)
        -- reset our size
        self.visible_area:setSizeTo(self.dimen)
        if self.footer_visible and not self.footer.settings.reclaim_height then
            self.visible_area.h = self.visible_area.h - self.footer:getHeight()
        end
        if self.document.configurable.writing_direction == 0 then
            -- starts from left of page_area
            self.visible_area.x = self.page_area.x
        else
            -- start from right of page_area
            self.visible_area.x = self.page_area.x + self.page_area.w - self.visible_area.w
        end
        -- Check if we are in zoom_bottom_to_top
        if self.document.configurable.zoom_direction and self.document.configurable.zoom_direction >= 2 and self.document.configurable.zoom_direction <= 5 then
            -- starts from bottom of page_area
            self.visible_area.y = self.page_area.y + self.page_area.h - self.visible_area.h
        else
            -- starts from top of page_area
            self.visible_area.y = self.page_area.y
        end
        if not self.page_scroll then
            -- and recalculate it according to page size
            self.visible_area:offsetWithin(self.page_area, 0, 0)
        end
        -- clear dim area
        self.dim_area:clear()
        self.ui:handleEvent(
            Event:new("ViewRecalculate", self.visible_area, self.page_area))
    else
        self.visible_area:setSizeTo(self.dimen)
    end
    self.state.offset = Geom:new{x = 0, y = 0}
    if self.dimen.h > self.visible_area.h then
        if self.footer_visible and not self.footer.settings.reclaim_height then
            self.state.offset.y = (self.dimen.h - (self.visible_area.h + self.footer:getHeight())) / 2
        else
            self.state.offset.y = (self.dimen.h - self.visible_area.h) / 2
        end
    end
    if self.dimen.w > self.visible_area.w then
        self.state.offset.x = (self.dimen.w - self.visible_area.w) / 2
    end

    self:setupNoteMarkPosition()

    -- Flag a repaint so self:paintTo will be called
    -- NOTE: This is also unfortunately called during panning, essentially making sure we'll never be using "fast" for pans ;).
    UIManager:setDirty(self.dialog, self.currently_scrolling and "fast" or "partial")
end

function ReaderView:PanningUpdate(dx, dy)
    logger.dbg("pan by", dx, dy)
    local old = self.visible_area:copy()
    self.visible_area:offsetWithin(self.page_area, dx, dy)
    if self.visible_area ~= old then
        -- flag a repaint
        UIManager:setDirty(self.dialog, "partial")
        logger.dbg("on pan: page_area", self.page_area)
        logger.dbg("on pan: visible_area", self.visible_area)
        self.ui:handleEvent(
            Event:new("ViewRecalculate", self.visible_area, self.page_area))
    end
    return true
end

function ReaderView:PanningStart(x, y)
    logger.dbg("panning start", x, y)
    if not self.panning_visible_area then
        self.panning_visible_area = self.visible_area:copy()
    end
    self.visible_area = self.panning_visible_area:copy()
    self.visible_area:offsetWithin(self.page_area, x, y)
    self.ui:handleEvent(Event:new("ViewRecalculate", self.visible_area, self.page_area))
    UIManager:setDirty(self.dialog, "partial")
end

function ReaderView:PanningStop()
    self.panning_visible_area = nil
end

function ReaderView:SetZoomCenter(x, y)
    local old = self.visible_area:copy()
    self.visible_area:centerWithin(self.page_area, x, y)
    if self.visible_area ~= old then
        self.ui:handleEvent(Event:new("ViewRecalculate", self.visible_area, self.page_area))
        UIManager:setDirty(self.dialog, "partial")
    end
end

function ReaderView:getViewContext()
    if self.page_scroll then
        return self.page_states
    else
        return {
            {
                page = self.state.page,
                pos = self.state.pos,
                zoom = self.state.zoom,
                rotation = self.state.rotation,
                gamma = self.state.gamma,
                offset = self.state.offset:copy(),
                bbox = self.state.bbox,
            },
            self.visible_area:copy(),
            self.page_area:copy(),
        }
    end
end

function ReaderView:restoreViewContext(ctx)
    -- The format of the context is different depending on page_scroll.
    -- If we're asked to restore the other format, just ignore it
    -- (our only caller, ReaderPaging:onRestoreBookLocation(), will
    -- at least change to the page of the context, which is all that
    -- can be done when restoring from a different mode)
    if self.page_scroll then
        if ctx[1] and ctx[1].visible_area then
            self.page_states = ctx
            return true
        end
    else
        if ctx[1] and ctx[1].pos then
            self.state = ctx[1]
            self.visible_area = ctx[2]
            self.page_area = ctx[3]
            return true
        end
    end
    return false
end

function ReaderView:onSetRotationMode(mode)
    local old_mode = Screen:getRotationMode()
    if mode ~= nil and mode ~= old_mode then
        Screen:setRotationMode(mode)
        self:rotate(mode, old_mode)
    end
end

function ReaderView:rotate(mode, old_mode)
        -- NOTE: We cannot rely on getScreenMode, as it actually checks the screen dimensions, instead of the rotation mode.
        --       (i.e., it returns how the screen *looks* like, not how it's oriented relative to its native layout).
        --       This would horribly break if you started in Portrait (both rotation and visually),
        --       then resized your window to a Landscape layout *without* changing the rotation.
        --       If you then attempted to switch to a Landscape *rotation*, it would mistakenly think the layout hadn't changed!
        --       So, instead, as we're concerned with *rotation* layouts, just compare the two.
        --       We use LinuxFB-style constants, so, Portraits are even, Landscapes are odds, making this trivial.
    local matching_orientation = bit.band(mode, 1) == bit.band(old_mode, 1)
    if matching_orientation then
        -- No layout change, just rotate & repaint with a flash
        UIManager:setDirty(self.dialog, "full")
    else
        UIManager:setDirty(nil, "full") -- SetDimensions will only request a partial, we want a flash
        local new_screen_size = Screen:getSize()
        self.ui:handleEvent(Event:new("SetDimensions", new_screen_size))
        self.ui:onScreenResize(new_screen_size)
        self.ui:handleEvent(Event:new("InitScrollPageStates"))
    end
    Notification:notify(T(_("Rotation mode set to: %1"), optionsutil:getOptionText("SetRotationMode", mode)))
end

function ReaderView:onSetDimensions(dimensions)
    self:resetLayout()
    self.dimen = dimensions
    -- recalculate view
    self:recalculate()
end

function ReaderView:onRestoreDimensions(dimensions)
    self:resetLayout()
    self.dimen = dimensions
    -- recalculate view
    self:recalculate()
end

function ReaderView:onSetScrollMode(page_scroll)
    if self.ui.paging and page_scroll
            and self.ui.zooming.paged_modes[self.zoom_mode]
            and self.document.configurable.text_wrap == 0 then
        UIManager:show(InfoMessage:new{
            text = _([[
Continuous view (scroll mode) works best with zoom to page width, zoom to content width or zoom to rows.

In combination with zoom to fit page, page height, content height, content or columns, continuous view can cause unexpected shifts when turning pages.]]),
            timeout = 5,
        })
    end

    self:resetHighlightBoxesCache()
    self.page_scroll = page_scroll
    if not page_scroll then
        self.document.configurable.page_scroll = 0
    end
    self:recalculate()
    self.ui:handleEvent(Event:new("InitScrollPageStates"))
end

function ReaderView:onReadSettings(config)
    if self.ui.paging then
        self.document:setTileCacheValidity(config:readSetting("tile_cache_validity_ts"))
        self.render_mode = config:readSetting("render_mode") or G_defaults:readSetting("DRENDER_MODE")
        self.document.render_mode = self.render_mode
        if config:has("gamma") then -- old doc contrast setting
            config:saveSetting("kopt_contrast", config:readSetting("gamma"))
            config:delSetting("gamma")
        end
    end
    if G_reader_settings:nilOrFalse("lock_rotation") then
        local setting_name = self.ui.paging and "kopt_rotation_mode" or "copt_rotation_mode"
        -- document.configurable.rotation_mode is not ready yet
        local rotation_mode = config:readSetting(setting_name)
                           or G_reader_settings:readSetting(setting_name)
                           or Screen.DEVICE_ROTATED_UPRIGHT
        self:onSetRotationMode(rotation_mode)
    end
    self:resetLayout()
    self.page_scroll = (config:readSetting("kopt_page_scroll") or self.document.configurable.page_scroll) == 1
    if config:has("inverse_reading_order") then
        self.inverse_reading_order = config:isTrue("inverse_reading_order")
    else
        self.inverse_reading_order = G_reader_settings:isTrue("inverse_reading_order")
    end
    if config:has("invert_ui_layout") then
        self.invert_ui_layout = config:isTrue("invert_ui_layout")
    else
        self.invert_ui_layout = G_reader_settings:isTrue("invert_ui_layout")
    end
    self.footer:invertProgressBar(self.invert_ui_layout)
    self.page_overlap_enable = config:isTrue("show_overlap_enable") or G_reader_settings:isTrue("page_overlap_enable") or G_defaults:readSetting("DSHOWOVERLAP")
    self.page_overlap_style = config:readSetting("page_overlap_style") or G_reader_settings:readSetting("page_overlap_style") or "dim"
    self.page_gap.height = Screen:scaleBySize(config:readSetting("kopt_page_gap_height")
                                              or G_reader_settings:readSetting("kopt_page_gap_height")
                                              or 8)
end

function ReaderView:shouldInvertBiDiLayoutMirroring()
    return self.invert_ui_layout
end

function ReaderView:onToggleUILayoutMiroring(toggle)
    if toggle == nil then
        toggle = not self.invert_ui_layout
    end
    if self.invert_ui_layout ~= toggle then
        self.invert_ui_layout = toggle
        self.footer:invertProgressBar(self.invert_ui_layout)
    end
    return true
end

function ReaderView:onPageUpdate(new_page_no)
    self.state.page = new_page_no
    self.state.drawn = false
    self:recalculate()
    self.highlight.temp = {}
    self:checkAutoSaveSettings()
end

function ReaderView:onPosUpdate(new_pos)
    self.state.pos = new_pos
    self:recalculate()
    self.highlight.temp = {}
    self:checkAutoSaveSettings()
end

function ReaderView:onZoomUpdate(zoom)
    self.state.zoom = zoom
    self:recalculate()
    self.highlight.temp = {}
end

function ReaderView:onBBoxUpdate(bbox)
    self.use_bbox = bbox and true or false
end

--- @note: From ReaderRotation, which was broken, and has been removed in #12658
function ReaderView:onRotationUpdate(rotation)
    self.state.rotation = rotation
    self:recalculate()
end

function ReaderView:onPageChangeAnimation(forward)
    if Device:canDoSwipeAnimation() and G_reader_settings:isTrue("swipe_animations") then
        if self.inverse_reading_order then forward = not forward end
        Screen:setSwipeAnimations(true)
        Screen:setSwipeDirection(forward)
    end
end

function ReaderView:onTogglePageChangeAnimation()
    G_reader_settings:flipNilOrFalse("swipe_animations")
end

function ReaderView:onReaderFooterVisibilityChange()
    -- Don't bother ReaderRolling with this nonsense, the footer's height is NOT handled via visible_area there ;)
    if self.ui.paging and self.state.page then
        -- We don't need to do anything if reclaim is enabled ;).
        if not self.footer.settings.reclaim_height then
            -- NOTE: Without reclaim, toggling the footer affects the available area,
            --       so we need to recompute the full layout.
            -- NOTE: ReaderView:recalculate will snap visible_area to page_area edges (depending on zoom direction).
            --       We don't actually want to move here, so save & restore our current visible_area *coordinates*...
            local x, y = self.visible_area.x, self.visible_area.y
            self.ui:handleEvent(Event:new("SetDimensions", Screen:getSize()))
            self.visible_area.x = x
            self.visible_area.y = y

            -- Now, for scroll mode, ReaderView:recalculate does *not* affect any of the actual page_states,
            -- so we'll fudge the bottom page's visible area ourselves,
            -- so as not to leave a blank area behind the footer when hiding it...
            -- This might cause the next scroll to scroll a footer height's *less* than expected,
            -- but that should be hardly noticeable, and since we scroll less, it won't skip over anything.
            if self.page_scroll then
                local bottom_page = self.page_states[#self.page_states]
                -- Not sure if this can ever be `nil`...
                if bottom_page then
                    if self.footer_visible then
                        bottom_page.visible_area.h = bottom_page.visible_area.h - self.footer:getHeight()
                    else
                        bottom_page.visible_area.h = bottom_page.visible_area.h + self.footer:getHeight()
                    end
                end
            end
        end
    end
end

function ReaderView:onGammaUpdate(gamma, no_notification)
    self.state.gamma = gamma
    if self.page_scroll then
        self.ui:handleEvent(Event:new("UpdateScrollPageGamma", gamma))
    end
    if not no_notification then
        Notification:notify(T(_("Contrast set to: %1."), gamma))
    end
end

-- For ReaderKOptListener
function ReaderView:onDitheringUpdate()
    -- Do the device cap checks again, to avoid snafus when sharing configs between devices
    if Device:hasEinkScreen() then
        if Device:canHWDither() then
            if self.document.configurable.hw_dithering then
                self.document.hw_dithering = self.document.configurable.hw_dithering == 1
            end
        elseif Screen.fb_bpp == 8 then
            if self.document.configurable.sw_dithering then
                self.document.sw_dithering = self.document.configurable.sw_dithering == 1
            end
        end
    end
end

-- For KOptOptions
function ReaderView:onHWDitheringUpdate(toggle)
    self.document.hw_dithering = toggle
    Notification:notify(T(_("Hardware dithering set to: %1."), tostring(toggle)))
end

function ReaderView:onSWDitheringUpdate(toggle)
    self.document.sw_dithering = toggle
    Notification:notify(T(_("Software dithering set to: %1."), tostring(toggle)))
end

function ReaderView:onFontSizeUpdate(font_size)
    if self.ui.paging then
        self.ui:handleEvent(Event:new("ReZoom", font_size))
        Notification:notify(T(_("Font zoom set to: %1."), font_size))
    end
end

function ReaderView:onDefectSizeUpdate()
    self.ui:handleEvent(Event:new("ReZoom"))
end

function ReaderView:onPageCrop()
    self.ui:handleEvent(Event:new("ReZoom"))
end

function ReaderView:onMarginUpdate()
    self.ui:handleEvent(Event:new("ReZoom"))
end

function ReaderView:onSetViewMode(new_mode)
    if new_mode ~= self.view_mode then
        self:resetHighlightBoxesCache()
        self.view_mode = new_mode
        self.document:setViewMode(new_mode)
        self.ui:handleEvent(Event:new("ChangeViewMode"))
        Notification:notify(T( _("View mode set to: %1"), optionsutil:getOptionText("SetViewMode", new_mode)))
    end
end

function ReaderView:resetHighlightBoxesCache(items)
    if type(items) ~= "table" then
        self.highlight.page_boxes = {}
    else
        for _, item in ipairs(items) do
            if item.drawer then
                local page0, page1
                if self.ui.rolling then
                    page0 = self.document:getPageFromXPointer(item.pos0)
                    page1 = self.document:getPageFromXPointer(item.pos1)
                else
                    page0 = item.pos0.page
                    page1 = item.pos1.page
                end
                for page = page0, page1 do
                    self.highlight.page_boxes[page] = nil
                end
            end
        end
        if items.index_modified then -- annotation added or removed, shift annotations indexes
            local index, index_shift = items.index_modified, 1
            if index < 0 then
                index, index_shift = -index, -1
            end
            for _, page_boxes in pairs(self.highlight.page_boxes) do
                for _, box in ipairs(page_boxes) do
                    if box.index >= index then
                        box.index = box.index + index_shift
                    end
                end
            end
        end
    end
end

ReaderView.onReflowUpdated = ReaderView.resetHighlightBoxesCache
ReaderView.onDocumentRerendered = ReaderView.resetHighlightBoxesCache
ReaderView.onAnnotationsModified = ReaderView.resetHighlightBoxesCache

function ReaderView:onPageGapUpdate(page_gap)
    self.page_gap.height = Screen:scaleBySize(page_gap)
    Notification:notify(T(_("Page gap set to %1."), optionsutil.formatFlexSize(page_gap)))
    return true
end

function ReaderView:onSaveSettings()
    if self.ui.paging then
        if self.document:isEdited() and not self.ui.highlight.highlight_write_into_pdf then
            self.document:resetTileCacheValidity()
        end
        self.ui.doc_settings:saveSetting("tile_cache_validity_ts", self.document:getTileCacheValidity())
        if self.document.is_djvu then
            self.ui.doc_settings:saveSetting("render_mode", self.render_mode)
        end
    end
    -- Don't etch the current rotation in stone when sticky rotation is enabled
    if G_reader_settings:nilOrFalse("lock_rotation") then
        self.document.configurable.rotation_mode = Screen:getRotationMode() -- will be saved by ReaderConfig
    end
    self.ui.doc_settings:saveSetting("inverse_reading_order", self.inverse_reading_order)
    self.ui.doc_settings:saveSetting("invert_ui_layout", self.invert_ui_layout)
    self.ui.doc_settings:saveSetting("show_overlap_enable", self.page_overlap_enable)
    self.ui.doc_settings:saveSetting("page_overlap_style", self.page_overlap_style)
end

function ReaderView:getRenderModeMenuTable()
    local view = self
    local function make_mode(text, mode)
        return {
            text = text,
            checked_func = function() return view.render_mode == mode end,
            callback = function()
                view.render_mode = mode
                view.document.render_mode = mode
                view:recalculate()
                UIManager:broadcastEvent(Event:new("RenderingModeUpdate"))
            end,
        }
    end
    return  {
        -- @translators Selects which layers of the DjVu image should be rendered.  Valid  rendering  modes are color, black, mask, foreground, and background. See http://djvu.sourceforge.net/ and https://en.wikipedia.org/wiki/DjVu for more information about the format.
        text = _("DjVu render mode"),
        sub_item_table = {
            make_mode(_("COLOUR (works for both colour and b&w pages)"), 0),
            make_mode(_("BLACK & WHITE (for b&w pages only, much faster)"), 1),
            make_mode(_("COLOUR ONLY (slightly faster than COLOUR)"), 2),
            make_mode(_("MASK ONLY (for b&w pages only)"), 3),
            make_mode(_("COLOUR BACKGROUND (show only background)"), 4),
            make_mode(_("COLOUR FOREGROUND (show only foreground)"), 5),
        }
    }
end

function ReaderView:onCloseWidget()
    -- Stop any pending HintPage event
    UIManager:unschedule(self.emitHintPageEvent)
    --- @fixme: The awful readerhighlight_spec test *relies* on this pointer being left dangling...
    if not self.ui._testsuite then
        self.emitHintPageEvent = nil
    end
end

function ReaderView:onReaderReady()
    self.ui.doc_settings:delSetting("docsettings_reset_done")
    self.settings_last_save_time = UIManager:getElapsedTimeSinceBoot()
end

function ReaderView:onResume()
    -- As settings were saved on suspend, reset this on resume,
    -- as there's no need for a possibly immediate save.
    self.settings_last_save_time = UIManager:getElapsedTimeSinceBoot()
end

function ReaderView:checkAutoSaveSettings()
    if not self.settings_last_save_time then -- reader not yet ready
        return
    end
    if G_reader_settings:nilOrFalse("auto_save_settings_interval_minutes") then
        -- no auto save
        return
    end

    local interval_m = G_reader_settings:readSetting("auto_save_settings_interval_minutes")
    local interval = time.s(interval_m * 60)
    local now = UIManager:getElapsedTimeSinceBoot()
    if now - self.settings_last_save_time >= interval then
        self.settings_last_save_time = now
        -- I/O, delay until after the pageturn
        UIManager:tickAfterNext(function()
            self.ui:saveSettings()
        end)
    end
end

function ReaderView:isOverlapAllowed()
    if self.ui.paging then
        return not self.page_scroll
            and (self.ui.paging.zoom_mode ~= "page"
                or (self.ui.paging.zoom_mode == "page" and self.document.configurable.text_wrap == 1))
            and not self.ui.paging.zoom_mode:find("height")
    else
        return self.view_mode ~= "page"
    end
end

function ReaderView:setupTouchZones()
    (self.ui.rolling or self.ui.paging):setupTouchZones()
end

function ReaderView:onToggleReadingOrder(toggle)
    if toggle == nil then
        toggle = not self.inverse_reading_order
    end
    if self.inverse_reading_order ~= toggle then
        self.inverse_reading_order = toggle
        self:setupTouchZones()
        local is_rtl = self.inverse_reading_order ~= BD.mirroredUILayout() -- mirrored reading
        Notification:notify(is_rtl and _("RTL page turning.") or _("LTR page turning."))
    end
    return true
end

function ReaderView:getTapZones()
    local forward_zone, backward_zone
    local DTAP_ZONE_FORWARD = G_defaults:readSetting("DTAP_ZONE_FORWARD")
    local DTAP_ZONE_BACKWARD = G_defaults:readSetting("DTAP_ZONE_BACKWARD")
    local tap_zones_type = G_reader_settings:readSetting("page_turns_tap_zones", "default")
    if tap_zones_type == "default" then
        forward_zone = {
            ratio_x = DTAP_ZONE_FORWARD.x, ratio_y = DTAP_ZONE_FORWARD.y,
            ratio_w = DTAP_ZONE_FORWARD.w, ratio_h = DTAP_ZONE_FORWARD.h,
        }
        backward_zone = {
            ratio_x = DTAP_ZONE_BACKWARD.x, ratio_y = DTAP_ZONE_BACKWARD.y,
            ratio_w = DTAP_ZONE_BACKWARD.w, ratio_h = DTAP_ZONE_BACKWARD.h,
        }
    else -- user defined page turns tap zones
        local tap_zone_forward_w = G_reader_settings:readSetting("page_turns_tap_zone_forward_size_ratio", DTAP_ZONE_FORWARD.w)
        local tap_zone_backward_w = G_reader_settings:readSetting("page_turns_tap_zone_backward_size_ratio", DTAP_ZONE_BACKWARD.w)
        if tap_zones_type == "left_right" then
            forward_zone = {
                ratio_x = 1 - tap_zone_forward_w, ratio_y = 0,
                ratio_w = tap_zone_forward_w, ratio_h = 1,
            }
            backward_zone = {
                ratio_x = 0, ratio_y = 0,
                ratio_w = tap_zone_backward_w, ratio_h = 1,
            }
        elseif tap_zones_type == "top_bottom" then
            forward_zone = {
                ratio_x = 0, ratio_y = 1 - tap_zone_forward_w,
                ratio_w = 1, ratio_h = tap_zone_forward_w,
            }
            backward_zone = {
                ratio_x = 0, ratio_y = 0,
                ratio_w = 1, ratio_h = tap_zone_backward_w,
            }
        else -- "bottom_top"
            forward_zone = {
                ratio_x = 0, ratio_y = 0,
                ratio_w = 1, ratio_h = tap_zone_forward_w,
            }
            backward_zone = {
                ratio_x = 0, ratio_y = 1 - tap_zone_backward_w,
                ratio_w = 1, ratio_h = tap_zone_backward_w,
            }
        end
    end
    if self.inverse_reading_order ~= BD.mirroredUILayout() then -- mirrored reading
        forward_zone.ratio_x = 1 - forward_zone.ratio_x - forward_zone.ratio_w
        backward_zone.ratio_x = 1 - backward_zone.ratio_x - backward_zone.ratio_w
    end
    return forward_zone, backward_zone
end

function ReaderView:setupNoteMarkPosition()
    local is_sidemark = self.highlight.note_mark == "sidemark"

    -- set/free note marker sign
    if is_sidemark then
        if not self.note_mark_sign then
            self.note_mark_sign = TextWidget:new{
                text = "\u{F040}", -- pencil
                face = Font:getFace("smallinfofont", 14),
                padding = 0,
            }
        end
    else
        if self.note_mark_sign then
            self.note_mark_sign:free()
            self.note_mark_sign = nil
        end
    end

    -- calculate position x of the note side line/mark
    if is_sidemark or self.highlight.note_mark == "sideline" then
        local screen_w = Screen:getWidth()
        local sign_w = is_sidemark and self.note_mark_sign:getWidth() or self.note_mark_line_w
        local sign_gap = Screen:scaleBySize(5) -- to the text (cre) or to the screen edge (pdf)
        if self.ui.paging then
            if BD.mirroredUILayout() then
                self.note_mark_pos_x1 = sign_gap
            else
                self.note_mark_pos_x1 = screen_w - sign_gap - sign_w
            end
        else
            local doc_margins = self.document:getPageMargins()
            local pos_x_r = screen_w - doc_margins["right"] + sign_gap -- mark in the right margin
            local pos_x_l = doc_margins["left"] - sign_gap - sign_w -- mark in the left margin
            if self.document:getVisiblePageCount() == 1 then
                if BD.mirroredUILayout() then
                    self.note_mark_pos_x1 = pos_x_l
                else
                    self.note_mark_pos_x1 = pos_x_r
                end
            else -- two-page mode
                local page2_x = self.document:getPageOffsetX(self.document:getCurrentPage(true)+1)
                if BD.mirroredUILayout() then
                    self.note_mark_pos_x1 = pos_x_l
                    self.note_mark_pos_x2 = pos_x_l + page2_x
                else
                    self.note_mark_pos_x1 = pos_x_r - page2_x
                    self.note_mark_pos_x2 = pos_x_r
                end
            end
        end
    end
end

function ReaderView:getCurrentPageLineWordCounts()
    local lines_nb, words_nb = 0, 0
    if self.ui.rolling then
        local res = self.document:getTextFromPositions({x = 0, y = 0},
            {x = Screen:getWidth(), y = Screen:getHeight()}, true) -- do not highlight
        if res then
            lines_nb = #self.document:getScreenBoxesFromPositions(res.pos0, res.pos1, true)
            for word in util.gsplit(res.text, "[%s%p]+", false) do
                if util.hasCJKChar(word) then
                    for char in util.gsplit(word, "[\192-\255][\128-\191]+", true) do
                        words_nb = words_nb + 1
                    end
                else
                    words_nb = words_nb + 1
                end
            end
        end
    else
        local page_boxes = self.document:getTextBoxes(self.ui:getCurrentPage())
        if page_boxes and page_boxes[1][1].word then
            lines_nb = #page_boxes
            for _, line in ipairs(page_boxes) do
                if #line == 1 and line[1].word == "" then -- empty line
                    lines_nb = lines_nb - 1
                else
                    words_nb = words_nb + #line
                    local last_word = line[#line].word
                    if last_word:sub(-1) == "-" and last_word ~= "-" then -- hyphenated
                        words_nb = words_nb - 1
                    end
                end
            end
        end
    end
    return lines_nb, words_nb
end

return ReaderView
