mirror of
https://github.com/koreader/koreader.git
synced 2025-12-13 20:36:53 +01:00
546 lines
18 KiB
Lua
546 lines
18 KiB
Lua
--[[--
|
|
HTML widget (without scroll bars).
|
|
--]]
|
|
|
|
local Device = require("device")
|
|
local DrawContext = require("ffi/drawcontext")
|
|
local Geom = require("ui/geometry")
|
|
local GestureRange = require("ui/gesturerange")
|
|
local InputContainer = require("ui/widget/container/inputcontainer")
|
|
local Mupdf = require("ffi/mupdf")
|
|
local Screen = Device.screen
|
|
local UIManager = require("ui/uimanager")
|
|
local logger = require("logger")
|
|
local time = require("ui/time")
|
|
local util = require("util")
|
|
|
|
-- -1: right to left, 0: mixed, +1: left to right
|
|
local function getLineTextDirection(line)
|
|
local word_count = #line
|
|
if word_count <= 1 then
|
|
return 1
|
|
end
|
|
|
|
local ltr = true
|
|
local rtl = true
|
|
for i = 2, word_count do
|
|
if line[i].x0 > line[i - 1].x0 then
|
|
rtl = false
|
|
elseif line[i].x0 < line[i - 1].x0 then
|
|
ltr = false
|
|
end
|
|
end
|
|
if ltr and not rtl then
|
|
return 1
|
|
elseif rtl and not ltr then
|
|
return -1
|
|
else
|
|
return 0
|
|
end
|
|
end
|
|
|
|
local function getWordIndices(lines, pos)
|
|
local last_checked_line_index = nil
|
|
for line_index, line in ipairs(lines) do
|
|
if pos.y >= line.y0 then -- check if pos in on or below the line
|
|
if pos.y < line.y1 then -- check if pos is within the line vertically
|
|
local rtl_line = getLineTextDirection(line) < 0
|
|
if pos.x >= line.x0 and pos.x < line.x1 then -- check if pos is within the line horizontally
|
|
if #line >= 1 then -- if line is not empty then check for exact word hit
|
|
local word_start_index = 1
|
|
local word_end_index = #line
|
|
local step = 1
|
|
if rtl_line then
|
|
word_start_index, word_end_index = word_end_index, word_start_index
|
|
step = -1
|
|
end
|
|
|
|
local word_x0 = line[word_start_index].x0
|
|
for word_index = word_start_index, word_end_index, step do
|
|
local word = line[word_index]
|
|
if pos.x >= word_x0 and pos.x < word.x1 then
|
|
return line_index, word_index
|
|
end
|
|
|
|
-- join the word rectangles horizontally to avoid hit gaps
|
|
word_x0 = word.x1
|
|
end
|
|
end
|
|
elseif pos.x < line.x0 then -- check if pos is before the current line horizontally
|
|
if rtl_line then
|
|
return line_index, #line
|
|
else
|
|
return line_index, 1
|
|
end
|
|
elseif pos.x >= line.x1 then -- check if pos after the current line horizontally
|
|
if rtl_line then
|
|
-- To match TextBoxWidget's selection behavior this should be "line_index, 1"
|
|
-- but then the selection will jump between the full row and the visually
|
|
-- last word when hitting a vertical gap. If we extend the line vertically
|
|
-- till the next one then selection will be weird around new paragraphs.
|
|
-- The solution might require getPageText() to add empty lines.
|
|
return line_index, #line
|
|
else
|
|
return line_index, #line
|
|
end
|
|
end
|
|
end
|
|
|
|
last_checked_line_index = line_index
|
|
end
|
|
end
|
|
|
|
if last_checked_line_index == nil then
|
|
return 1, 1
|
|
else
|
|
return last_checked_line_index, #lines[last_checked_line_index]
|
|
end
|
|
end
|
|
|
|
local function getSelectedText(lines, start_pos, end_pos)
|
|
local start_line_index, start_word_index = getWordIndices(lines, start_pos)
|
|
local end_line_index, end_word_index = getWordIndices(lines, end_pos)
|
|
if start_line_index == nil or end_line_index == nil then
|
|
return nil, nil
|
|
elseif start_line_index > end_line_index then
|
|
start_line_index, end_line_index = end_line_index, start_line_index
|
|
start_word_index, end_word_index = end_word_index, start_word_index
|
|
elseif start_line_index == end_line_index and start_word_index > end_word_index then
|
|
start_word_index, end_word_index = end_word_index, start_word_index
|
|
end
|
|
|
|
local found_start = false
|
|
local words = {}
|
|
local rects = {}
|
|
for line_index = start_line_index, end_line_index do
|
|
local line = lines[line_index]
|
|
local line_last_rect = nil
|
|
local line_text_direction = getLineTextDirection(line)
|
|
for word_index, word in ipairs(line) do
|
|
if type(word) == 'table' then
|
|
if line_index == start_line_index and word_index == start_word_index then
|
|
found_start = true
|
|
end
|
|
if found_start then
|
|
table.insert(words, word.word)
|
|
|
|
-- do not try to join word rects in mixed direction lines
|
|
if line_last_rect == nil or line_text_direction == 0 then
|
|
local rect = Geom:new{
|
|
x = word.x0,
|
|
y = line.y0,
|
|
w = word.x1 - word.x0,
|
|
h = line.y1 - line.y0,
|
|
}
|
|
table.insert(rects, rect)
|
|
line_last_rect = rect
|
|
else
|
|
if line_text_direction > 0 then -- left to right
|
|
line_last_rect.w = word.x1 - line_last_rect.x
|
|
else -- right to left
|
|
line_last_rect.w = line_last_rect.w + (line_last_rect.x - word.x0)
|
|
line_last_rect.x = word.x0
|
|
end
|
|
end
|
|
|
|
if line_index == end_line_index and word_index == end_word_index then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if found_start then
|
|
return table.concat(words, " "), rects
|
|
else
|
|
return nil, nil
|
|
end
|
|
end
|
|
|
|
local function areTextBoxesEqual(boxes1, text1, boxes2, text2)
|
|
if text1 ~= text2 then
|
|
return false
|
|
end
|
|
if boxes1 and boxes2 then
|
|
if #boxes1 ~= #boxes2 then
|
|
return false
|
|
end
|
|
for i = 1, #boxes1, 1 do
|
|
if boxes1[i] ~= boxes2[i] then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
else
|
|
return (boxes1 == nil) == (boxes2 == nil)
|
|
end
|
|
end
|
|
|
|
local HtmlBoxWidget = InputContainer:extend{
|
|
bb = nil,
|
|
dimen = nil,
|
|
dialog = nil, -- parent dialog that will be set dirty
|
|
document = nil,
|
|
page_count = 0,
|
|
page_number = 1,
|
|
page_boxes = nil,
|
|
hold_start_pos = nil,
|
|
hold_end_pos = nil,
|
|
hold_start_time = nil,
|
|
html_link_tapped_callback = nil,
|
|
|
|
highlight_text_selection = false, -- if true then the selected text will be highlighted
|
|
highlight_rects = nil,
|
|
highlight_text = nil,
|
|
highlight_clear_and_redraw_action = nil,
|
|
}
|
|
|
|
function HtmlBoxWidget:init()
|
|
if Device:isTouchDevice() then
|
|
self.ges_events.TapText = {
|
|
GestureRange:new{
|
|
ges = "tap",
|
|
range = function() return self.dimen end,
|
|
},
|
|
}
|
|
end
|
|
self.highlight_lighten_factor = G_reader_settings:readSetting("highlight_lighten_factor", 0.2)
|
|
end
|
|
|
|
-- These are generic "fixes" to MuPDF HTML stylesheet:
|
|
-- - MuPDF doesn't set some elements as being display:block, and would
|
|
-- consider them inline, and would badly handle <BR/> inside them.
|
|
-- Note: this is a generic issue with <BR/> inside inline elements, see:
|
|
-- https://github.com/koreader/koreader/issues/12258#issuecomment-2267629234
|
|
local mupdf_css_fixes = [[
|
|
article, aside, button, canvas, datalist, details, dialog, dir, fieldset, figcaption,
|
|
figure, footer, form, frame, frameset, header, hgroup, iframe, legend, listing,
|
|
main, map, marquee, multicol, nav, noembed, noframes, noscript, optgroup, output,
|
|
plaintext, search, select, summary, template, textarea, video, xmp {
|
|
display: block;
|
|
}
|
|
]]
|
|
|
|
function HtmlBoxWidget:setContent(body, css, default_font_size, is_xhtml, no_css_fixes, html_resource_directory)
|
|
-- fz_set_user_css is tied to the context instead of the document so to easily support multiple
|
|
-- HTML dictionaries with different CSS, we embed the stylesheet into the HTML instead of using
|
|
-- that function.
|
|
local head = ""
|
|
if css or not no_css_fixes then
|
|
head = string.format("<head><style>\n%s\n%s</style></head>", mupdf_css_fixes, css or "")
|
|
end
|
|
local html = string.format("<html>%s<body>%s</body></html>", head, body)
|
|
|
|
-- For some reason in MuPDF <br/> always creates both a line break and an empty line, so we have to
|
|
-- simulate the normal <br/> behavior.
|
|
-- https://bugs.ghostscript.com/show_bug.cgi?id=698351
|
|
html = html:gsub("%<br ?/?%>", " <div></div>")
|
|
|
|
-- We can provide some "magic"/"mimetype" to Mupdf.openDocumentFromText():
|
|
-- - "html" will get MuPDF to use its bundled gumbo-parser to parse HTML5 according to the specs.
|
|
-- - "xhtml" will get MuPDF to use its own XML parser, and if it fails, to switch to gumbo-parser.
|
|
-- When we know the body is balanced XHTML, it's safer to use "xhtml" to avoid the HTML5
|
|
-- rules to trigger (ie. <title><p>123</p></title>, which is valid in FB2 snippets, parsed
|
|
-- as title>p, while gumbo-parse would consider "<p>123</p>" as being plain text).
|
|
local ok
|
|
ok, self.document = pcall(Mupdf.openDocumentFromText, html, is_xhtml and "xhtml" or "html", html_resource_directory)
|
|
if not ok then
|
|
-- self.document contains the error
|
|
logger.warn("HTML loading error:", self.document)
|
|
|
|
body = util.htmlToPlainText(body)
|
|
body = util.htmlEscape(body)
|
|
-- Normally \n would be replaced with <br/>. See the previous comment regarding the bug in MuPDF.
|
|
body = body:gsub("\n", " <div></div>")
|
|
html = string.format("<html>%s<body>%s</body></html>", head, body)
|
|
|
|
ok, self.document = pcall(Mupdf.openDocumentFromText, html, "html", html_resource_directory)
|
|
if not ok then
|
|
error(self.document)
|
|
end
|
|
end
|
|
|
|
self.document:layoutDocument(self.dimen.w, self.dimen.h, default_font_size)
|
|
|
|
self.page_count = self.document:getPages()
|
|
self.page_boxes = nil
|
|
self:clearHighlight()
|
|
end
|
|
|
|
function HtmlBoxWidget:_render()
|
|
if self.bb then
|
|
return
|
|
end
|
|
local page = self.document:openPage(self.page_number)
|
|
self.document:setColorRendering(Screen:isColorEnabled())
|
|
local dc = DrawContext.new()
|
|
self.bb = page:draw_new(dc, self.dimen.w, self.dimen.h, 0, 0)
|
|
page:close()
|
|
|
|
if self.highlight_text_selection and self.highlight_rects then
|
|
for _, rect in ipairs(self.highlight_rects) do
|
|
self.bb:darkenRect(rect.x, rect.y, rect.w, rect.h, self.highlight_lighten_factor)
|
|
end
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:getSize()
|
|
return self.dimen
|
|
end
|
|
|
|
function HtmlBoxWidget:getSinglePageHeight()
|
|
if self.page_count == 1 then
|
|
local page = self.document:openPage(1)
|
|
local x0, y0, x1, y1 = page:getUsedBBox() -- luacheck: no unused
|
|
page:close()
|
|
return math.ceil(y1) -- no content after y1
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:paintTo(bb, x, y)
|
|
self.dimen.x = x
|
|
self.dimen.y = y
|
|
|
|
self:_render()
|
|
|
|
local size = self:getSize()
|
|
|
|
bb:blitFrom(self.bb, x, y, 0, 0, size.w, size.h)
|
|
end
|
|
|
|
function HtmlBoxWidget:freeBb()
|
|
if self.bb and self.bb.free then
|
|
self.bb:free()
|
|
end
|
|
|
|
self.bb = nil
|
|
end
|
|
|
|
-- This will normally be called by our WidgetContainer:free()
|
|
-- But it SHOULD explicitly be called if we are getting replaced
|
|
-- (ie: in some other widget's update()), to not leak memory with
|
|
-- BlitBuffer zombies
|
|
function HtmlBoxWidget:free()
|
|
--print("HtmlBoxWidget:free on", self)
|
|
self:freeBb()
|
|
|
|
if self.document then
|
|
self.document:close()
|
|
self.document = nil
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:onCloseWidget()
|
|
-- free when UIManager:close() was called
|
|
self:free()
|
|
end
|
|
|
|
function HtmlBoxWidget:getPosFromAbsPos(abs_pos)
|
|
local pos = Geom:new{
|
|
x = abs_pos.x - self.dimen.x,
|
|
y = abs_pos.y - self.dimen.y,
|
|
}
|
|
|
|
-- check if the coordinates are actually inside our area
|
|
if pos.x < 0 or pos.x >= self.dimen.w or pos.y < 0 or pos.y >= self.dimen.h then
|
|
return nil
|
|
end
|
|
|
|
return pos
|
|
end
|
|
|
|
function HtmlBoxWidget:onHoldStartText(_, ges)
|
|
self:unscheduleClearHighlightAndRedraw()
|
|
self.hold_start_pos = self:getPosFromAbsPos(ges.pos)
|
|
self.hold_end_pos = self.hold_start_pos
|
|
self.highlight_rects = nil
|
|
self.highlight_text = nil
|
|
|
|
if not self.hold_start_pos then
|
|
return false -- let event be processed by other widgets
|
|
end
|
|
|
|
self.hold_start_time = UIManager:getTime()
|
|
|
|
if self:updateHighlight() then
|
|
self:redrawHighlight()
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function HtmlBoxWidget:onHoldPanText(_, ges)
|
|
-- We don't highlight the currently selected text, but just let this
|
|
-- event pop up if we are not currently selecting text
|
|
if not self.hold_start_pos then
|
|
return false
|
|
end
|
|
|
|
self.hold_end_pos = Geom:new{
|
|
x = ges.pos.x - self.dimen.x,
|
|
y = ges.pos.y - self.dimen.y,
|
|
}
|
|
|
|
if self:updateHighlight() then
|
|
self.hold_start_time = UIManager:getTime()
|
|
self:redrawHighlight()
|
|
end
|
|
|
|
return true
|
|
end
|
|
|
|
function HtmlBoxWidget:onHoldReleaseText(callback, ges)
|
|
if not callback then
|
|
return false
|
|
end
|
|
|
|
-- check we have seen a HoldStart event
|
|
if not self.hold_start_pos then
|
|
return false
|
|
end
|
|
|
|
self.hold_end_pos = Geom:new{
|
|
x = ges.pos.x - self.dimen.x,
|
|
y = ges.pos.y - self.dimen.y,
|
|
}
|
|
|
|
if self:updateHighlight() then
|
|
self:redrawHighlight()
|
|
end
|
|
|
|
if not self.highlight_text then
|
|
return false
|
|
end
|
|
|
|
local hold_duration = time.now() - self.hold_start_time
|
|
callback(self.highlight_text, hold_duration)
|
|
|
|
return true
|
|
end
|
|
|
|
function HtmlBoxWidget:getLinkByPosition(pos)
|
|
local page = self.document:openPage(self.page_number)
|
|
local links = page:getPageLinks()
|
|
page:close()
|
|
|
|
for _, link in ipairs(links) do
|
|
if pos.x >= link.x0 and pos.x < link.x1 and pos.y >= link.y0 and pos.y < link.y1 then
|
|
return link
|
|
end
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:onTapText(arg, ges)
|
|
if G_reader_settings:isFalse("tap_to_follow_links") then
|
|
return
|
|
end
|
|
|
|
if self.html_link_tapped_callback then
|
|
local pos = self:getPosFromAbsPos(ges.pos)
|
|
if pos then
|
|
local link = self:getLinkByPosition(pos)
|
|
if link then
|
|
self.html_link_tapped_callback(link)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:setPageNumber(page_number)
|
|
if page_number == self.page_number then
|
|
return
|
|
end
|
|
self.page_number = page_number
|
|
self.page_boxes = nil
|
|
self:clearHighlight()
|
|
end
|
|
|
|
-- Returns true if the highlight has changed.
|
|
function HtmlBoxWidget:clearHighlight()
|
|
self.hold_start_pos = nil
|
|
self.hold_end_pos = nil
|
|
return self:updateHighlight()
|
|
end
|
|
|
|
-- Returns true if the highlight has changed.
|
|
function HtmlBoxWidget:updateHighlight()
|
|
if self.hold_start_pos and self.hold_end_pos then
|
|
-- getPageText is slow so we only call it when needed, and keep the result.
|
|
if self.page_boxes == nil then
|
|
local page = self.document:openPage(self.page_number)
|
|
self.page_boxes = page:getPageText()
|
|
|
|
-- In same cases MuPDF returns a visually single line of text as multiple lines.
|
|
-- Merge such lines to ensure that getSelectedText works properly.
|
|
local line_index = 2
|
|
while line_index <= #self.page_boxes do
|
|
local prev_line = self.page_boxes[line_index - 1]
|
|
local line = self.page_boxes[line_index]
|
|
if line.y0 == prev_line.y0 and line.y1 == prev_line.y1 then
|
|
if line.x0 < prev_line.x0 then
|
|
prev_line.x0 = line.x0
|
|
end
|
|
if line.x1 > prev_line.x1 then
|
|
prev_line.x1 = line.x1
|
|
end
|
|
for _, word in ipairs(line) do
|
|
table.insert(prev_line, word)
|
|
end
|
|
table.remove(self.page_boxes, line_index)
|
|
else
|
|
line_index = line_index + 1
|
|
end
|
|
end
|
|
|
|
page:close()
|
|
end
|
|
|
|
local text, rects = getSelectedText(self.page_boxes, self.hold_start_pos, self.hold_end_pos)
|
|
local changed = not areTextBoxesEqual(self.highlight_rects, self.highlight_text, rects, text)
|
|
if changed then
|
|
self.highlight_rects = rects
|
|
self.highlight_text = text
|
|
end
|
|
return changed
|
|
else
|
|
local changed = self.highlight_rects ~= nil
|
|
self.highlight_rects = nil
|
|
self.highlight_text = nil
|
|
return changed
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:redrawHighlight()
|
|
if self.highlight_text_selection then
|
|
self:freeBb()
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
return "ui", self.dimen
|
|
end)
|
|
end
|
|
end
|
|
|
|
function HtmlBoxWidget:scheduleClearHighlightAndRedraw()
|
|
if self.highlight_clear_and_redraw_action then
|
|
return
|
|
end
|
|
|
|
self.highlight_clear_and_redraw_action = function ()
|
|
self.highlight_clear_and_redraw_action = nil
|
|
if self:clearHighlight() then
|
|
self:redrawHighlight()
|
|
end
|
|
end
|
|
UIManager:scheduleIn(G_defaults:readSetting("DELAY_CLEAR_HIGHLIGHT_S"), self.highlight_clear_and_redraw_action)
|
|
end
|
|
|
|
function HtmlBoxWidget:unscheduleClearHighlightAndRedraw()
|
|
if self.highlight_clear_and_redraw_action then
|
|
UIManager:unschedule(self.highlight_clear_and_redraw_action)
|
|
self.highlight_clear_and_redraw_action = nil
|
|
end
|
|
end
|
|
|
|
return HtmlBoxWidget
|