diff --git a/base b/base index 79574a5a4..19519c5e8 160000 --- a/base +++ b/base @@ -1 +1 @@ -Subproject commit 79574a5a40ab4648af9e4bba733ad6ef5eddf299 +Subproject commit 19519c5e826aacdd4b8ffd9e22559ca0c13d87a4 diff --git a/frontend/apps/reader/modules/readerflipping.lua b/frontend/apps/reader/modules/readerflipping.lua index 482cb5827..132f805d7 100644 --- a/frontend/apps/reader/modules/readerflipping.lua +++ b/frontend/apps/reader/modules/readerflipping.lua @@ -47,6 +47,12 @@ function ReaderFlipping:resetLayout() end function ReaderFlipping:onTap() + if not self.ui.document.info.has_pages then + -- ReaderRolling has no support (yet) for onTogglePageFlipping, + -- so don't make that top left tap area unusable (and allow + -- taping on links there) + return false + end self.ui:handleEvent(Event:new("TogglePageFlipping")) return true end diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index a3a1574df..017eb1960 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -223,7 +223,7 @@ function ReaderHighlight:onTapXPointerSavedHighlight(ges) -- (A highlight starting on cur_page-17 and ending on cur_page+13 is -- a highlight to consider) if start_page <= cur_page + neighbour_pages and end_page >= cur_page - neighbour_pages then - local boxes = self.ui.document:getScreenBoxesFromPositions(pos0, pos1) + local boxes = self.ui.document:getScreenBoxesFromPositions(pos0, pos1, true) -- get_segments=true if boxes then for index, box in pairs(boxes) do if inside_box(pos, box) then diff --git a/frontend/apps/reader/modules/readerlink.lua b/frontend/apps/reader/modules/readerlink.lua index 8877531f8..c2655d0f7 100644 --- a/frontend/apps/reader/modules/readerlink.lua +++ b/frontend/apps/reader/modules/readerlink.lua @@ -68,23 +68,43 @@ function ReaderLink:initGesListener() end local function isTapToFollowLinksOn() - return not G_reader_settings:isFalse("tap_to_follow_links") + return G_reader_settings:nilOrTrue("tap_to_follow_links") +end + +local function isLargerTapAreaToFollowLinksEnabled() + return G_reader_settings:isTrue("larger_tap_area_to_follow_links") +end + +local function isTapIgnoreExternalLinksEnabled() + return G_reader_settings:isTrue("tap_ignore_external_links") +end + +local function isTapLinkFootnotePopupEnabled() + return G_reader_settings:isTrue("tap_link_footnote_popup") +end + +local function isPreferFootnoteEnabled() + return G_reader_settings:isTrue("link_prefer_footnote") end local function isSwipeToGoBackEnabled() - return G_reader_settings:readSetting("swipe_to_go_back") == true + return G_reader_settings:isTrue("swipe_to_go_back") end local function isSwipeToFollowFirstLinkEnabled() - return G_reader_settings:readSetting("swipe_to_follow_first_link") == true + return G_reader_settings:isTrue("swipe_to_follow_first_link") end local function isSwipeToFollowNearestLinkEnabled() - return G_reader_settings:readSetting("swipe_to_follow_nearest_link") == true + return G_reader_settings:isTrue("swipe_to_follow_nearest_link") +end + +local function isSwipeLinkFootnotePopupEnabled() + return G_reader_settings:isTrue("swipe_link_footnote_popup") end local function isSwipeToJumpToLatestBookmarkEnabled() - return G_reader_settings:readSetting("swipe_to_jump_to_latest_bookmark") == true + return G_reader_settings:isTrue("swipe_to_jump_to_latest_bookmark") end function ReaderLink:addToMainMenu(menu_items) @@ -150,6 +170,73 @@ If any of the other Swipe to follow link options is enabled, this will work only }, } } + -- Insert other items that are (for now) only supported with CreDocuments + -- (They could be supported nearly as-is, but given that there is a lot + -- less visual feedback on PDF document of what is a link, or that we just + -- followed a link, than on EPUB, it's safer to not use them on PDF documents + -- even if the user enabled these features for EPUB documents). + if not self.ui.document.info.has_pages then + local footnote_popup_help_text = _([[ +Show internal link target content in a footnote popup when it looks like it might be a footnote, instead of following the link. + +Note that depending on the book quality, footnote detection may not always work correctly. +The footnote content may be empty, truncated, or include other footnotes. + +From the footnote popup, you can jump to the footnote location in the book by swiping to the left.]]) + -- Tap section + menu_items.follow_links.sub_item_table[1].separator = nil + table.insert(menu_items.follow_links.sub_item_table, 2, { + text = _("Allow larger tap area around links"), + checked_func = isLargerTapAreaToFollowLinksEnabled, + callback = function() + G_reader_settings:saveSetting("larger_tap_area_to_follow_links", + not isLargerTapAreaToFollowLinksEnabled()) + end, + help_text = _([[Extends the tap area around internal links. Useful with a small font where tapping on small footnote links may be tedious.]]), + }) + table.insert(menu_items.follow_links.sub_item_table, 3, { + text = _("Ignore external links"), + checked_func = isTapIgnoreExternalLinksEnabled, + callback = function() + G_reader_settings:saveSetting("tap_ignore_external_links", + not isTapIgnoreExternalLinksEnabled()) + end, + help_text = _([[Ignore taps on external links. Useful with Wikipedia EPUBs to make page turning easier. +You can still follow them from the dictionary window or the selection menu after holding on them.]]), + }) + table.insert(menu_items.follow_links.sub_item_table, 4, { + text = _("Show footnotes in popup"), + checked_func = isTapLinkFootnotePopupEnabled, + callback = function() + G_reader_settings:saveSetting("tap_link_footnote_popup", + not isTapLinkFootnotePopupEnabled()) + end, + help_text = footnote_popup_help_text, + separator = true, + }) + table.insert(menu_items.follow_links.sub_item_table, 5, { + text = _("Show more links as footnotes"), + checked_func = isPreferFootnoteEnabled, + callback = function() + G_reader_settings:saveSetting("link_prefer_footnote", + not isPreferFootnoteEnabled()) + end, + help_text = _([[Loosen footnote detection rules to show more links as footnotes.]]), + separator = true, + }) + -- Swipe section + menu_items.follow_links.sub_item_table[8].separator = nil + table.insert(menu_items.follow_links.sub_item_table, 9, { + text = _("Show footnotes in popup"), + checked_func = isSwipeLinkFootnotePopupEnabled, + callback = function() + G_reader_settings:saveSetting("swipe_link_footnote_popup", + not isSwipeLinkFootnotePopupEnabled()) + end, + help_text = footnote_popup_help_text, + separator = true, + }) + end menu_items.go_to_previous_location = { text = _("Go back to previous location"), enabled_func = function() return #self.location_stack > 0 end, @@ -220,25 +307,33 @@ function ReaderLink:getLinkFromGes(ges) -- 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 + local from_xpointer = nil + if a_xpointer and self:isXpointerCoherent(a_xpointer) then + from_xpointer = a_xpointer end if link_xpointer ~= "" then - -- This link's target xpointer is more precise than a classic + -- This link's source 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 + -- (keep a_xpointer even if incoherent, might be needed for + -- footnote detection (better than nothing if incoherent) return { xpointer = link_xpointer, marker_xpointer = link_xpointer, - from_xpointer = a_xpointer, + from_xpointer = from_xpointer, + a_xpointer = a_xpointer, + -- tap y-position should be a good approximation of link y + -- (needed to keep its highlight a bit more time if it was + -- hidden by the footnote popup) + link_y = ges.pos.y } end end end --- Highlights a linkbox if available and goes to it. -function ReaderLink:showLinkBox(link) +function ReaderLink:showLinkBox(link, allow_footnote_popup) if link and link.lbox then -- pdfdocument -- screen box that holds the link local sbox = self.view:pageToScreenTransform(link.pos.page, @@ -247,12 +342,14 @@ function ReaderLink:showLinkBox(link) UIManager:show(LinkBox:new{ box = sbox, timeout = FOLLOW_LINK_TIMEOUT, - callback = function() self:onGotoLink(link.link) end + callback = function() + self:onGotoLink(link.link, false, allow_footnote_popup) + end }) return true end elseif link and link.xpointer ~= "" then -- credocument - return self:onGotoLink(link) + return self:onGotoLink(link, false, allow_footnote_popup) end end @@ -265,9 +362,43 @@ end function ReaderLink:onTap(_, ges) if not isTapToFollowLinksOn() then return end - local link = self:getLinkFromGes(ges) - if link then - return self:showLinkBox(link) + if self.ui.document.info.has_pages then + -- (footnote popup, larger tap area and ignore external links + -- are for now not supported with non-CreDocuments) + local link = self:getLinkFromGes(ges) + if link then + return self:showLinkBox(link) + end + return + end + local allow_footnote_popup = isTapLinkFootnotePopupEnabled() + -- If tap_ignore_external_links, skip precise tap detection to really + -- ignore a tap on an external link, and allow using onGoToPageLink() + -- to find the nearest internal link + if not isTapIgnoreExternalLinksEnabled() then + local link = self:getLinkFromGes(ges) + if link then + return self:showLinkBox(link, allow_footnote_popup) + end + end + if isLargerTapAreaToFollowLinksEnabled() or isTapIgnoreExternalLinksEnabled() then + local max_distance = 0 -- used when only isTapIgnoreExternalLinksEnabled() + if isLargerTapAreaToFollowLinksEnabled() then + -- If no link found exactly at the tap position, + -- try to find any link in page around that tap position. + -- onGoToPageLink() will grab only internal links, which + -- is nice as url links are usually longer - so this + -- give more chance to catch a small link to footnote stuck + -- to a longer Wikipedia article name link. + -- + -- 30px on a reference 167 dpi screen makes 0.45cm, which + -- seems fine (on a 300dpi device, this will be scaled + -- to 54px (which makes 1/20th of screen witdh on a GloHD) + -- Trust Screen.dpi (which may not be the real device + -- screen DPI if the user has set another one). + max_distance = Screen:scaleByDPI(30) + end + return self:onGoToPageLink(ges, false, allow_footnote_popup, max_distance) end end @@ -283,7 +414,9 @@ function ReaderLink:addCurrentLocationToStack() end --- Goes to link. -function ReaderLink:onGotoLink(link, neglect_current_location) +-- (This is called by other modules (highlight, search) to jump to a xpointer, +-- they should not provide allow_footnote_popup=true) +function ReaderLink:onGotoLink(link, neglect_current_location, allow_footnote_popup) 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 @@ -305,6 +438,12 @@ function ReaderLink:onGotoLink(link, neglect_current_location) -- which accepts both of the above legitimate xpointer as input. if self.ui.document:isXPointerInDocument(link.xpointer) then logger.dbg("Internal link:", link) + if allow_footnote_popup then + if self:showAsFootnotePopup(link, neglect_current_location) then + return true + end + -- if it fails for any reason, fallback to following link + end if not neglect_current_location then if link.from_xpointer then -- We have a more precise xpointer than the xpointer to top of @@ -478,9 +617,11 @@ function ReaderLink:onSwipe(arg, ges) elseif ges.direction == "west" then local ret = false if isSwipeToFollowFirstLinkEnabled() then + -- no sense allowing footnote popup if first link ret = self:onGoToPageLink(ges, true) elseif isSwipeToFollowNearestLinkEnabled() then - ret = self:onGoToPageLink(ges) + local allow_footnote_popup = isSwipeLinkFootnotePopupEnabled() + ret = self:onGoToPageLink(ges, false, allow_footnote_popup) end -- If no link found, or no follow link option enabled, -- jump to latest bookmark (if enabled) @@ -492,8 +633,11 @@ function ReaderLink:onSwipe(arg, ges) end --- Goes to link nearest to the gesture (or first link in page) -function ReaderLink:onGoToPageLink(ges, use_page_first_link) +function ReaderLink:onGoToPageLink(ges, use_page_first_link, allow_footnote_popup, max_distance) local selected_link = nil + local selected_distance2 = nil + -- We use squares of distances all along the calculations, no need + -- to math.sqrt() them when comparing if self.ui.document.info.has_pages then local pos = self.view:screenToPageTransform(ges.pos) if not pos then @@ -539,8 +683,18 @@ function ReaderLink:onGoToPageLink(ges, use_page_first_link) end end end + if shortest_dist then + selected_distance2 = shortest_dist + end else - local links = self.ui.document:getPageLinks() + -- Getting segments on a page with many internal links is + -- a bit expensive. With larger_tap_area_to_follow_links=true, + -- this is done for each tap on screen (changing pages, showing + -- menu...). We might want to cache these links per page (and + -- clear that cache when page layout change). + -- As we care only about internal links, we request them only + -- (and avoid that expensive segments work on external links) + local links = self.ui.document:getPageLinks(true) -- only get internal links if not links or #links == 0 then return end @@ -563,6 +717,29 @@ function ReaderLink:onGoToPageLink(ges, use_page_first_link) -- ["start_y"] = 1201 -- ["a_xpointer"] = "/body/DocFragment/body/div/p[12]/sup[3]/a[3].0", -- }, + -- and when segments requested (example for a multi-lines link): + -- [3] = { + -- ["section"] = "#_doc_fragment_0_ Man_of_letters", + -- ["a_xpointer"] = "/body/DocFragment/body/div/div[4]/ul/li[3]/ul/li[2]/ul/li[1]/ul/li[3]/a.0", + -- ["start_x"] = 101, + -- ["start_y"] = 457, + -- ["end_x"] = 176, + -- ["end_y"] = 482,, + -- ["segments"] = { + -- [1] = { + -- ["x0"] = 101, + -- ["y0"] = 457, + -- ["x1"] = 590, + -- ["y1"] = 482, + -- }, + -- [2] = { + -- ["x0"] = 101, + -- ["y0"] = 482, + -- ["x1"] = 177, + -- ["y1"] = 507, + -- } + -- }, + -- }, -- 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... @@ -579,42 +756,96 @@ function ReaderLink:onGoToPageLink(ges, use_page_first_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 + if link["segments"] then + -- With segments, each is a horizontal segment, with start_x < end_x, + -- and we should compute the distance from gesture position to + -- each segment. + local segments_max_y = -1 + local link_is_shortest = false + local segments = link["segments"] + for i=1, #segments do + local segment = segments[i] + local segment_dist + -- Distance here is kept squared (d^2 = diff_x^2 + diff_y^2), + -- and we compute each part individually + -- First, vertical distance (squared) + if pos_y < segment.y0 then -- above the segment height + segment_dist = math.pow(segment.y0 - pos_y, 2) + elseif pos_y > segment.y1 then -- below the segment height + segment_dist = math.pow(pos_y - segment.y1, 2) + else -- gesture pos is on the segment height, no vertical distance + segment_dist = 0 + end + -- Next, horizontal distance (squared) + if pos_x < segment.x0 then -- on the left of segment: calc dist to x0 + segment_dist = segment_dist + math.pow(segment.x0 - pos_x, 2) + elseif pos_x > segment.x1 then -- on the right of segment : calc dist to x1 + segment_dist = segment_dist + math.pow(pos_x - segment.x1, 2) + -- else -- gesture pos is in the segment width, no horizontal distance + end + if shortest_dist == nil or segment_dist < shortest_dist then + selected_link = link + shortest_dist = segment_dist + link_is_shortest = true + end + if segment.y1 > segments_max_y then + segments_max_y = segment.y1 + end + end + if link_is_shortest then + -- update the selected_link we just set with its lower segment y + selected_link.link_y = segments_max_y + end + else + -- Before "segments" were available, we did this: + -- We'd only get a horizontal segment if the link is on a single line. + -- When it is multi-lines, we can't do much calculation... + -- We used to just check distance from start_x and end_x, and + -- we could miss a tap in the middle of a long link. + -- (also start_y = end_y = the top of the rect for a link on a single line) + 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 + selected_link.link_y = link.end_y + shortest_dist = min_dist + end 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 shortest_dist then + selected_distance2 = shortest_dist + end if selected_link then - logger.dbg("original selected_link", selected_link) + logger.dbg("nearest selected_link", selected_link) + -- Check a_xpointer is coherent, use it as from_xpointer only if it is + local from_xpointer = nil + if selected_link.a_xpointer and self:isXpointerCoherent(selected_link.a_xpointer) then + from_xpointer = selected_link.a_xpointer + end -- 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"], + xpointer = selected_link.section, + marker_xpointer = selected_link.section, + from_xpointer = from_xpointer, + -- (keep a_xpointer even if incoherent, might be needed for + -- footnote detection (better than nothing if incoherent) + a_xpointer = selected_link.a_xpointer, + -- keep the link y position, so we can keep its highlight shown + -- a bit more time if it was hidden by the footnote popup + link_y = selected_link.link_y, } - 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) + if max_distance and selected_distance2 and selected_distance2 > math.pow(max_distance, 2) then + logger.dbg("nearest link is further than max distance, ignoring it") + else + return self:onGotoLink(selected_link, false, allow_footnote_popup) + end end end @@ -647,4 +878,188 @@ function ReaderLink:onGoToLatestBookmark(ges) end end +function ReaderLink:showAsFootnotePopup(link, neglect_current_location) + if self.ui.document.info.has_pages then + return false -- not supported + end + + local source_xpointer = link.from_xpointer or link.a_xpointer + local target_xpointer = link.xpointer + if not source_xpointer or not target_xpointer then + return false + end + local trust_source_xpointer = link.from_xpointer ~= nil + + -- For reference, Kobo information and conditions for showing a link as popup: + -- https://github.com/kobolabs/epub-spec#footnotesendnotes-are-fully-supported-across-kobo-platforms + -- Calibre has its own heuristics to decide if a link is to a footnote or not, + -- and what to gather around the footnote target as the footnote content to display: + -- Nearly same logic, implemented in python and in coffeescript: + -- https://github.com/kovidgoyal/calibre/blob/master/src/pyj/read_book/footnotes.pyj + -- https://github.com/kovidgoyal/calibre/blob/master/src/calibre/ebooks/oeb/display/extract.coffee + + -- We do many tests, including most of those done by Kobo and Calibre, to + -- detect if a link is to a footnote. + -- The detection is done in cre.cpp, because it makes use of DOM navigation and + -- inspection that can't be done from Lua (unless we add many proxy functions) + + -- Detection flags, to allow tweaking a bit cre.cpp code if needed + local flags = 0 + + -- If no detection decided, fallback to false (not a footnote, so, follow link) + if isPreferFootnoteEnabled() then + flags = flags + 0x0001 -- if set, fallback to true + end + + if trust_source_xpointer then + -- trust source_xpointer: allow checking attribute and styles + -- if not trusted, checks marked (*) don't apply + flags = flags + 0x0002 + end + + -- Trust role= and epub:type= attribute values if defined, for source(*) and target + -- (If needed, we could add a check for a private CSS property "-cr-hint: footnote" + -- or "-cr-hint: noteref", so one can define it to specific classes with Styles + -- tweaks.) + flags = flags + 0x0004 + -- flags = flags + 0x0008 -- Unused yet + + -- TARGET STATUS AND SOURCE RELATION + -- Target must have an anchor #id (ie: must not be a simple link to a html file) + flags = flags + 0x0010 + -- Target must come after source in the book + -- (Glossary definitions may point to others before, so avoid this check + -- if user wants more footnotes) + if not isPreferFootnoteEnabled() then + flags = flags + 0x0020 + end + -- Target must not be a target of a TOC entry + flags = flags + 0x0040 + -- flags = flags + 0x0080 -- Unused yet + + -- SOURCE NODE CONTEXT + -- (*) Source link must not be empty content, and must not be the only content of + -- its parent block tag (this could mean it's a chapter title in an inline ToC) + flags = flags + 0x0100 + -- (*) Source node vertical alignment: + -- check that all non-empty-nor-space-only children have their computed + -- vertical-align: any of: sub super top bottom (which will be the case + -- whether a parent or the childre themselves are in a or ) + -- (Also checks if parent or children are or , which may + -- have been tweaked with CSS to not have one of these vertical-align.) + flags = flags + 0x0200 + -- (*) Source node text (punctuation and parens stripped) is a number + -- (3 digits max, to avoid catching years ... but only years>1000) + flags = flags + 0x0400 + -- (*) Source node text (punctuation and parens stripped) is 1 or 2 letters, + -- with 0 to 2 numbers (a, z, ab, 1a, B2) + flags = flags + 0x0800 + + -- TARGET NODE CONTEXT + -- Target must not contain, or be contained, in H1..H6 + flags = flags + 0x1000 + -- flags = flags + 0x2000 -- Unused yet + -- Try to extend footnote, to gather more text after target + flags = flags + 0x4000 + -- Extended target readable text (not accounting HTML tags) must not be + -- larger than max_text_size + flags = flags + 0x8000 + local max_text_size = 10000 -- nb of chars + + logger.dbg("Checking if link is to a footnote:", flags, source_xpointer, target_xpointer) + local is_footnote, reason, extStopReason, extStartXP, extEndXP = + self.ui.document:isLinkToFootnote(source_xpointer, target_xpointer, flags, max_text_size) + if not is_footnote then + logger.info("not a footnote:", reason) + return false + end + logger.info("is a footnote:", reason) + if extStartXP then + logger.info(" extended until:", extStopReason) + logger.info(extStartXP) + logger.info(extEndXP) + else + logger.info(" not extended because:", extStopReason) + end + -- OK, done with the dirty footnote detection work, we can now + -- get back to the fancy UI stuff + + -- We don't request CSS files, to have a more consistent footnote style. + -- (we still get and give to MuPDF styles set with style="" ) + -- (We also don't because MuPDF is quite sensitive to bad css, and may + -- then just ignore the whole stylesheet, including our own declarations + -- we add at start) + -- + -- flags = 0x0000 to get the simplest/purest HTML without CSS + local html + if extStartXP and extEndXP then + html = self.ui.document:getHTMLFromXPointers(extStartXP, extEndXP, 0x0000) + else + html = self.ui.document:getHTMLFromXPointer(target_xpointer, 0x0000, true) + -- from_final_parent = true to get a possibly more complete footnote + end + if not html then + logger.info("failed getting HTML for xpointer:", target_xpointer) + return false + end + + -- if false then -- for debug, to display html + -- UIManager:show( require("ui/widget/textviewer"):new{text = html}) + -- return true + -- end + + -- As we stay on the current page, we can highlight the selected link + -- (which might not be seen when covered by FootnoteWidget) + local close_callback = nil + if link.from_xpointer then -- coherent xpointer + self.ui.document:highlightXPointer(link.from_xpointer) + UIManager:setDirty(self.dialog, "ui") + close_callback = function(footnote_height) + -- remove this highlight (actually all) on close + local clear_highlight = function() + self.ui.document:highlightXPointer() + UIManager:setDirty(self.dialog, "ui") + end + if footnote_height then + -- If the link was hidden by the footnote popup, + -- delay a bit its clearing, so the user can see + -- it and know where to start reading again + local footnote_top_y = Screen:getHeight() - footnote_height + if link.link_y > footnote_top_y then + UIManager:scheduleIn(0.5, clear_highlight) + else + clear_highlight() + end + else + clear_highlight() + end + end + end + + -- We give FootnoteWidget the document margins and font size, so + -- it can base its own values on them (note that this can look + -- misaligned when floating punctuation is enabled, as margins then + -- don't have a fixed size) + local FootnoteWidget = require("ui/widget/footnotewidget") + local popup + popup = FootnoteWidget:new{ + html = html, + doc_font_size = Screen:scaleBySize(self.ui.font.font_size), + doc_margins = self.ui.document._document:getPageMargins(), + close_callback = close_callback, + follow_callback = function() -- follow the link on swipe west + UIManager:close(popup) + self:onGotoLink(link, neglect_current_location) + end, + on_tap_close_callback = function(arg, ges) + -- on tap outside, see if we are tapping on another footnote, + -- and display it if we do (avoid the need for 2 taps) + self:onTap(arg, ges) + end, + dialog = self.dialog, + } + UIManager:show(popup) + return true +end + return ReaderLink diff --git a/frontend/apps/reader/modules/readerrolling.lua b/frontend/apps/reader/modules/readerrolling.lua index 36e6b36ef..605e5b95e 100644 --- a/frontend/apps/reader/modules/readerrolling.lua +++ b/frontend/apps/reader/modules/readerrolling.lua @@ -703,12 +703,12 @@ end --[[ currently we don't need to get page links on each page/pos update since we can check link on the fly when tapping on the screen ---]] function ReaderRolling:updatePageLink() logger.dbg("update page link") local links = self.ui.document:getPageLinks() self.view.links = links end +--]] function ReaderRolling:onSetStatusLine(status_line) self.cre_top_bar_enabled = status_line == 0 diff --git a/frontend/document/credocument.lua b/frontend/document/credocument.lua index c2a619f69..41728422a 100644 --- a/frontend/document/credocument.lua +++ b/frontend/document/credocument.lua @@ -359,14 +359,23 @@ function CreDocument:getCurrentPos() return self._document:getCurrentPos() end -function CreDocument:getPageLinks() - return self._document:getPageLinks() +function CreDocument:getPageLinks(internal_links_only) + return self._document:getPageLinks(internal_links_only) end function CreDocument:getLinkFromPosition(pos) return self._document:getLinkFromPosition(pos.x, pos.y) end +function CreDocument:isLinkToFootnote(source_xpointer, target_xpointer, flags, max_text_size) + return self._document:isLinkToFootnote(source_xpointer, target_xpointer, flags, max_text_size) +end + +function CreDocument:highlightXPointer(xp) + -- with xp=nil, clears previous highlight(s) + return self._document:highlightXPointer(xp) +end + function CreDocument:getDocumentFileContent(filepath) if filepath then return self._document:getDocumentFileContent(filepath) diff --git a/frontend/ui/widget/footnotewidget.lua b/frontend/ui/widget/footnotewidget.lua new file mode 100644 index 000000000..7b386f543 --- /dev/null +++ b/frontend/ui/widget/footnotewidget.lua @@ -0,0 +1,345 @@ +local Blitbuffer = require("ffi/blitbuffer") +local BottomContainer = require("ui/widget/container/bottomcontainer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local Event = require("ui/event") +local FrameContainer = require("ui/widget/container/framecontainer") +local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") +local HorizontalGroup = require("ui/widget/horizontalgroup") +local HorizontalSpan = require("ui/widget/horizontalspan") +local InputContainer = require("ui/widget/container/inputcontainer") +local LineWidget = require("ui/widget/linewidget") +local ScrollHtmlWidget = require("ui/widget/scrollhtmlwidget") +local Size = require("ui/size") +local UIManager = require("ui/uimanager") +local VerticalGroup = require("ui/widget/verticalgroup") +local VerticalSpan = require("ui/widget/verticalspan") +local _ = require("gettext") +local Screen = Device.screen +local T = require("ffi/util").template + +-- If we wanted to use the default font set for the book, +-- we'd need to add a few functions to crengine and cre.cpp +-- to get the font files paths (for each font, regular, italic, +-- bold...) so we can pass them to MuPDF with: +-- @font-face { +-- font-family: 'KOReader Footnote Font'; +-- src: url("%1"); +-- } +-- @font-face { +-- font-family: 'KOReader Footnote Font'; +-- src: url("%2"); +-- font-style: italic; +-- } +-- body { +-- font-family: 'KOReader Footnote Font'; +-- } +-- But it looks quite fine if we use "Noto Sans": the difference in font look +-- (Sans, vs probably Serif in the book) will help noticing this is a KOReader +-- UI element, and somehow justify the difference in looks. + +-- Note: we can't use < or > in comments in the CSS, or MuPDF complains with: +-- error: css syntax error: unterminated comment. +-- So, HTML tags in comments are written upppercase (eg:
  • => LI) + +-- Independent string for @page, so we can T() it individually, +-- without needing to escape % in DEFAULT_CSS +local PAGE_CSS = [[ +@page { + margin: %1 %2 %3 %4; + font-family: '%5'; +} +%6 +]] + +-- Make default MuPDF styles (source/html/html-layout.c) a bit +-- more similar to our epub.css ones, and more condensed to fit +-- in a small bottom pannel +local DEFAULT_CSS = [[ +body { + margin: 0; /* MuPDF: margin: 1em */ + padding: 0; + line-height: 1.3; + text-align: justify; +} +h1, h2, h3, h4, h5, h6 { margin: 0; } /* MuPDF: margin: XXem 0 , vary with level */ +blockquote { margin: 0 3em } /* MuPDF: margin: 1em 40px */ +p { margin: 0; } /* MuPDF: margin: 1em 0 */ +ol { margin: 0.5em 0; } /* MuPDF: margin: 1em 0; padding: 0 0 0 30pt */ +ul { margin: 0.5em 0; } /* MuPDF: margin: 1em 0; padding: 0 0 0 30pt */ +dl { margin: 0.5em; } /* MuPDF: margin: 1em 0 */ +dd { margin-left: 1.3em; } /* MuPDF: margin: 0 0 0 40px */ +pre { margin: 0.5em 0; } /* MuPDF: margin: 1em 0 */ +a { color: black; } /* MuPDF: color: #06C; */ +/* MuPDF has no support for text-decoration, so we can't underline links, + * which is fine as we don't really need to provide link support */ + +/* MuPDF draws the bullet for a standalone LI outside the margin. + * Avoid it being displayed if the first node is a LI (in + * Wikipedia EPUBs, each footnote is a LI */ +body > li { list-style-type: none; } + +/* Remove any (possibly multiple) backlinks in Wikipedia EPUBs footnotes */ +.noprint { display: none; } +]] + +-- Add this if needed for debugging: +-- @page { background-color: #cccccc; } +-- body { background-color: #eeeeee; } + +-- Widget to display footnote HTML content +local FootnoteWidget = InputContainer:new{ + html = nil, + css = nil, + -- font_face can't really be overriden, it needs to be known by MuPDF + font_face = "Noto Sans", + -- For the doc_* values, we expect to be provided with the real + -- (already scaled) sizes in screen pixels + doc_font_size = Screen:scaleBySize(18), + doc_margins = { + left = Screen:scaleBySize(20), + right = Screen:scaleBySize(20), + top = Screen:scaleBySize(10), + bottom = Screen:scaleBySize(10), + }, + width = Screen:getWidth(), + height = math.floor(Screen:getHeight() * 1/3), -- will be decreased when content is smaller + follow_callback = nil, + on_tap_close_callback = nil, + close_callback = nil, + dialog = nil, +} + +function FootnoteWidget:init() + if Device:isTouchDevice() then + local range = Geom:new{ + x = 0, y = 0, + w = Screen:getWidth(), + h = Screen:getHeight(), + } + self.ges_events = { + TapClose = { + GestureRange:new{ + ges = "tap", + range = range, + } + }, + SwipeFollow = { + GestureRange:new{ + ges = "swipe", + range = range, + } + }, + HoldStartText = { + GestureRange:new{ + ges = "hold", + range = range, + }, + }, + HoldPanText = { + GestureRange:new{ + ges = "hold", + range = range, + }, + }, + HoldReleaseText = { + GestureRange:new{ + ges = "hold_release", + range = range, + }, + -- callback function when HoldReleaseText is handled as args + args = function(text, hold_duration) + if self.dialog then + local lookup_target = hold_duration < 3.0 and "LookupWord" or "LookupWikipedia" + self.dialog:handleEvent( + Event:new(lookup_target, text) + ) + end + end + }, + } + end + if Device:hasKeys() then + self.key_events = { + Close = { {"Back"}, doc = "cancel" } + } + end + + -- Workaround bugs in MuPDF: + -- There is something with its handling of
    : + --
    abc
    def
    : 2 lines, no space in between + --
    abc
    def
    : 3 lines, empty line in between + -- Remove any attribute, let a
    be a plain
    + self.html = self.html:gsub([[]*>]], [[
    ]]) + + -- We may use a font size a bit smaller than the document one (because + -- footnotes are usually smaller, and because NotoSans is a bit on the + -- larger size when compared to other fonts at the same size) + local font_size = self.doc_font_size - 2 + + -- We want to display the footnote text with the same margins as + -- the document, but keep the scrollbar in the right margin, so + -- both small footnotes (without scrollbar) and longer ones (with + -- scrollbar) have their text aligned with the document text. + -- MuPDF, when rendering some footnotes, may put list item + -- bullets in its own left margin. To get a chance to have them + -- shown, we let MuPDF handle our left margin. + local html_left_margin = self.doc_margins.left .. "px" + local css = T(PAGE_CSS, "0", "0", "0", html_left_margin, -- top right bottom left + self.font_face, DEFAULT_CSS) + if self.css then -- add any provided css + css = css .. "\n" .. self.css + end + -- require("logger").dbg("CSS:", css) + -- require("logger").dbg("HTML:", self.html) + + -- Scrollbar on the right: the document may have quite large margins, + -- and we don't want a scrollbar that large. + -- Ensute a not too large scrollbar, and bring it closer to the screen + -- edge, leaving the remaining available room between it and the text. + local item_width = math.min(math.ceil(self.doc_margins.right * 2/5), Screen:scaleBySize(10)) + local scroll_bar_width = item_width + local padding_right = item_width + local text_scroll_span = self.doc_margins.right - scroll_bar_width - padding_right + if text_scroll_span < padding_right then + -- With small doc margins, space between text and scrollbar may get + -- too small: switch it with right padding + text_scroll_span, padding_right = padding_right, text_scroll_span + end + local htmlwidget_width = self.width - padding_right + + -- Top and bottom padding: we'd rather not use document margins: + -- they can be large, which makes sense at screen edges, but + -- would be too large for our top padding. Also, MuPDF will often + -- let some blank area at bottom with its line layout and page + -- breaking algorithms, which will visually add to the bottom padding. + local padding_top = Size.padding.large + local padding_bottom = Size.padding.large + local htmlwidget_height = self.height - padding_top - padding_bottom + + self.htmlwidget = ScrollHtmlWidget:new{ + html_body = self.html, + css = css, + default_font_size = font_size, + width = htmlwidget_width, + height = htmlwidget_height, + scroll_bar_width = scroll_bar_width, + text_scroll_span = text_scroll_span, + dialog = self.dialog, + } + + -- We only want a top border, so use a LineWidget for that + local top_border_size = Size.line.thick + local vgroup = VerticalGroup:new{ + LineWidget:new{ + dimen = Geom:new{ + w = self.width, + h = top_border_size, + } + }, + VerticalSpan:new{ width = padding_top }, + HorizontalGroup:new{ + self.htmlwidget, + HorizontalSpan:new{ width = padding_right }, + }, + VerticalSpan:new{ width = padding_bottom }, + } + + -- If htmlwidget contains only one page (small footnote content), + -- display only the valuable area: push the blank area down the screen + -- (we can do that because no scroll bar will be displayed) + local single_page_height = self.htmlwidget:getSinglePageHeight() -- nil if multi-pages + if single_page_height then + local added_bottom_pad = 0 + -- See if needed: + -- Add a bit to bottom padding, as getSinglePageHeight() cut can be rough + -- added_bottom_pad = font_size * 0.2 + local reduced_height = single_page_height + top_border_size + padding_top + padding_bottom + added_bottom_pad + vgroup = CenterContainer:new{ + dimen = Geom:new{ + h = reduced_height, + w = self.width, + }, + ignore = "height", + vgroup, + } + self.height = reduced_height -- for close_callback + end + + -- Needed only to set a white background + self.container = FrameContainer:new{ + background = Blitbuffer.COLOR_WHITE, + bordersize = 0, + margin = 0, + padding = 0, + vgroup, + } + + self[1] = BottomContainer:new{ + dimen = Screen:getSize(), + self.container + } +end + +function FootnoteWidget:onShow() + UIManager:setDirty(self.dialog, function() + return "ui", self.container.dimen + end) +end + +function FootnoteWidget:onCloseWidget() + if self.close_callback then + self.close_callback(self.height) + end + UIManager:setDirty(self.dialog, function() + return "partial", self.container.dimen + end) +end + +function FootnoteWidget:onClose() + UIManager:close(self) + return true +end + +function FootnoteWidget:onTapClose(arg, ges) + if ges.pos:notIntersectWith(self.container.dimen) then + self:onClose() + -- Allow ReaderLink to check if our dismiss tap + -- was itself on another footnote, and display + -- it. This avoids having to tap 2 times to + -- see another footnote. + if self.on_tap_close_callback then + self.on_tap_close_callback(arg, ges) + end + return true + end + return false +end + +function FootnoteWidget:onSwipeFollow(arg, ges) + if ges.direction == "west" then + if self.follow_callback then + return self.follow_callback() + end + elseif ges.direction == "south" or ges.direction == "east" then + -- We can close with swipe down. If footnote is scrollable, + -- this event will be eaten by ScrollHtmlWidget, and it will + -- work only when started outside the footnote. + -- Also allow closing with swipe east (like we do to go back + -- from link) + self:onClose() + return true + elseif ges.direction == "north" then + -- no use for now + do end -- luacheck: ignore 541 + else -- diagonal swipe + -- trigger full refresh + UIManager:setDirty(nil, "full") + -- a long diagonal swipe may also be used for taking a screenshot, + -- so let it propagate + end + return false +end + +return FootnoteWidget diff --git a/frontend/ui/widget/htmlboxwidget.lua b/frontend/ui/widget/htmlboxwidget.lua index bb6d7467e..25df52753 100644 --- a/frontend/ui/widget/htmlboxwidget.lua +++ b/frontend/ui/widget/htmlboxwidget.lua @@ -103,6 +103,15 @@ function HtmlBoxWidget:getSize() return self.dimen end +function HtmlBoxWidget:getSinglePageHeight() + if self.page_count == 1 then + local page = self.document:openPage(1) + local x0, y0, x1, y1 = page:getUsedBBox() -- luacheck: no unused + page:close() + return math.ceil(y1) -- no content after y1 + end +end + function HtmlBoxWidget:paintTo(bb, x, y) self.dimen.x = x self.dimen.y = y diff --git a/frontend/ui/widget/scrollhtmlwidget.lua b/frontend/ui/widget/scrollhtmlwidget.lua index 2593f6cd2..7e745f245 100644 --- a/frontend/ui/widget/scrollhtmlwidget.lua +++ b/frontend/ui/widget/scrollhtmlwidget.lua @@ -82,6 +82,10 @@ function ScrollHtmlWidget:init() end end +function ScrollHtmlWidget:getSinglePageHeight() + return self.htmlbox_widget:getSinglePageHeight() +end + function ScrollHtmlWidget:scrollToRatio(ratio) ratio = math.max(0, math.min(1, ratio)) -- ensure ratio is between 0 and 1 (100%) local page_num = 1 + Math.round((self.htmlbox_widget.page_count - 1) * ratio)