From eeb3c0845716c616f21d6c42d88a63922de203d1 Mon Sep 17 00:00:00 2001 From: poire-z Date: Sun, 5 Mar 2023 13:54:25 +0100 Subject: [PATCH] View HTML: add CSS helpers with long-press Move View html code from ReaderHighlight to a new dedicated module. Long-press on an element or its text in the HTML will show a popup with a list of selectors related to this element that can be copied to clipboard (to be pasted in Find or in a Book style tweak). 2 addtional buttons in this popup allow seeing all the CSS rulesets in all stylesheets that would be matched by this element, which should make it easier understanding the publisher stylesheets and using or creating style tweaks. --- .../apps/reader/modules/readerhighlight.lua | 124 +---- frontend/document/credocument.lua | 4 + frontend/ui/viewhtml.lua | 432 ++++++++++++++++++ 3 files changed, 438 insertions(+), 122 deletions(-) create mode 100644 frontend/ui/viewhtml.lua 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