diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index aa234968c..19ad088b4 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -1337,128 +1337,8 @@ end function ReaderHighlight:viewSelectionHTML(debug_view, no_css_files_buttons) if self.ui.paging then return end if self.selected_text and self.selected_text.pos0 and self.selected_text.pos1 then - -- For available flags, see the "#define WRITENODEEX_*" in crengine/src/lvtinydom.cpp - -- Start with valid and classic displayed HTML (with only block nodes indented), - -- including styles found in , linked CSS files content, and misc info. - local html_flags = 0x6830 - if not debug_view then - debug_view = 0 - end - if debug_view == 1 then - -- Each node on a line, with markers and numbers of skipped chars and siblings shown, - -- with possibly invalid HTML (text nodes not escaped) - html_flags = 0x6B5A - elseif debug_view == 2 then - -- Additionally see rendering methods of each node - html_flags = 0x6F5A - elseif debug_view == 3 then - -- Or additionally see unicode codepoint of each char - html_flags = 0x6B5E - end - local html, css_files = self.ui.document:getHTMLFromXPointers(self.selected_text.pos0, - self.selected_text.pos1, html_flags, true) - if html then - -- Make some invisible chars visible - if debug_view >= 1 then - html = html:gsub("\xC2\xA0", "␣") -- no break space: open box - html = html:gsub("\xC2\xAD", "⋅") -- soft hyphen: dot operator (smaller than middle dot ·) - -- Prettify inlined CSS (from , put in an internal - -- element by crengine (the opening tag may - -- include some href=, or end with " ~X>" with some html_flags) - -- (We do that in debug_view mode only: as this may increase - -- the height of this section, we don't want to have to scroll - -- many pages to get to the HTML content on the initial view.) - html = html:gsub("(]*>)%s*(.-)%s*()", function(pre, css_text, post) - return pre .. "\n" .. util.prettifyCSS(css_text) .. post - end) - end - local Font = require("ui/font") - local textviewer - local buttons_hold_callback = function() - -- Allow hiding css files buttons if there are too many - -- and the available height for text is too short - UIManager:close(textviewer) - self:viewSelectionHTML(debug_view, not no_css_files_buttons) - end - local buttons_table = {} - if css_files and not no_css_files_buttons then - for i=1, #css_files do - local button = { - text = T(_("View %1"), BD.filepath(css_files[i])), - callback = function() - local css_text = self.ui.document:getDocumentFileContent(css_files[i]) - local cssviewer - cssviewer = TextViewer:new{ - title = css_files[i], - text = css_text or _("Failed getting CSS content"), - text_face = Font:getFace("smallinfont"), - justified = false, - para_direction_rtl = false, - auto_para_direction = false, - add_default_buttons = true, - buttons_table = { - {{ - text = _("Prettify"), - enabled = css_text and true or false, - callback = function() - UIManager:close(cssviewer) - UIManager:show(TextViewer:new{ - title = css_files[i], - text = util.prettifyCSS(css_text), - text_face = Font:getFace("smallinfont"), - justified = false, - para_direction_rtl = false, - auto_para_direction = false, - }) - end, - }}, - } - } - UIManager:show(cssviewer) - end, - hold_callback = buttons_hold_callback, - } - -- One button per row, to make room for the possibly long css filename - table.insert(buttons_table, {button}) - end - end - local next_debug_text - local next_debug_view = debug_view + 1 - if next_debug_view == 1 then - next_debug_text = _("Switch to debug view") - elseif next_debug_view == 2 then - next_debug_text = _("Switch to rendering debug view") - elseif next_debug_view == 3 then - next_debug_text = _("Switch to unicode debug view") - else - next_debug_view = 0 - next_debug_text = _("Switch to standard view") - end - table.insert(buttons_table, {{ - text = next_debug_text, - callback = function() - UIManager:close(textviewer) - self:viewSelectionHTML(next_debug_view, no_css_files_buttons) - end, - hold_callback = buttons_hold_callback, - }}) - textviewer = TextViewer:new{ - title = _("Selection HTML"), - text = html, - text_face = Font:getFace("smallinfont"), - justified = false, - para_direction_rtl = false, - auto_para_direction = false, - add_default_buttons = true, - default_hold_callback = buttons_hold_callback, - buttons_table = buttons_table, - } - UIManager:show(textviewer) - else - UIManager:show(InfoMessage:new{ - text = _("Failed getting HTML for selection"), - }) - end + local ViewHtml = require("ui/viewhtml") + ViewHtml:viewSelectionHTML(self.ui.document, self.selected_text) end end diff --git a/frontend/document/credocument.lua b/frontend/document/credocument.lua index 95943d5fd..a60797fce 100644 --- a/frontend/document/credocument.lua +++ b/frontend/document/credocument.lua @@ -937,6 +937,10 @@ function CreDocument:getHTMLFromXPointers(xp0, xp1, flags, from_root_node) end end +function CreDocument:getStylesheetsMatchingRulesets(node_dataindex) + return self._document:getStylesheetsMatchingRulesets(node_dataindex) +end + function CreDocument:getNormalizedXPointer(xp) -- Returns false when xpointer is not found in the DOM. -- When requested DOM version >= getDomVersionWithNormalizedXPointers, diff --git a/frontend/ui/viewhtml.lua b/frontend/ui/viewhtml.lua new file mode 100644 index 000000000..b7902963f --- /dev/null +++ b/frontend/ui/viewhtml.lua @@ -0,0 +1,432 @@ +--[[-- +This module shows HTML code and CSS content from crengine documents. +It it used by ReaderHighlight as an action after text selection. +--]] + +local BD = require("ui/bidi") +local Device = require("device") +local Font = require("ui/font") +local InfoMessage = require("ui/widget/infomessage") +local Notification = require("ui/widget/notification") +local TextViewer = require("ui/widget/textviewer") +local UIManager = require("ui/uimanager") +local util = require("util") +local _ = require("gettext") +local T = require("ffi/util").template + +local ViewHtml = { + VIEWS = { + -- For available flags, see the "#define WRITENODEEX_*" in crengine/src/lvtinydom.cpp. + -- Start with valid and classic displayed HTML (with only block nodes indented), + -- including styles found in , linked CSS files content, and misc info. + { _("Switch to standard view"), 0xE830, false }, + + -- Each node on a line, with markers and numbers of skipped chars and siblings shown, + -- with possibly invalid HTML (text nodes not escaped) + { _("Switch to debug view"), 0xEB5A, true }, + + -- Additionally show rendering methods of each node + { _("Switch to rendering debug view"), 0xEF5A, true }, + + -- Or additionally show unicode codepoint of each char + { _("Switch to unicode debug view"), 0xEB5E, true }, + } +} + +-- Main entry point +function ViewHtml:viewSelectionHTML(document, selected_text) + if not selected_text or not selected_text.pos0 or not selected_text.pos1 then + return + end + self:_viewSelectionHTML(document, selected_text, 1, true, false) +end + +function ViewHtml:_viewSelectionHTML(document, selected_text, view, with_css_files_buttons, hide_stylesheet_elem_content) + local next_view = view < #self.VIEWS and view + 1 or 1 + local next_view_text = self.VIEWS[next_view][1] + + local html_flags = self.VIEWS[view][2] + local massage_html = self.VIEWS[view][3] + + local html, css_files, css_selectors_offsets = document:getHTMLFromXPointers(selected_text.pos0, + selected_text.pos1, html_flags, true) + if not html then + UIManager:show(InfoMessage:new{ + text = _("Failed getting HTML for selection"), + }) + return + end + + -- Our substitutions may mess with the offsets in css_selectors_offsets: we need to keep + -- track of shifts induced by these substitutions to correct the offsets + local offset_shifts = {} + local replace_in_html = function(pat, repl) + local new_html = "" + local is_match = false -- given the html we get and our patterns, we know the first part won't be a match + for part in util.gsplit(html, pat, true) do + if is_match then + local r = type(repl) == "function" and repl(part) or repl + local offset_shift = #r - #part + if offset_shift ~= 0 then + table.insert(offset_shifts, {#new_html + #part + 1, offset_shift}) + end + new_html = new_html .. r + else + new_html = new_html .. part + end + is_match = not is_match + end + html = new_html + end + if massage_html then + -- Make some invisible chars visible + replace_in_html("\xC2\xA0", "␣") -- no break space: open box + replace_in_html("\xC2\xAD", "⋅") -- soft hyphen: dot operator (smaller than middle dot ·) + -- Prettify inlined CSS (from , put in an internal + -- element by crengine (the opening tag may + -- include some href=, or end with " ~X>" with some html_flags) + -- (We do that in debug views only: as this may increase the + -- height of this section, we don't want to have to scroll many + -- pages to get to the HTML content on the initial view.) + end + if massage_html or hide_stylesheet_elem_content then + replace_in_html("]*>(.-)", function(s) + local pre, css_text, post = s:match("(]*>)%s*(.-)%s*()") + if hide_stylesheet_elem_content then + return pre .. "[...]" .. post + end + return pre .. "\n" .. util.prettifyCSS(css_text) .. post + end) + end + + local textviewer + -- Prepare bottom buttons and their actions + local buttons_hold_callback = function() + -- Allow hiding css files buttons if there are too many + -- and the available height for text is too short + UIManager:close(textviewer) + self:_viewSelectionHTML(document, selected_text, view, not with_css_files_buttons, hide_stylesheet_elem_content) + end + local buttons_table = {} + if css_files and with_css_files_buttons then + for i=1, #css_files do + local button = { + text = T(_("View %1"), BD.filepath(css_files[i])), + callback = function() + local css_text = document:getDocumentFileContent(css_files[i]) + local cssviewer + cssviewer = TextViewer:new{ + title = css_files[i], + text = css_text or _("Failed getting CSS content"), + text_face = Font:getFace("smallinfont"), + justified = false, + para_direction_rtl = false, + auto_para_direction = false, + add_default_buttons = true, + buttons_table = { + {{ + text = _("Prettify"), + enabled = css_text and true or false, + callback = function() + UIManager:close(cssviewer) + UIManager:show(TextViewer:new{ + title = css_files[i], + text = util.prettifyCSS(css_text), + text_face = Font:getFace("smallinfont"), + justified = false, + para_direction_rtl = false, + auto_para_direction = false, + }) + end, + }}, + } + } + UIManager:show(cssviewer) + end, + hold_callback = buttons_hold_callback, + } + -- One button per row, to make room for the possibly long css filename + table.insert(buttons_table, {button}) + end + end + table.insert(buttons_table, {{ + text = next_view_text, + callback = function() + UIManager:close(textviewer) + self:_viewSelectionHTML(document, selected_text, next_view, with_css_files_buttons, hide_stylesheet_elem_content) + end, + hold_callback = buttons_hold_callback, + }}) + + -- Long-press in the HTML will present a list of CSS selectors related to the element + -- we pressed on, to be copied to clipboard + local text_selection_callback = function(text, hold_duration, start_idx, end_idx, to_source_index_func) + if not css_selectors_offsets or css_selectors_offsets == "" then -- no flag provided + Device.input.setClipboardText(text) + UIManager:show(Notification:new{ + text = _("Selection copied to clipboard.") + }) + return + end + -- We only work with one index (let's choose start_idx), and we want the offset in the utf8 stream + local idx = to_source_index_func(start_idx) + self:_handleLongPress(document, css_selectors_offsets, offset_shifts, idx, function() + UIManager:close(textviewer) + self:_viewSelectionHTML(document, selected_text, view, with_css_files_buttons, not hide_stylesheet_elem_content) + end) + end + + textviewer = TextViewer:new{ + title = _("Selection HTML"), + text = html, + text_face = Font:getFace("smallinfont"), + justified = false, + para_direction_rtl = false, + auto_para_direction = false, + add_default_buttons = true, + default_hold_callback = buttons_hold_callback, + buttons_table = buttons_table, + text_selection_callback = text_selection_callback, + } + UIManager:show(textviewer) +end + +function ViewHtml:_handleLongPress(document, css_selectors_offsets, offset_shifts, idx, stylesheet_elem_callback) + + -- We want to propose for "copy into clipboard" a few interesting selectors related to the element + -- the user long-pressed on, which can then be pasted in "Find" when viewing a stylesheet, or + -- pasted in "Book style tweaks" when willing to tweak the style for this element. + local proposed_selectors = {} + local seen_kind = {} -- only one selector of some kind proposed, to not have too many + local ancestors_classnames_selector = "" -- we will have a final one selecting the whole ancestors + + -- Ignore some crengine internal attributes: + local ignore_attrs = { "StyleSheet" } + -- Some attributes have too variable values, that are not interesting when used as selectors: + local skip_value_attrs = { "href", "id", "style", "title", } + + -- We will also show 2 buttons to show the individual CSS rulesets (selector + declaration) + -- that would match this elements, and this element and its ancestor. + local ancestors = {} + + -- We get as css_selectors_offsets from crengine such content: + -- (Format: Offset in 'html', node level, node dataIndex, element name, class and attribute selectors + -- 0 2 33 body + -- 9 3 449 DocFragment [StyleSheet=stylesheet.css] [id=_doc_fragment_52] [lang=fr-FR] + -- 90 4 465 stylesheet [href=OPS/] + -- 163 4 + -- 168 4 481 body [type=bodymatter] [lang=fr-FR] [lang=fr-FR] .calibre1 + -- 251 5 545 section .chap [type=chapter] [role=doc-chapter] + -- 321 6 561 div + -- 349 7 577 p .justif1 .no-indent [type=main] + -- 395 7 + -- 406 7 593 p .justif1 + -- 457 7 + -- 472 6 + -- 489 5 + -- 501 4 + -- 518 3 + -- 526 2 + local offsets = {} + for line in css_selectors_offsets:gmatch("[^\n]+") do + local t = util.splitToArray(line, "\t") + table.insert(offsets, t) + end + -- Iterate from end until we find a smaller offset (this is the element we are in) + -- and from then on, only deal with elements with a smaller level (the parents) + local cur_level = math.huge + local stop_gathering_selectors = false + for i=#offsets, 1, -1 do + local info = offsets[i] + local offset, level = tonumber(info[1]), tonumber(info[2]) + -- Correct offsets with the shifts caused by our substitutions + for _, offset_shift in ipairs(offset_shifts) do + if offset >= offset_shift[1] then + offset = offset + offset_shift[2] + end + end + if offset <= idx and level < cur_level then -- meeting element or new parent + cur_level = level + if #info > 2 then -- this is an element (and not a level we leave) + local elem = info[4] + table.insert(ancestors, { elem, info[3] }) + if elem == "body" and #proposed_selectors > 0 then + -- Stop and don't include body (unless long-press on itself) + stop_gathering_selectors = true + end + if not stop_gathering_selectors then + if not seen_kind.element then + -- Propose as selector the selected element tag name, ie. "p". + if elem == "stylesheet" then -- long-press on + stylesheet_elem_callback() + return + end + table.insert(proposed_selectors, elem) + end + local all_classnames = "" + local all_attrs = "" + for j=5, #info do + local sel = info[j] + if sel:sub(1,1) == "." then + if not seen_kind.individual_classname then + -- Propose as selectors each of the classnames of the selected element + -- (or its neareast parent with a class), ie. ".justif1" , ".no-indent". + table.insert(proposed_selectors, sel) + end + all_classnames = all_classnames .. sel + else + local attrname = sel:match("^%[(.-)=") or "" + if elem == "DocFragment" then + if attrname == "id" then -- keep id= full, it can be useful with DocFragment + all_attrs = all_attrs .. sel + end + elseif util.arrayContains(ignore_attrs, attrname) then + do end -- luacheck: ignore 541 + elseif util.arrayContains(skip_value_attrs, attrname) then + all_attrs = all_attrs .. "[" .. attrname .. "]" + else + all_attrs = all_attrs .. sel + end + end + end + if all_classnames ~= "" and not seen_kind.all_classnames then + -- Propose as selector the selected element (or its neareast parent with a class) + -- with all its classnames concatenated, ie. "p.justif1.no-indent". + table.insert(proposed_selectors, elem .. all_classnames) + seen_kind.all_classnames = true + seen_kind.individual_classname = true + end + if all_attrs ~= "" and not seen_kind.element then + -- Propose as selector the selected element with all its attributes (and classnames), + -- ie. "p.justif1.no-indent[type=main]". + table.insert(proposed_selectors, elem .. all_classnames .. all_attrs) + end + -- Accumulate into the full ancestor element & classname selector + if ancestors_classnames_selector ~= "" then + ancestors_classnames_selector = " > " .. ancestors_classnames_selector + end + ancestors_classnames_selector = elem .. all_classnames .. ancestors_classnames_selector + seen_kind.element = true -- done with selectors targetting the selected element only + end + if elem == "DocFragment" or elem == "FictionBook" then + -- Ignore the root node up these + break; + end + end + end + end + + -- Add a button for each proposed selector to copy it, avoiding possible duplicates + table.insert(proposed_selectors, ancestors_classnames_selector) -- ie. "section.chap > div > p.justif1.no-indent + local copy_buttons = {} + local add_copy_button = function(text) + table.insert(copy_buttons, {{ + text = text, + callback = function() + Device.input.setClipboardText(text) + UIManager:show(Notification:new{ + text = _("Selector copied to clipboard.") + }) + end, + -- Allow "appending" with long-press, in case we want to gather a few selectors + -- at once to later work with them in a style tweak + hold_callback = function() + Device.input.setClipboardText(Device.input.getClipboardText() .. "\n" .. text) + UIManager:show(Notification:new{ + text = _("Selector appended to clipboard.") + }) + end, + }}) + end + local already_added = {} + for _, text in ipairs(proposed_selectors) do + if text and text ~= "" and not already_added[text] then + add_copy_button(text) + already_added[text] = true + end + end + + -- Add Show matched stylesheet rulesets buttons + table.insert(copy_buttons, {}) + table.insert(copy_buttons, {{ + text = _("Show matched stylesheets rules (element only)"), + callback = function() + self:_showMatchingSelectors(document, ancestors, false) + end, + }}) + table.insert(copy_buttons, {{ + text = _("Show matched stylesheets rules (all ancestors)"), + callback = function() + self:_showMatchingSelectors(document, ancestors, true) + end, + }}) + + local ButtonDialogTitle = require("ui/widget/buttondialogtitle") + local widget = ButtonDialogTitle:new{ + title = _("Copy to clipboard:"), + title_align = "center", + width_factor = 0.8, + use_info_style = false, + buttons = copy_buttons, + } + UIManager:show(widget) +end + +function ViewHtml:_showMatchingSelectors(document, ancestors, show_all_ancestors) + local snippets + if not show_all_ancestors then + local node_dataindex = ancestors[1][2] + snippets = document:getStylesheetsMatchingRulesets(node_dataindex) + else + snippets = {} + local elements = {} + for _, ancestor in ipairs(ancestors) do + table.insert(elements, 1, ancestor[1]) + end + for i = 1, #ancestors do + local node_dataindex = ancestors[i][2] + if #snippets > 0 then + -- Separate them with 2 blank lines + table.insert(snippets, "") + table.insert(snippets, "") + end + local desc = table.concat(elements, " > ", 1, #ancestors - i + 1) + table.insert(snippets, "/* ====== " .. desc .. " */") + util.arrayAppend(snippets, document:getStylesheetsMatchingRulesets(node_dataindex)) + end + end + + local title = show_all_ancestors and _("Matching rulesets (all ancestors)") + or _("Matching rulesets (element only)") + local css_text = table.concat(snippets, "\n") + local cssviewer + cssviewer = TextViewer:new{ + title = title, + text = css_text or _("No matching rulesets"), + text_face = Font:getFace("smallinfont"), + justified = false, + para_direction_rtl = false, + auto_para_direction = false, + add_default_buttons = true, + buttons_table = { + {{ + text = _("Prettify"), + enabled = css_text and true or false, + callback = function() + UIManager:close(cssviewer) + UIManager:show(TextViewer:new{ + title = title, + text = util.prettifyCSS(css_text), + text_face = Font:getFace("smallinfont"), + justified = false, + para_direction_rtl = false, + auto_para_direction = false, + }) + end, + }}, + } + } + UIManager:show(cssviewer) +end + +return ViewHtml