From b40bc53fc79f748dad767ad995a2110b568a1007 Mon Sep 17 00:00:00 2001 From: TnS-hun Date: Mon, 15 Jan 2018 23:51:43 +0100 Subject: [PATCH] HTML dictionary link support (#3603) --- .../apps/reader/modules/readerdictionary.lua | 146 +++++++++++------- frontend/ui/widget/dictquicklookup.lua | 4 + frontend/ui/widget/htmlboxwidget.lua | 75 +++++++-- frontend/ui/widget/scrollhtmlwidget.lua | 2 + frontend/util.lua | 14 +- 5 files changed, 174 insertions(+), 67 deletions(-) diff --git a/frontend/apps/reader/modules/readerdictionary.lua b/frontend/apps/reader/modules/readerdictionary.lua index 50bdee651..ec0513cf1 100644 --- a/frontend/apps/reader/modules/readerdictionary.lua +++ b/frontend/apps/reader/modules/readerdictionary.lua @@ -2,6 +2,7 @@ local ConfirmBox = require("ui/widget/confirmbox") local DataStorage = require("datastorage") local Device = require("device") local DictQuickLookup = require("ui/widget/dictquicklookup") +local Geom = require("ui/geometry") local InfoMessage = require("ui/widget/infomessage") local InputContainer = require("ui/widget/container/inputcontainer") local JSON = require("json") @@ -146,6 +147,8 @@ function ReaderDictionary:init() end function ReaderDictionary:updateSdcvDictNamesOptions() + self.enabled_dict_names = nil + -- We cannot tell sdcv which dictionaries to ignore, but we -- can tell it which dictionaries to use, by using multiple -- -u options. @@ -153,28 +156,16 @@ function ReaderDictionary:updateSdcvDictNamesOptions() -- them for ordering queries and results) local dicts_disabled = G_reader_settings:readSetting("dicts_disabled") if not next(dicts_disabled) then - -- no dict disabled, no need to use any -u option - self.sdcv_dictnames_options_raw = nil - self.sdcv_dictnames_options_escaped = nil return end - local u_options_raw = {} -- for android call (individual unesscaped elements) - local u_options_escaped = {} -- for other devices call via shell for _, ifo in pairs(available_ifos) do if not dicts_disabled[ifo.file] then - table.insert(u_options_raw, "-u") - table.insert(u_options_raw, ifo.name) - -- Escape chars in dictname so it's ok for the shell command - -- local u_esc = ("-u %q"):format(ifo.name) - -- This may be safer than using lua's %q: - local u_esc = "-u '" .. ifo.name:gsub("'", "'\\''") .. "'" - table.insert(u_options_escaped, u_esc) + if not self.enabled_dict_names then + self.enabled_dict_names = {} + end + table.insert(self.enabled_dict_names, ifo.name) end - -- Note: if all dicts are disabled, we won't get any -u, and so - -- all dicts will be queried. end - self.sdcv_dictnames_options_raw = u_options_raw - self.sdcv_dictnames_options_escaped = table.concat(u_options_escaped, " ") end function ReaderDictionary:addToMainMenu(menu_items) @@ -306,14 +297,58 @@ If you'd like to change the order in which dictionaries are queried (and their r end function ReaderDictionary:onLookupWord(word, box, highlight, link) + logger.dbg("dict lookup word:", word, box) + -- escape quotes and other funny characters in word + word = self:cleanSelection(word) + logger.dbg("dict stripped word:", word) + self.highlight = highlight + -- Wrapped through Trapper, as we may be using Trapper:dismissablePopen() in it Trapper:wrap(function() - self:stardictLookup(word, box, link) + self:stardictLookup(word, self.enabled_dict_names, not self.disable_fuzzy_search, box, link) end) return true end +function ReaderDictionary:onHtmlDictionaryLinkTapped(dictionary, link) + if not link.uri then + return + end + + -- The protocol is either "bword" or there is no protocol, only the word. + -- https://github.com/koreader/koreader/issues/3588#issuecomment-357088125 + local url_prefix = "bword://" + local word + if link.uri:sub(1,url_prefix:len()) == url_prefix then + word = link.uri:sub(url_prefix:len() + 1) + elseif link.uri:find("://") then + return + else + word = link.uri + end + + if word == "" then + return + end + + local link_box = Geom:new{ + x = link.x0, + y = link.y0, + w = math.abs(link.x1 - link.x0), + h = math.abs(link.y1 - link.y0), + } + + -- Only the first dictionary window stores the highlight, this way the highlight + -- is only removed when there are no more dictionary windows open. + self.highlight = nil + + -- Wrapped through Trapper, as we may be using Trapper:dismissablePopen() in it + Trapper:wrap(function() + self:stardictLookup(word, {dictionary}, false, link_box, nil) + end) +end + --- Gets number of available, enabled, and disabled dictionaries -- @treturn int nb_available -- @treturn int nb_enabled @@ -460,27 +495,7 @@ function ReaderDictionary:dismissLookupInfo() self.lookup_progress_msg = nil end -function ReaderDictionary:stardictLookup(word, box, link) - logger.dbg("lookup word:", word, box) - -- escape quotes and other funny characters in word - word = self:cleanSelection(word) - logger.dbg("stripped word:", word) - if word == "" then - return - end - - if not self.disable_lookup_history then - local book_title = self.ui.doc_settings and self.ui.doc_settings:readSetting("doc_props").title or _("Dictionary lookup") - lookup_history:addTableItem("lookup_history", { - book_title = book_title, - time = os.time(), - word = word, - }) - end - - if not self.disable_fuzzy_search then - self:showLookupInfo(word) - end +function ReaderDictionary:startSdcv(word, dict_names, fuzzy_search) local final_results = {} local seen_results = {} -- Allow for two sdcv calls : one in the classic data/dict, and @@ -503,30 +518,31 @@ function ReaderDictionary:stardictLookup(word, box, link) definition = _([[No dictionaries installed. Please search for "Dictionary support" in the KOReader Wiki to get more information about installing new dictionaries.]]), } } - self:showDict(word, final_results, box, link) - return + return final_results end local lookup_cancelled = false - local common_options = self.disable_fuzzy_search and "-nje" or "-nj" for _, dict_dir in ipairs(dict_dirs) do if lookup_cancelled then break -- don't do any more lookup on additional dict_dirs end + + local args = {"./sdcv", "--utf8-input", "--utf8-output", "--json-output", "--non-interactive", "--data-dir", dict_dir, word} + if not fuzzy_search then + table.insert(args, "--exact-search") + end + if dict_names then + for _, opt in pairs(dict_names) do + table.insert(args, "-u") + table.insert(args, opt) + end + end + local results_str = nil if Device:isAndroid() then local A = require("android") - local args = {"./sdcv", "--utf8-input", "--utf8-output", common_options, word, "--data-dir", dict_dir} - if self.sdcv_dictnames_options_raw then - for _, opt in pairs(self.sdcv_dictnames_options_raw) do - table.insert(args, opt) - end - end results_str = A.stdout(unpack(args)) else - local cmd = ("./sdcv --utf8-input --utf8-output %q %q --data-dir %q"):format(common_options, word, dict_dir) - if self.sdcv_dictnames_options_escaped then - cmd = cmd .. " " .. self.sdcv_dictnames_options_escaped - end + local cmd = util.shell_escape(args) -- cmd = "sleep 7 ; " .. cmd -- uncomment to simulate long lookup time if self.lookup_progress_msg then @@ -584,7 +600,30 @@ function ReaderDictionary:stardictLookup(word, box, link) } } end - self:showDict(word, tidyMarkup(final_results), box, link) + + return final_results +end + +function ReaderDictionary:stardictLookup(word, dict_names, fuzzy_search, box, link) + if word == "" then + return + end + + if not self.disable_lookup_history then + local book_title = self.ui.doc_settings and self.ui.doc_settings:readSetting("doc_props").title or _("Dictionary lookup") + lookup_history:addTableItem("lookup_history", { + book_title = book_title, + time = os.time(), + word = word, + }) + end + + if fuzzy_search then + self:showLookupInfo(word) + end + + local results = self:startSdcv(word, dict_names, fuzzy_search) + self:showDict(word, tidyMarkup(results), box, link) end function ReaderDictionary:showDict(word, results, box, link) @@ -613,6 +652,9 @@ function ReaderDictionary:showDict(word, results, box, link) self.view.footer:updateFooter() end end, + html_dictionary_link_tapped_callback = function(dictionary, html_link) + self:onHtmlDictionaryLinkTapped(dictionary, html_link) + end, } table.insert(self.dict_window_list, self.dict_window) UIManager:show(self.dict_window) diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index b54a8fd6d..8a6b4200a 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -59,6 +59,7 @@ local DictQuickLookup = InputContainer:new{ button_padding = Screen:scaleBySize(14), -- refresh_callback will be called before we trigger full refresh in onSwipe refresh_callback = nil, + html_dictionary_link_tapped_callback = nil, } local highlight_strings = { @@ -273,6 +274,9 @@ function DictQuickLookup:update() width = self.width, height = self.is_fullpage and self.height*0.75 or self.height*0.7, dialog = self, + html_link_tapped_callback = function(link) + self.html_dictionary_link_tapped_callback(self.dictionary, link) + end, } else text_widget = ScrollTextWidget:new{ diff --git a/frontend/ui/widget/htmlboxwidget.lua b/frontend/ui/widget/htmlboxwidget.lua index 62e5f56b4..7b707d0d9 100644 --- a/frontend/ui/widget/htmlboxwidget.lua +++ b/frontend/ui/widget/htmlboxwidget.lua @@ -2,8 +2,10 @@ HTML widget (without scroll bars). --]] +local Device = require("device") local DrawContext = require("ffi/drawcontext") local Geom = require("ui/geometry") +local GestureRange = require("ui/gesturerange") local InputContainer = require("ui/widget/container/inputcontainer") local Mupdf = require("ffi/mupdf") local Screen = require("device").screen @@ -19,8 +21,22 @@ local HtmlBoxWidget = InputContainer:new{ page_number = 1, hold_start_pos = nil, hold_start_tv = nil, + html_link_tapped_callback = nil, } +function HtmlBoxWidget:init() + if Device:isTouchDevice() then + self.ges_events = { + TapText = { + GestureRange:new{ + ges = "tap", + range = function() return self.dimen end, + }, + }, + } + end +end + function HtmlBoxWidget:setContent(body, css, default_font_size) -- fz_set_user_css is tied to the context instead of the document so to easily support multiple -- HTML dictionaries with different CSS, we embed the stylesheet into the HTML instead of using @@ -118,12 +134,22 @@ function HtmlBoxWidget:onCloseWidget() self:free() end -function HtmlBoxWidget:onHoldStartText(_, ges) - self.hold_start_pos = Geom:new{ - x = ges.pos.x - self.dimen.x, - y = ges.pos.y - self.dimen.y, +function HtmlBoxWidget:getPosFromAbsPos(abs_pos) + local pos = Geom:new{ + x = abs_pos.x - self.dimen.x, + y = abs_pos.y - self.dimen.y, } + -- check if the coordinates are actually inside our area + if pos.x < 0 or pos.x >= self.dimen.w or pos.y < 0 or pos.y >= self.dimen.h then + return nil + end + + return pos +end + +function HtmlBoxWidget:onHoldStartText(_, ges) + self.hold_start_pos = self:getPosFromAbsPos(ges.pos) self.hold_start_tv = TimeVal.now() return true @@ -167,18 +193,10 @@ function HtmlBoxWidget:onHoldReleaseText(callback, ges) end local start_pos = self.hold_start_pos - local end_pos = Geom:new{ - x = ges.pos.x - self.dimen.x, - y = ges.pos.y - self.dimen.y, - } - self.hold_start_pos = nil - -- check start and end coordinates are actually inside our area - if start_pos.x < 0 or end_pos.x < 0 or - start_pos.x >= self.dimen.w or end_pos.x >= self.dimen.w or - start_pos.y < 0 or end_pos.y < 0 or - start_pos.y >= self.dimen.h or end_pos.y >= self.dimen.h then + local end_pos = self:getPosFromAbsPos(ges.pos) + if not end_pos then return false end @@ -196,4 +214,33 @@ function HtmlBoxWidget:onHoldReleaseText(callback, ges) return true end +function HtmlBoxWidget:getLinkByPosition(pos) + local page = self.document:openPage(self.page_number) + local links = page:getPageLinks() + page:close() + + for _, link in pairs(links) do + if pos.x >= link.x0 and pos.x < link.x1 and pos.y >= link.y0 and pos.y < link.y1 then + return link + end + end +end + +function HtmlBoxWidget:onTapText(arg, ges) + if G_reader_settings:isFalse("tap_to_follow_links") then + return + end + + if self.html_link_tapped_callback then + local pos = self:getPosFromAbsPos(ges.pos) + if pos then + local link = self:getLinkByPosition(pos) + if link then + self.html_link_tapped_callback(link) + return true + end + end + end +end + return HtmlBoxWidget diff --git a/frontend/ui/widget/scrollhtmlwidget.lua b/frontend/ui/widget/scrollhtmlwidget.lua index 9ba21550d..732fded80 100644 --- a/frontend/ui/widget/scrollhtmlwidget.lua +++ b/frontend/ui/widget/scrollhtmlwidget.lua @@ -22,6 +22,7 @@ local ScrollHtmlWidget = InputContainer:new{ htmlbox_widget = nil, v_scroll_bar = nil, dialog = nil, + html_link_tapped_callback = nil, dimen = nil, width = 0, height = 0, @@ -35,6 +36,7 @@ function ScrollHtmlWidget:init() w = self.width - self.scroll_bar_width - self.text_scroll_span, h = self.height, }, + html_link_tapped_callback = self.html_link_tapped_callback, } self.htmlbox_widget:setContent(self.html_body, self.css, self.default_font_size) diff --git a/frontend/util.lua b/frontend/util.lua index 7bfe0719b..34abc4c89 100644 --- a/frontend/util.lua +++ b/frontend/util.lua @@ -572,7 +572,7 @@ function util.htmlToPlainTextIfHtml(text) end --- Encode the HTML entities in a string --- @string text the string to escape +--- @string text the string to escape -- Taken from https://github.com/kernelsauce/turbo/blob/e4a35c2e3fb63f07464f8f8e17252bea3a029685/turbo/escape.lua#L58-L70 function util.htmlEscape(text) return text:gsub("[}{\">/<'&]", { @@ -585,4 +585,16 @@ function util.htmlEscape(text) }) end +--- Escape list for shell usage +--- @table args the list of arguments to escape +--- @treturn string the escaped and concatenated arguments +function util.shell_escape(args) + local escaped_args = {} + for _, arg in ipairs(args) do + arg = "'" .. arg:gsub("'", "'\\''") .. "'" + table.insert(escaped_args, arg) + end + return table.concat(escaped_args, " ") +end + return util