--[[-- ReaderLink is an abstraction for document-specific link interfaces. ]] local ConfirmBox = require("ui/widget/confirmbox") local Device = require("device") local Event = require("ui/event") local Geom = require("ui/geometry") local GestureRange = require("ui/gesturerange") local InputContainer = require("ui/widget/container/inputcontainer") local LinkBox = require("ui/widget/linkbox") local Notification = require("ui/widget/notification") local UIManager = require("ui/uimanager") local logger = require("logger") local util = require("util") local _ = require("gettext") local Screen = Device.screen local T = require("ffi/util").template local ReaderLink = InputContainer:new{ location_stack = {} } function ReaderLink:init() if Device:isTouchDevice() then self:initGesListener() end self.ui:registerPostInitCallback(function() self.ui.menu:registerToMainMenu(self) end) end function ReaderLink:onReadSettings(config) -- called when loading new document self.location_stack = {} end function ReaderLink:initGesListener() if Device:isTouchDevice() then self.ges_events = { Tap = { GestureRange:new{ ges = "tap", range = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight() } } }, Swipe = { GestureRange:new{ ges = "swipe", range = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight(), } } }, } end end local function isTapToFollowLinksOn() return not G_reader_settings:isFalse("tap_to_follow_links") end local function isSwipeToGoBackEnabled() return G_reader_settings:readSetting("swipe_to_go_back") == true end local function isSwipeToFollowFirstLinkEnabled() return G_reader_settings:readSetting("swipe_to_follow_first_link") == true end local function isSwipeToFollowNearestLinkEnabled() return G_reader_settings:readSetting("swipe_to_follow_nearest_link") == true end local function isSwipeToJumpToLatestBookmarkEnabled() return G_reader_settings:readSetting("swipe_to_jump_to_latest_bookmark") == true end function ReaderLink:addToMainMenu(menu_items) -- insert table to main reader menu menu_items.follow_links = { text = _("Links"), sub_item_table = { { text = _("Tap to follow links"), checked_func = isTapToFollowLinksOn, callback = function() G_reader_settings:saveSetting("tap_to_follow_links", not isTapToFollowLinksOn()) end, separator = true, }, { text = _("Swipe to go back"), checked_func = isSwipeToGoBackEnabled, callback = function() G_reader_settings:saveSetting("swipe_to_go_back", not isSwipeToGoBackEnabled()) end, }, { text = _("Swipe to follow first link on page"), checked_func = isSwipeToFollowFirstLinkEnabled, callback = function() G_reader_settings:saveSetting("swipe_to_follow_first_link", not isSwipeToFollowFirstLinkEnabled()) if isSwipeToFollowFirstLinkEnabled() then G_reader_settings:delSetting("swipe_to_follow_nearest_link") -- can't have both end end, }, { text = _("Swipe to follow nearest link"), checked_func = isSwipeToFollowNearestLinkEnabled, callback = function() G_reader_settings:saveSetting("swipe_to_follow_nearest_link", not isSwipeToFollowNearestLinkEnabled()) if isSwipeToFollowNearestLinkEnabled() then G_reader_settings:delSetting("swipe_to_follow_first_link") -- can't have both end end, separator = true, }, { text = _("Swipe to jump to latest bookmark"), checked_func = isSwipeToJumpToLatestBookmarkEnabled, callback = function() G_reader_settings:saveSetting("swipe_to_jump_to_latest_bookmark", not isSwipeToJumpToLatestBookmarkEnabled()) end, }, } } menu_items.go_to_previous_location = { text = _("Go back to previous location"), enabled_func = function() return #self.location_stack > 0 end, callback = function() self:onGoBackLink() end, hold_callback = function() UIManager:show(ConfirmBox:new{ text = _("Clear location history?"), ok_text = _("Clear"), ok_callback = function() self.location_stack = {} end, }) end, } end --- Check if a xpointer to node really points to itself function ReaderLink:isXpointerCoherent(a_xpointer) -- Get screen coordinates of xpointer local doc_margins = self.ui.document._document:getPageMargins() local doc_y, doc_x = self.ui.document:getPosFromXPointer(a_xpointer) local top_y = self.ui.document:getCurrentPos() -- (strange, but using doc_margins.top is accurate even in scroll mode) local screen_y = doc_y - top_y + doc_margins["top"] local screen_x = doc_x + doc_margins["left"] -- Get again link and a_xpointer from this position local re_link_xpointer, re_a_xpointer = self.ui.document:getLinkFromPosition({x = screen_x, y = screen_y}) -- luacheck: no unused -- We should get the same a_xpointer. If not, crengine has messed up -- and we should not trust this xpointer to get back to this link. if re_a_xpointer ~= a_xpointer then logger.info("not coherent a_xpointer:", a_xpointer) return false end return true end --- Gets link from gesture. -- `Document:getLinkFromPosition()` behaves differently depending on -- document type, so this function provides a wrapper. function ReaderLink:getLinkFromGes(ges) if self.ui.document.info.has_pages then local pos = self.view:screenToPageTransform(ges.pos) if pos then -- link box in native page local link, lbox = self.ui.document:getLinkFromPosition(pos.page, pos) if link and lbox then return { link = link, lbox = lbox, pos = pos, } end end else local link_xpointer, a_xpointer = self.ui.document:getLinkFromPosition(ges.pos) logger.dbg("getLinkFromPosition link_xpointer:", link_xpointer) logger.dbg("getLinkFromPosition a_xpointer:", a_xpointer) -- On some documents, crengine may sometimes give a wrong a_xpointer -- (in some Wikipedia saved as EPUB, it would point to some other -- element in the same paragraph). If followed then back, we could get -- to a different page. So, we check here how valid it is, and if not, -- we just discard it so that addCurrentLocationToStack() is used. if a_xpointer and not self:isXpointerCoherent(a_xpointer) then a_xpointer = nil end if link_xpointer ~= "" then -- This link's target xpointer is more precise than a classic -- xpointer to top of a page: we can show a marker at its -- y-position in target page return { xpointer = link_xpointer, marker_xpointer = link_xpointer, from_xpointer = a_xpointer, } end end end --- Highlights a linkbox if available and goes to it. function ReaderLink:showLinkBox(link) if link and link.lbox then -- pdfdocument -- screen box that holds the link local sbox = self.view:pageToScreenTransform(link.pos.page, self.ui.document:nativeToPageRectTransform(link.pos.page, link.lbox)) if sbox then UIManager:show(LinkBox:new{ box = sbox, timeout = FOLLOW_LINK_TIMEOUT, callback = function() self:onGotoLink(link.link) end }) return true end elseif link and link.xpointer ~= "" then -- credocument return self:onGotoLink(link) end end function ReaderLink:onSetDimensions(dimen) -- update listening according to new screen dimen if Device:isTouchDevice() then self:initGesListener() end end function ReaderLink:onTap(_, ges) if not isTapToFollowLinksOn() then return end local link = self:getLinkFromGes(ges) if link then return self:showLinkBox(link) end end --- Remember current location so we can go back to it function ReaderLink:addCurrentLocationToStack() if self.ui.document.info.has_pages then table.insert(self.location_stack, self.ui.paging:getBookLocation()) else table.insert(self.location_stack, { xpointer = self.ui.rolling:getBookLocation(), }) end end --- Goes to link. function ReaderLink:onGotoLink(link, neglect_current_location) logger.dbg("onGotoLink:", link) if self.ui.document.info.has_pages then -- internal pdf links have a "page" attribute, while external ones have an "uri" attribute if link.page then -- Internal link logger.dbg("Internal link:", link) if not neglect_current_location then self:addCurrentLocationToStack() end self.ui:handleEvent(Event:new("GotoPage", link.page + 1)) return true end link = link.uri -- external link else -- For crengine, internal links may look like : -- #_doc_fragment_0_Organisation (link from anchor) -- /body/DocFragment/body/ul[2]/li[5]/text()[3].16 (xpointer from full-text search) -- If the XPointer does not exist (or is a full url), we will jump to page 1 -- Best to check that this link exists in document with the following, -- which accepts both of the above legitimate xpointer as input. if self.ui.document:isXPointerInDocument(link.xpointer) then logger.dbg("Internal link:", link) if not neglect_current_location then if link.from_xpointer then -- We have a more precise xpointer than the xpointer to top of -- current page that addCurrentLocationToStack() would give, and -- we may be able to show a marker when back local saved_location if self.view.view_mode == "scroll" then -- In scroll mode, we still use the top of page as the -- xpointer to go back to, so we get back to the same view. -- We can still show the marker at the link position saved_location = { xpointer = self.ui.rolling:getBookLocation(), marker_xpointer = link.from_xpointer, } else -- In page mode, we use the same for go to and for marker, -- as 'page mode' ensures we get back to the same view. saved_location = { xpointer = link.from_xpointer, marker_xpointer = link.from_xpointer, } end table.insert(self.location_stack, saved_location) else self:addCurrentLocationToStack() end end self.ui:handleEvent(Event:new("GotoXPointer", link.xpointer, link.marker_xpointer)) return true end end logger.dbg("External link:", link) -- Check if it is a wikipedia link local wiki_lang, wiki_page = link.xpointer:match([[https?://([^%.]+).wikipedia.org/wiki/([^/]+)]]) if wiki_lang and wiki_page then logger.dbg("Wikipedia link:", wiki_lang, wiki_page) -- Ask for user confirmation before launching lookup (on a -- wikipedia page saved as epub, full of wikipedia links, it's -- too easy to click on links when wanting to change page...) -- But first check if this wikipedia article has been saved as EPUB local epub_filename = util.replaceInvalidChars(wiki_page:gsub("_", " ")) .. "."..string.upper(wiki_lang)..".epub" local epub_fullpath -- either in current book directory local last_file = G_reader_settings:readSetting("lastfile") if last_file then local current_book_dir = last_file:match("(.*)/") local epub_path = current_book_dir .. "/" .. epub_filename if util.pathExists(epub_path) then epub_fullpath = epub_path end end -- or in wikipedia save directory if not epub_fullpath then local dir = G_reader_settings:readSetting("wikipedia_save_dir") if not dir then dir = G_reader_settings:readSetting("home_dir") end if not dir then dir = require("apps/filemanager/filemanagerutil").getDefaultDir() end if dir then local epub_path = dir .. "/" .. epub_filename if util.pathExists(epub_path) then epub_fullpath = epub_path end end end if epub_fullpath then local MultiConfirmBox = require("ui/widget/multiconfirmbox") UIManager:show(MultiConfirmBox:new{ text = T(_("Would you like to read this Wikipedia %1 article?\n\n%2\n\nThis article has previously been saved as EPUB. You may wish to read the saved EPUB instead."), wiki_lang:upper(), wiki_page:gsub("_", " "), epub_fullpath), choice1_text = _("Read online"), choice1_callback = function() UIManager:nextTick(function() self.ui:handleEvent(Event:new("LookupWikipedia", wiki_page, false, true, wiki_lang)) end) end, choice2_text = _("Read EPUB"), choice2_callback = function() -- close current ReaderUI, and create a new one UIManager:scheduleIn(0.1, function() local ReaderUI = require("apps/reader/readerui") local reader = ReaderUI:_getRunningInstance() if reader then reader:onClose() end ReaderUI:showReader(epub_fullpath) end) end, }) else UIManager:show(ConfirmBox:new{ text = T(_("Would you like to read this Wikipedia %1 article?\n\n%2\n"), wiki_lang:upper(), wiki_page:gsub("_", " ")), ok_callback = function() UIManager:nextTick(function() self.ui:handleEvent(Event:new("LookupWikipedia", wiki_page, false, true, wiki_lang)) end) end }) end else local InfoMessage = require("ui/widget/infomessage") UIManager:show(InfoMessage:new{ text = T(_("Invalid or external link:\n%1"), link.xpointer), -- no timeout to allow user to type that link in his web browser }) end -- don't propagate, user will notice and tap elsewhere if he wants to change page return true end --- Goes back to previous location. function ReaderLink:onGoBackLink() local saved_location = table.remove(self.location_stack) if saved_location then logger.dbg("GoBack: restoring:", saved_location) self.ui:handleEvent(Event:new('RestoreBookLocation', saved_location)) return true end end function ReaderLink:onSwipe(arg, ges) if ges.direction == "east" then if isSwipeToGoBackEnabled() then if #self.location_stack > 0 then -- Remember if location stack is going to be empty, so we -- can stop the propagation of next swipe back: so the user -- knows it is empty and that next swipe back will get him -- to previous page (and not to previous location) self.swipe_back_resist = #self.location_stack == 1 return self:onGoBackLink() elseif self.swipe_back_resist then self.swipe_back_resist = false -- Make that gesture don't do anything, and show a Notification -- so the user knows why UIManager:show(Notification:new{ text = _("Location history is empty"), timeout = 1.0, }) return true end end elseif ges.direction == "west" then local ret = false if isSwipeToFollowFirstLinkEnabled() then ret = self:onGoToPageLink(ges, true) elseif isSwipeToFollowNearestLinkEnabled() then ret = self:onGoToPageLink(ges) end -- If no link found, or no follow link option enabled, -- jump to latest bookmark (if enabled) if not ret and isSwipeToJumpToLatestBookmarkEnabled() then ret = self:onGoToLatestBookmark(ges) end return ret end end --- Goes to link nearest to the gesture (or first link in page) function ReaderLink:onGoToPageLink(ges, use_page_first_link) local selected_link = nil if self.ui.document.info.has_pages then local pos = self.view:screenToPageTransform(ges.pos) if not pos then return end local links = self.ui.document:getPageLinks(pos.page) if not links or #links == 0 then return end -- DEBUG("PDF Page links : ", links) -- We may get multiple links: internal ones (with "page" key) -- that we're interested in, but also external links (no "page", but -- a "uri" key) that we don't care about. -- [2] = { -- ["y1"] = 107.88977050781, -- ["x1"] = 176.60360717773, -- ["y0"] = 97.944396972656, -- ["x0"] = 97, -- ["page"] = 347 -- }, local pos_x, pos_y = pos.x, pos.y local shortest_dist = nil local first_y0 = nil for _, link in ipairs(links) do if link["page"] then if use_page_first_link then -- Links may not be in the order they are in the page, so let's -- find the one with the smallest y0. if first_y0 == nil or link["y0"] < first_y0 then selected_link = link first_y0 = link["y0"] end else local start_dist = math.pow(link.x0 - pos_x, 2) + math.pow(link.y0 - pos_y, 2) local end_dist = math.pow(link.x1 - pos_x, 2) + math.pow(link.y1 - pos_y, 2) local min_dist = math.min(start_dist, end_dist) if shortest_dist == nil or min_dist < shortest_dist then -- onGotoLink()'s GotoPage event needs the link -- itself, and will use its "page" value selected_link = link shortest_dist = min_dist end end end end else local links = self.ui.document:getPageLinks() if not links or #links == 0 then return end -- DEBUG("CRE Page links : ", links) -- We may get multiple links: internal ones (they have a "section" key) -- that we're interested in, but also external links (no "section", but -- a "uri" key) that we don't care about. -- [1] = { -- ["end_x"] = 825, -- ["uri"] = "", -- ["end_y"] = 333511, -- ["start_x"] = 90, -- ["start_y"] = 333511 -- }, -- [2] = { -- ["end_x"] = 366, -- ["section"] = "#_doc_fragment_19_ftn_fn6", -- ["end_y"] = 1201, -- ["start_x"] = 352, -- ["start_y"] = 1201 -- ["a_xpointer"] = "/body/DocFragment/body/div/p[12]/sup[3]/a[3].0", -- }, -- Note: with some documents and some links, crengine may give wrong -- coordinates, and our code below may miss or give the wrong first -- or nearest link... local pos_x, pos_y = ges.pos.x, ges.pos.y local shortest_dist = nil local first_start_y = nil for _, link in ipairs(links) do if link["section"] then if use_page_first_link then -- links may not be in the order they are in the page, so let's -- find the one with the smallest start_y. if first_start_y == nil or link["start_y"] < first_start_y then selected_link = link first_start_y = link["start_y"] end else local start_dist = math.pow(link.start_x - pos_x, 2) + math.pow(link.start_y - pos_y, 2) local end_dist = math.pow(link.end_x - pos_x, 2) + math.pow(link.end_y - pos_y, 2) local min_dist = math.min(start_dist, end_dist) if shortest_dist == nil or min_dist < shortest_dist then selected_link = link shortest_dist = min_dist end end end end -- cre.cpp getPageLinks() does highlight found links : -- sel.add( new ldomXRange(*links[i]) ); // highlight -- and we'll find them highlighted when back from link. -- So let's clear them now. self.ui.document:clearSelection() -- (Comment out previous line to visually see which links on the -- page are not coherent: those not highlighted) if selected_link then logger.dbg("original selected_link", selected_link) -- Make it a link as expected by onGotoLink selected_link = { xpointer = selected_link["section"], marker_xpointer = selected_link["section"], from_xpointer = selected_link["a_xpointer"], } logger.dbg("selected_link", selected_link) -- Check from_xpointer is coherent, and unset it if not if selected_link.from_xpointer and not self:isXpointerCoherent(selected_link.from_xpointer) then selected_link.from_xpointer = nil end end end if selected_link then return self:onGotoLink(selected_link) end end function ReaderLink:onGoToLatestBookmark(ges) local latest_bookmark = self.ui.bookmark:getLatestBookmark() if latest_bookmark then if self.ui.document.info.has_pages then -- self:onGotoLink() needs something with a page attribute. -- we need to substract 1 to bookmark page, as links start from 0 -- and onGotoLink will add 1 - we need a fake_link (with a single -- page attribute) so we don't touch the bookmark itself local fake_link = {} fake_link.page = latest_bookmark.page - 1 return self:onGotoLink(fake_link) else -- Make it a link as expected by onGotoLink local link if latest_bookmark.pos0 then -- text highlighted, precise xpointer link = { xpointer = latest_bookmark.pos0, marker_xpointer = latest_bookmark.pos0, } else -- page bookmarked, 'page' is a xpointer to top of page link = { xpointer = latest_bookmark.page, } end return self:onGotoLink(link) end end end return ReaderLink