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.
reviewable/pr10188/r1
poire-z 1 year ago
parent 945a1cc76f
commit eeb3c08457

@ -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 <HEAD>, 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 <HEAD>, put in an internal
-- <body><stylesheet> 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("(<stylesheet[^>]*>)%s*(.-)%s*(</stylesheet>)", 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

@ -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,

@ -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 <HEAD>, 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 <HEAD>, put in an internal
-- <body><stylesheet> 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("<stylesheet[^>]*>(.-)</stylesheet>", function(s)
local pre, css_text, post = s:match("(<stylesheet[^>]*>)%s*(.-)%s*(</stylesheet>)")
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 <body> 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>
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
Loading…
Cancel
Save