fix(sync): capture block context across inline splits (drop-caps)

getTextFromXPointer returns a single text node, so a position on
<p><span>C</span>onvergence of Heaven and Earth</p> sent context 'C'.
The server text search matched the first 'C' in the chapter and stored
doc-start, reopening the web reader at the chapter beginning.

When the cleaned node text is tiny (<15 runes), walk up to 3 levels to
enclosing element pointers and return the first block text >=15 runes
(keeping the longest seen as fallback). Kindle-light: at most 3 extra
local reads, only on the tiny-text path, zero extra network. Char-offset
slicing applies to the original node text only, not parent blocks.
This commit is contained in:
2026-09-08 22:32:55 -04:00
parent 3d1c0fba4e
commit 9425edb6e9
+68 -10
View File
@@ -764,23 +764,81 @@ function Bookhoard:getContextText()
local xp = self:getLastProgress() local xp = self:getLastProgress()
if not xp then return "" end if not xp then return "" end
local text = self.ui.document:getTextFromXPointer(xp) local function clean(s)
if not text or text == "" then if not s or s == "" then return "" end
text = self.ui.document:getTextFromXPointer(self.ui.document:getNormalizedXPointer(xp)) if #s > 100 then
s = s:sub(1, 100)
end
return s:gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
end
local function tryPointer(p)
if not p or p == "" then return "" end
local ok, text = pcall(function()
return self.ui.document:getTextFromXPointer(p)
end)
if not ok then return "" end
return text or ""
end
local text = tryPointer(xp)
if text == "" then
local ok, nxp = pcall(function()
return self.ui.document:getNormalizedXPointer(xp)
end)
if ok and nxp and nxp ~= "" and nxp ~= xp then
text = tryPointer(nxp)
end
end
if text == "" then return "" end
local cleaned = clean(text)
-- Single text nodes split by inline markup (e.g. drop-caps like
-- <p><span>C</span>onvergence…</p>) return just "C" here. That one
-- character then false-positives the server text search to the first
-- "C" in the chapter (doc start). Walk up to the enclosing block
-- element(s) so we capture words, not the node. Kindle-light: at most
-- 3 extra local reads, only on this tiny-text path, zero extra network.
local function runeLen(s)
-- Cheap UTF-8 aware length without dependencies.
local _, n = s:gsub("[^\128-\191]", "")
return n
end
if cleaned ~= "" and runeLen(cleaned) < 15 then
local parent = xp
for _ = 1, 3 do
-- Strip one trailing path segment: "/text().N" or "/span[1]" etc.
local stripped = parent:gsub("/[^/]+$", "")
if not stripped or stripped == "" or stripped == parent then
break
end
parent = stripped
-- Don't walk past the document body; element pointers above the
-- block would return whole-chapter text.
if parent:match("/body%s*$") or parent:match("DocFragment%[%d+%]$") then
break
end
local ptext = tryPointer(parent)
if ptext and ptext ~= "" then
local pcleaned = clean(ptext)
if pcleaned ~= "" and runeLen(pcleaned) >= 15 then
return pcleaned
end
-- Keep the longest thing seen in case no level reaches 15.
if runeLen(pcleaned) > runeLen(cleaned) then
cleaned = pcleaned
end
end
end
return cleaned
end end
if not text or text == "" then return "" end
local char_offset = tonumber(xp:match("text%(%)%.?(%d+)")) or 0 local char_offset = tonumber(xp:match("text%(%)%.?(%d+)")) or 0
if char_offset > 0 and char_offset < #text then if char_offset > 0 and char_offset < #text then
text = text:sub(char_offset + 1) text = text:sub(char_offset + 1)
end end
if #text > 100 then return clean(text)
text = text:sub(1, 100)
end
text = text:gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
return text
end end
function Bookhoard:collectBookData() function Bookhoard:collectBookData()