2
0
mirror of https://github.com/koreader/koreader synced 2024-11-10 01:10:34 +00:00
koreader/frontend/ui/viewhtml.lua
poire-z eeb3c08457 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.
2023-03-05 23:42:24 +01:00

433 lines
19 KiB
Lua

--[[--
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