You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
koreader/frontend/ui/viewhtml.lua

444 lines
20 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 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, 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
-- (we provided capture_empty_entity=true, to match adjacent 'pat',
-- so here we may get empty 'part', that we can just concatenate)
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("\u{00A0}", "\u{2423}") -- no break space: open box
replace_in_html("\u{00AD}", "\u{22C5}") -- 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
-- Make sure we won't get wrapped just after our indentation if there is no break opportunity later
replace_in_html("\n *", function(s)
return "\n" .. ("\u{00A0}"):rep(#s - 1)
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_type = "code",
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_type = "code",
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_type = "code",
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,
hold_callback = function()
-- skip main stylesheet and style tweaks
self:_showMatchingSelectors(document, ancestors, false, false)
end,
}})
table.insert(copy_buttons, {{
text = _("Show matched stylesheets rules (all ancestors)"),
callback = function()
self:_showMatchingSelectors(document, ancestors, true)
end,
hold_callback = function()
-- skip main stylesheet and style tweaks
self:_showMatchingSelectors(document, ancestors, true, false)
end,
}})
local ButtonDialog = require("ui/widget/buttondialog")
local widget = ButtonDialog: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, with_main_stylesheet)
local snippets
if not show_all_ancestors then
local node_dataindex = ancestors[1][2]
snippets = document:getStylesheetsMatchingRulesets(node_dataindex, with_main_stylesheet)
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)
-- We use Unicode solid black blocks to make these really visible
table.insert(snippets, "/* \u{259B}" .. ("\u{2580}"):rep(20) .. " */")
table.insert(snippets, "/* \u{258C}" .. desc .. " */")
table.insert(snippets, "/* \u{2599}" .. ("\u{2584}"):rep(20) .. " */")
util.arrayAppend(snippets, document:getStylesheetsMatchingRulesets(node_dataindex, with_main_stylesheet))
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_type = "code",
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_type = "code",
para_direction_rtl = false,
auto_para_direction = false,
})
end,
}},
}
}
UIManager:show(cssviewer)
end
return ViewHtml