Lettore 0.3.0: bookmark per posizione (carattere) e paginazione corretta
- Bookmark cw:v2:<offset assoluto caratteri> invece di capitolo+frazione; migrazione automatica delle chiavi v1 (parseBookmarkKey) - Lunghezze testo per capitolo in C++ (text node del DOM, entità decodificate, body-only) per mappare offset assoluto -> capitolo - Paginazione: stile iniettato height:auto/overflow:visible + unlock JS dei contenitori hidden — il CSS di molti EPUB bloccava lo scroll e ogni tap saltava al capitolo successivo - prevPage dall'inizio capitolo -> ultima pagina del capitolo precedente - Cambio font mantiene la posizione (restore per carattere) - Label menu: pagina corrente/totale del capitolo; max su documentElement+body - Test: harness C++ (Qt) e test JS con DOM mock
This commit is contained in:
+175
-44
@@ -12,9 +12,18 @@ Page {
|
|||||||
|
|
||||||
property int currentChapter: 0
|
property int currentChapter: 0
|
||||||
property double chapterFraction: 0.0
|
property double chapterFraction: 0.0
|
||||||
|
// posizione di lettura nel capitolo corrente, in caratteri di testo
|
||||||
|
// (conteggio JS sui text node: stabile al variare di font/margini)
|
||||||
|
property int charOffset: 0
|
||||||
|
// offset da ripristinare al load del capitolo (-1 = nessuno)
|
||||||
|
property int restoreCharOffset: -1
|
||||||
|
// ultimo offset assoluto salvato (evita scritture continue)
|
||||||
|
property int lastSavedAbs: -1
|
||||||
|
// pagina corrente / pagine totali del capitolo (per la label del menu)
|
||||||
|
property int pageIndex: 1
|
||||||
|
property int pageCount: 1
|
||||||
property bool ready: false
|
property bool ready: false
|
||||||
property bool restoring: false
|
property bool restoring: false
|
||||||
property int saveCounter: 0
|
|
||||||
property string errorMessage: ""
|
property string errorMessage: ""
|
||||||
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
|
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
|
||||||
property double fontScale: 1.0
|
property double fontScale: 1.0
|
||||||
@@ -72,7 +81,8 @@ Page {
|
|||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
width: parent.width - 2 * Theme.horizontalPageMargin
|
width: parent.width - 2 * Theme.horizontalPageMargin
|
||||||
text: (book.chapterCount > 0)
|
text: (book.chapterCount > 0)
|
||||||
? (page.currentChapter + 1) + "/" + book.chapterCount
|
? page.pageIndex + "/" + page.pageCount + " · "
|
||||||
|
+ (page.currentChapter + 1) + "/" + book.chapterCount
|
||||||
+ " · " + book.chapterTitle(page.currentChapter)
|
+ " · " + book.chapterTitle(page.currentChapter)
|
||||||
+ " · " + Math.round(page.overallProgress() * 100) + "%"
|
+ " · " + Math.round(page.overallProgress() * 100) + "%"
|
||||||
: "…"
|
: "…"
|
||||||
@@ -126,13 +136,28 @@ Page {
|
|||||||
return
|
return
|
||||||
loadWatchdog.stop()
|
loadWatchdog.stop()
|
||||||
page.ready = true
|
page.ready = true
|
||||||
// ripristina la posizione in frazione sull'altezza ricalcolata
|
// sblocca lo scroll se il CSS del libro lo blocca, poi ripristina
|
||||||
if (page.chapterFraction > 0.01) {
|
// la posizione per OFFSET DI CARATTERE (stabile con font/margini)
|
||||||
web.runJavaScript(
|
web.runJavaScript(page.unlockScrollJs(), function() {
|
||||||
"window.scrollTo(0," + page.chapterFraction
|
if (page.restoreCharOffset >= 0) {
|
||||||
+ "*Math.max(1,document.documentElement.scrollHeight-window.innerHeight));true",
|
var off = page.restoreCharOffset
|
||||||
function() { })
|
page.restoreCharOffset = -1
|
||||||
}
|
if (off >= book.chapterTextLength(page.currentChapter)) {
|
||||||
|
// oltre la fine = vai all'ultima pagina (usato da
|
||||||
|
// prevPage per non saltare la coda del capitolo)
|
||||||
|
web.runJavaScript(
|
||||||
|
"window.scrollTo(0,Math.max(1,"
|
||||||
|
+ "document.documentElement.scrollHeight-window.innerHeight));true",
|
||||||
|
function() { readScrollPosition() })
|
||||||
|
} else {
|
||||||
|
web.runJavaScript(page.scrollToCharJs(off), function() {
|
||||||
|
readScrollPosition()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
readScrollPosition()
|
||||||
|
}
|
||||||
|
})
|
||||||
page.restoring = false
|
page.restoring = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,28 +235,95 @@ Page {
|
|||||||
return (page.currentChapter + page.chapterFraction) / book.chapterCount
|
return (page.currentChapter + page.chapterFraction) / book.chapterCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JS che sblocca lo scroll del documento: rimuove overflow hidden / altezze
|
||||||
|
// fisse che i CSS di alcuni EPUB impongono (sintomo: max==0, ogni tap salta
|
||||||
|
// al capitolo successivo saltando tutto il testo)
|
||||||
|
function unlockScrollJs() {
|
||||||
|
return "(function(){"
|
||||||
|
+ "var de=document.documentElement,b=document.body;"
|
||||||
|
+ "if(!de||!b)return false;"
|
||||||
|
+ "de.style.setProperty('overflow','visible','important');"
|
||||||
|
+ "b.style.setProperty('overflow','visible','important');"
|
||||||
|
+ "de.style.setProperty('height','auto','important');"
|
||||||
|
+ "b.style.setProperty('height','auto','important');"
|
||||||
|
+ "var after=Math.max(de.scrollHeight,b.scrollHeight);"
|
||||||
|
+ "if(after<=window.innerHeight+1&&b.textContent.length>0){"
|
||||||
|
+ "var all=b.querySelectorAll('div,section,article,main,td,li');"
|
||||||
|
+ "for(var i=0;i<all.length;i++){var cs=getComputedStyle(all[i]);"
|
||||||
|
+ "if(cs.overflowY==='hidden'||cs.overflowY==='scroll'){"
|
||||||
|
+ "all[i].style.setProperty('overflow','visible','important');}}}"
|
||||||
|
+ "return true;})()"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS che legge la posizione: {o: caratteri di testo sopra la viewport,
|
||||||
|
// f: frazione di scroll, p: pagina corrente, pc: pagine totali}.
|
||||||
|
// Il conteggio cammina i text node del DOM: stabile al variare di font e
|
||||||
|
// margini (a differenza della frazione di scroll).
|
||||||
|
function positionJs() {
|
||||||
|
return "(function(){"
|
||||||
|
+ "var de=document.documentElement,b=document.body;"
|
||||||
|
+ "var max=Math.max(1,Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight);"
|
||||||
|
+ "var y=Math.max(0,Math.min(max,window.scrollY));"
|
||||||
|
+ "var vh=window.innerHeight;"
|
||||||
|
+ "var o=0;"
|
||||||
|
+ "var it=document.createTreeWalker(b,NodeFilter.SHOW_TEXT);"
|
||||||
|
+ "var n;"
|
||||||
|
+ "while((n=it.nextNode())){"
|
||||||
|
+ "var p=n.parentNode;"
|
||||||
|
+ "if(!p||p.tagName==='SCRIPT'||p.tagName==='STYLE'||p.tagName==='NOSCRIPT')continue;"
|
||||||
|
+ "var r=document.createRange();r.selectNodeContents(n);"
|
||||||
|
+ "var rect=r.getBoundingClientRect();"
|
||||||
|
+ "if(rect.bottom<=0){o+=n.data.length;continue;}"
|
||||||
|
+ "if(rect.top>=0)break;"
|
||||||
|
+ "var h=rect.bottom-rect.top;"
|
||||||
|
+ "if(h>0)o+=Math.round(n.data.length*(0-rect.top)/h);"
|
||||||
|
+ "break;}"
|
||||||
|
+ "return JSON.stringify({o:o,f:y/max,p:Math.floor(y/vh)+1,"
|
||||||
|
+ "pc:Math.max(1,Math.floor(max/vh)+1)});})()"
|
||||||
|
}
|
||||||
|
|
||||||
|
// JS che porta in cima alla viewport il carattere 'off' del capitolo
|
||||||
|
function scrollToCharJs(off) {
|
||||||
|
return "(function(){"
|
||||||
|
+ "var b=document.body,o=0;"
|
||||||
|
+ "var it=document.createTreeWalker(b,NodeFilter.SHOW_TEXT);"
|
||||||
|
+ "var n;"
|
||||||
|
+ "while((n=it.nextNode())){"
|
||||||
|
+ "var p=n.parentNode;"
|
||||||
|
+ "if(!p||p.tagName==='SCRIPT'||p.tagName==='STYLE'||p.tagName==='NOSCRIPT')continue;"
|
||||||
|
+ "if(o+n.data.length>=" + off + "){"
|
||||||
|
+ "var r=document.createRange();r.setStart(n," + off + "-o);r.collapse(true);"
|
||||||
|
+ "var rect=r.getBoundingClientRect();"
|
||||||
|
+ "if(rect.top>0)window.scrollTo(0,rect.top+window.scrollY-8);"
|
||||||
|
+ "return true;}"
|
||||||
|
+ "o+=n.data.length;}"
|
||||||
|
+ "return false;})()"
|
||||||
|
}
|
||||||
|
|
||||||
function readScrollPosition() {
|
function readScrollPosition() {
|
||||||
if (!web.loaded)
|
if (!web.loaded)
|
||||||
return
|
return
|
||||||
web.runJavaScript(
|
web.runJavaScript(page.positionJs(), function(res) {
|
||||||
"window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)",
|
var r = JSON.parse(res)
|
||||||
function(f) {
|
page.charOffset = r.o
|
||||||
var v = Math.max(0, Math.min(1, f))
|
page.chapterFraction = r.f
|
||||||
if (Math.abs(v - page.chapterFraction) > 0.005) {
|
page.pageIndex = r.p
|
||||||
page.chapterFraction = v
|
page.pageCount = r.pc
|
||||||
maybeSave(true)
|
maybeSave(false)
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeSave(force) {
|
function maybeSave(force) {
|
||||||
if (bookId <= 0)
|
if (bookId <= 0)
|
||||||
return
|
return
|
||||||
page.saveCounter++
|
// posizione assoluta nel libro = capitolo + caratteri del capitolo
|
||||||
// salva in locale ogni ~10 s (o subito su forzatura)
|
var abs = book.chapterStartChar(page.currentChapter) + page.charOffset
|
||||||
if (force || page.saveCounter % 5 === 0) {
|
// salva subito su forzatura, altrimenti solo se la posizione è cambiata
|
||||||
|
// di almeno 20 caratteri (evita scritture continue a tracker attivo)
|
||||||
|
if (force || Math.abs(abs - page.lastSavedAbs) > 20) {
|
||||||
|
page.lastSavedAbs = abs
|
||||||
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
||||||
book.makeBookmarkKey(page.currentChapter, page.chapterFraction))
|
book.makeBookmarkKey(page.currentChapter, page.charOffset))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +333,9 @@ Page {
|
|||||||
if (index !== page.currentChapter) {
|
if (index !== page.currentChapter) {
|
||||||
maybeSave(true)
|
maybeSave(true)
|
||||||
page.chapterFraction = 0
|
page.chapterFraction = 0
|
||||||
|
page.charOffset = 0
|
||||||
|
page.restoreCharOffset = -1
|
||||||
|
page.lastSavedAbs = -1
|
||||||
}
|
}
|
||||||
page.currentChapter = index
|
page.currentChapter = index
|
||||||
loadChapter()
|
loadChapter()
|
||||||
@@ -248,41 +343,73 @@ Page {
|
|||||||
|
|
||||||
function changeFontScale(delta) {
|
function changeFontScale(delta) {
|
||||||
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
|
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
|
||||||
// ricarica il capitolo con la nuova dimensione: il reflow avviene al load
|
// ricarica con la nuova dimensione, mantenendo la posizione di lettura:
|
||||||
|
// l'offset in CARATTERI è stabile col reflow (a differenza dei px scrollati)
|
||||||
|
page.restoreCharOffset = page.charOffset
|
||||||
loadChapter()
|
loadChapter()
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagina successiva: se l'ultima pagina del capitolo, passa al capitolo dopo
|
// pagina successiva: una viewport avanti; all'ultima pagina passa al capitolo
|
||||||
|
// dopo. max è calcolato su documentElement E body (alcuni libri scrollano il body).
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (!web.loaded || !page.ready)
|
if (!web.loaded || !page.ready)
|
||||||
return
|
return
|
||||||
web.runJavaScript(
|
web.runJavaScript(
|
||||||
"var vh=window.innerHeight;" +
|
"(function(){"
|
||||||
"var cur=Math.floor(window.scrollY/vh);" +
|
+ "var de=document.documentElement,b=document.body;"
|
||||||
"var max=Math.max(0,document.documentElement.scrollHeight-window.innerHeight);" +
|
+ "var max=Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight;"
|
||||||
"var target=(cur+1)*vh;" +
|
+ "if(max<=1){"
|
||||||
"if(target<=max){window.scrollTo(0,target);true}else{false}",
|
+ "de.style.setProperty('overflow','visible','important');"
|
||||||
function(hasNext) {
|
+ "b.style.setProperty('overflow','visible','important');"
|
||||||
if (hasNext)
|
+ "de.style.setProperty('height','auto','important');"
|
||||||
|
+ "b.style.setProperty('height','auto','important');"
|
||||||
|
+ "max=Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight;}"
|
||||||
|
+ "if(max<=1)return JSON.stringify({ok:false,atEnd:true});"
|
||||||
|
+ "var vh=window.innerHeight;"
|
||||||
|
+ "var cur=Math.max(0,Math.min(max,window.scrollY));"
|
||||||
|
+ "var target=Math.min(max,cur+vh);"
|
||||||
|
+ "if(target>cur+1){window.scrollTo(0,target);"
|
||||||
|
+ "return JSON.stringify({ok:true,atEnd:target>=max-1});}"
|
||||||
|
+ "return JSON.stringify({ok:false,atEnd:true});})()",
|
||||||
|
function(res) {
|
||||||
|
var r = JSON.parse(res)
|
||||||
|
if (r.ok) {
|
||||||
readScrollPosition()
|
readScrollPosition()
|
||||||
else
|
} else if (r.atEnd) {
|
||||||
gotoChapter(page.currentChapter + 1)
|
gotoChapter(page.currentChapter + 1)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagina precedente: se la prima del capitolo, torna al capitolo prima
|
// pagina precedente: una viewport indietro; dalla prima pagina torna al
|
||||||
|
// capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
if (!web.loaded || !page.ready)
|
if (!web.loaded || !page.ready)
|
||||||
return
|
return
|
||||||
web.runJavaScript(
|
web.runJavaScript(
|
||||||
"var vh=window.innerHeight;" +
|
"(function(){"
|
||||||
"var cur=Math.floor(window.scrollY/vh);" +
|
+ "var de=document.documentElement,b=document.body;"
|
||||||
"if(cur>0){window.scrollTo(0,(cur-1)*vh);true}else{false}",
|
+ "var max=Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight;"
|
||||||
function(hasPrev) {
|
+ "var vh=window.innerHeight;"
|
||||||
if (hasPrev)
|
+ "var cur=Math.max(0,Math.min(max,window.scrollY));"
|
||||||
|
+ "if(cur>1){window.scrollTo(0,Math.max(0,cur-vh));"
|
||||||
|
+ "return JSON.stringify({ok:true,atStart:false});}"
|
||||||
|
+ "return JSON.stringify({ok:false,atStart:true});})()",
|
||||||
|
function(res) {
|
||||||
|
var r = JSON.parse(res)
|
||||||
|
if (r.ok) {
|
||||||
readScrollPosition()
|
readScrollPosition()
|
||||||
else
|
} else if (r.atStart && page.currentChapter > 0) {
|
||||||
gotoChapter(page.currentChapter - 1)
|
maybeSave(true)
|
||||||
|
page.currentChapter -= 1
|
||||||
|
page.chapterFraction = 0
|
||||||
|
page.charOffset = 0
|
||||||
|
// sentinella "fine capitolo": va oltre la lunghezza, così
|
||||||
|
// onLoadedChanged porta all'ultima pagina
|
||||||
|
page.restoreCharOffset = book.chapterTextLength(page.currentChapter) + 1
|
||||||
|
page.lastSavedAbs = -1
|
||||||
|
loadChapter()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,19 +480,23 @@ Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openChapter() {
|
function openChapter() {
|
||||||
// ripristina il segno locale
|
// ripristina il segno locale: posizione per CARATTERE (v2),
|
||||||
|
// le vecchie chiavi v1 vengono migrate da parseBookmarkKey
|
||||||
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
|
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
|
||||||
var ch = 0
|
var ch = 0
|
||||||
var fr = 0.0
|
var off = -1
|
||||||
if (key.length > 0) {
|
if (key.length > 0) {
|
||||||
var r = book.parseBookmarkKey(key)
|
var r = book.parseBookmarkKey(key)
|
||||||
if (r && r.hasOwnProperty("chapter")) {
|
if (r && r.hasOwnProperty("chapter")) {
|
||||||
ch = Math.max(0, Math.min(r.chapter, book.chapterCount - 1))
|
ch = Math.max(0, Math.min(r.chapter, book.chapterCount - 1))
|
||||||
fr = r.fraction
|
off = r.hasOwnProperty("charOffset") ? r.charOffset : -1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
page.currentChapter = ch
|
page.currentChapter = ch
|
||||||
page.chapterFraction = fr
|
page.chapterFraction = 0
|
||||||
|
page.charOffset = 0
|
||||||
|
page.restoreCharOffset = off
|
||||||
|
page.lastSavedAbs = -1
|
||||||
loadChapter()
|
loadChapter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Name: harbour-calibreweb
|
Name: harbour-calibreweb
|
||||||
Summary: Client OPDS per Calibre Web
|
Summary: Client OPDS per Calibre Web
|
||||||
Version: 0.2.0
|
Version: 0.3.0
|
||||||
Release: 1
|
Release: 1
|
||||||
Group: Qt/Qt
|
Group: Qt/Qt
|
||||||
License: MIT
|
License: MIT
|
||||||
@@ -24,6 +24,24 @@ reflow del testo, margini configurabili e posizione salvata
|
|||||||
in locale.
|
in locale.
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Fri Aug 21 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.3.0-1
|
||||||
|
- Lettore: bookmark per POSIZIONE (numero di carattere, chiave cw:v2:<assoluto>)
|
||||||
|
invece di capitolo+frazione: la posizione è stabile cambiando font e margini;
|
||||||
|
le vecchie chiavi cw:v1 vengono migrate automaticamente al primo avvio
|
||||||
|
- Lettore: paginazione a tap corretta quando il CSS del libro blocca lo scroll
|
||||||
|
(height:100%/overflow:hidden): stile iniettato con height:auto+overflow:visible
|
||||||
|
e sblocco JS dei contenitori nascosti — prima ogni tap saltava al capitolo
|
||||||
|
successivo perdendo tutto il testo del capitolo
|
||||||
|
- Lettore: prevPage dall'inizio del capitolo torna all'ULTIMA pagina del capitolo
|
||||||
|
precedente (prima ripartiva dall'inizio saltando la coda del testo)
|
||||||
|
- Lettore: cambio font A-/A+ mantiene la posizione di lettura (ripristino per
|
||||||
|
carattere dopo il reflow)
|
||||||
|
- Lettore: label del menu con pagina corrente/totale del capitolo; avanzamento
|
||||||
|
calcolato su documentElement E body (libri che scrollano il body)
|
||||||
|
- Parser EPUB: lunghezze testo per capitolo (text node del DOM) per la mappatura
|
||||||
|
offset assoluto -> capitolo
|
||||||
|
- Test: harness C++ locale (Qt) + test JS con DOM mock per conteggio caratteri,
|
||||||
|
ripristino e paginazione
|
||||||
* Wed Aug 19 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.0-1
|
* Wed Aug 19 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.2.0-1
|
||||||
- Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop
|
- Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop
|
||||||
file (Internet;Downloads, org harbour/app calibreweb) e config in
|
file (Internet;Downloads, org harbour/app calibreweb) e config in
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Name: harbour-calibreweb
|
Name: harbour-calibreweb
|
||||||
Summary: Client OPDS per Calibre Web
|
Summary: Client OPDS per Calibre Web
|
||||||
Version: 0.1.0
|
Version: 0.3.0
|
||||||
Release: 1
|
Release: 1
|
||||||
Group: Qt/Qt
|
Group: Qt/Qt
|
||||||
License: MIT
|
License: MIT
|
||||||
|
|||||||
+153
-10
@@ -117,6 +117,82 @@ QByteArray extractEntryData(const QByteArray &data, const ZipEntry &e)
|
|||||||
return QByteArray(); // metodo non supportato
|
return QByteArray(); // metodo non supportato
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lunghezza del testo VISIBILE di un capitolo (criterio DOM/JS):
|
||||||
|
// tag rimossi, entità decodificate (1 carattere), head/script/style/noscript/
|
||||||
|
// title esclusi, spazi e newline contati come nel text node del browser.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
int plainTextLength(const QString &html)
|
||||||
|
{
|
||||||
|
// conta solo il testo dentro <body> (come il walker JS che parte da
|
||||||
|
// document.body): l'header XML/DOCTYPE e il <head> non contano
|
||||||
|
const int bodyStart = html.indexOf(QStringLiteral("<body"), 0, Qt::CaseInsensitive);
|
||||||
|
const int bodyEnd = html.indexOf(QStringLiteral("</body"), 0, Qt::CaseInsensitive);
|
||||||
|
const QString scope = (bodyStart >= 0)
|
||||||
|
? html.mid(bodyStart, (bodyEnd >= bodyStart ? bodyEnd : html.size()) - bodyStart)
|
||||||
|
: html;
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
int i = 0;
|
||||||
|
const int n = scope.size();
|
||||||
|
while (i < n) {
|
||||||
|
const QChar c = scope.at(i);
|
||||||
|
if (c == QLatin1Char('<')) {
|
||||||
|
const int close = scope.indexOf(QLatin1Char('>'), i + 1);
|
||||||
|
if (close < 0) {
|
||||||
|
++i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
QString tag = scope.mid(i + 1, close - i - 1).trimmed().toLower();
|
||||||
|
if (tag.startsWith(QLatin1String("!--"))) {
|
||||||
|
const int end = scope.indexOf(QLatin1String("-->"), close);
|
||||||
|
i = (end < 0) ? n : end + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tag.startsWith(QLatin1Char('/')))
|
||||||
|
tag = tag.mid(1);
|
||||||
|
int sp = 0;
|
||||||
|
while (sp < tag.size() && tag.at(sp).isLetterOrNumber())
|
||||||
|
++sp;
|
||||||
|
const QString name = tag.left(sp);
|
||||||
|
if (name == QLatin1String("script") || name == QLatin1String("style")
|
||||||
|
|| name == QLatin1String("noscript") || name == QLatin1String("head")
|
||||||
|
|| name == QLatin1String("title")) {
|
||||||
|
// salta tutto il contenuto (e la chiusura) del blocco raw
|
||||||
|
const QString closeTag = QStringLiteral("</") + name;
|
||||||
|
const int end = scope.indexOf(closeTag, close, Qt::CaseInsensitive);
|
||||||
|
if (end < 0) {
|
||||||
|
i = n;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const int gt = scope.indexOf(QLatin1Char('>'), end);
|
||||||
|
i = (gt < 0) ? n : gt + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
i = close + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == QLatin1Char('&')) {
|
||||||
|
const int semi = scope.indexOf(QLatin1Char(';'), i + 1);
|
||||||
|
if (semi > i && semi - i <= 10) {
|
||||||
|
const QString ent = scope.mid(i + 1, semi - i - 1);
|
||||||
|
if (ent == QLatin1String("amp") || ent == QLatin1String("lt")
|
||||||
|
|| ent == QLatin1String("gt") || ent == QLatin1String("quot")
|
||||||
|
|| ent == QLatin1String("apos") || ent == QLatin1String("nbsp")
|
||||||
|
|| ent.startsWith(QLatin1String("#"))) {
|
||||||
|
++count;
|
||||||
|
i = semi + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
++count;
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -140,6 +216,7 @@ void EpubBook::close()
|
|||||||
m_baseDir.clear();
|
m_baseDir.clear();
|
||||||
}
|
}
|
||||||
m_chapters.clear();
|
m_chapters.clear();
|
||||||
|
m_textLengths.clear();
|
||||||
m_title.clear();
|
m_title.clear();
|
||||||
m_author.clear();
|
m_author.clear();
|
||||||
}
|
}
|
||||||
@@ -185,6 +262,8 @@ bool EpubBook::open(const QString &epubPath)
|
|||||||
if (!parseOpf())
|
if (!parseOpf())
|
||||||
return false;
|
return false;
|
||||||
const bool ok = !m_chapters.isEmpty();
|
const bool ok = !m_chapters.isEmpty();
|
||||||
|
if (ok)
|
||||||
|
computeTextLengths();
|
||||||
emit changed();
|
emit changed();
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
@@ -418,8 +497,12 @@ QString EpubBook::chapterHtml(int index, int fontSizePx, int marginPx, int margi
|
|||||||
// stile di reflow iniettato nel documento: !important vince sui CSS del libro.
|
// stile di reflow iniettato nel documento: !important vince sui CSS del libro.
|
||||||
// I margini si possono aggiornare a caldo via runJavaScript (setProperty con
|
// I margini si possono aggiornare a caldo via runJavaScript (setProperty con
|
||||||
// 'important' inline), senza ricaricare il capitolo.
|
// 'important' inline), senza ricaricare il capitolo.
|
||||||
|
// height:auto + overflow:visible forzano lo scroll anche quando il CSS del
|
||||||
|
// libro imposta height:100%/overflow:hidden (senza, il documento non scorre
|
||||||
|
// e ogni tap salta al capitolo successivo).
|
||||||
const QString style = QStringLiteral(
|
const QString style = QStringLiteral(
|
||||||
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
|
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
|
||||||
|
"height:auto!important;overflow:visible!important;"
|
||||||
"margin-left:%2px!important;margin-right:%2px!important;"
|
"margin-left:%2px!important;margin-right:%2px!important;"
|
||||||
"margin-top:%3px!important;margin-bottom:%3px!important}"
|
"margin-top:%3px!important;margin-bottom:%3px!important}"
|
||||||
"p,div,li,td,blockquote,section{font-size:1em!important}</style>")
|
"p,div,li,td,blockquote,section{font-size:1em!important}</style>")
|
||||||
@@ -441,24 +524,84 @@ QString EpubBook::chapterBaseUrl(int index) const
|
|||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
QString EpubBook::makeBookmarkKey(int chapterIndex, double fraction) const
|
void EpubBook::computeTextLengths()
|
||||||
{
|
{
|
||||||
return QStringLiteral("cw:v1:%1:%2").arg(chapterIndex).arg(fraction, 0, 'f', 4);
|
m_textLengths.clear();
|
||||||
|
for (const EpubChapter &c : m_chapters) {
|
||||||
|
QFile f(c.path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) {
|
||||||
|
m_textLengths.append(0);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString html = QString::fromUtf8(f.readAll());
|
||||||
|
m_textLengths.append(plainTextLength(html));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int EpubBook::chapterTextLength(int index) const
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= m_textLengths.size())
|
||||||
|
return 0;
|
||||||
|
return m_textLengths.at(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 EpubBook::chapterStartChar(int index) const
|
||||||
|
{
|
||||||
|
qint64 start = 0;
|
||||||
|
const int end = qMin(index, m_textLengths.size());
|
||||||
|
for (int i = 0; i < end; ++i)
|
||||||
|
start += m_textLengths.at(i);
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EpubBook::makeBookmarkKey(int chapterIndex, qint64 charOffset) const
|
||||||
|
{
|
||||||
|
const qint64 abs = chapterStartChar(chapterIndex) + qMax<qint64>(0, charOffset);
|
||||||
|
return QStringLiteral("cw:v2:%1").arg(abs);
|
||||||
}
|
}
|
||||||
|
|
||||||
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
|
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
|
||||||
{
|
{
|
||||||
QVariantMap result;
|
QVariantMap result;
|
||||||
const QStringList parts = key.split(QLatin1Char(':'));
|
const QStringList parts = key.split(QLatin1Char(':'));
|
||||||
if (parts.size() != 4 || parts.at(0) != QLatin1String("cw")
|
if (parts.size() < 3 || parts.at(0) != QLatin1String("cw"))
|
||||||
|| parts.at(1) != QLatin1String("v1"))
|
|
||||||
return result;
|
return result;
|
||||||
bool okChapter = false, okFrac = false;
|
|
||||||
const int ch = parts.at(2).toInt(&okChapter);
|
if (parts.at(1) == QLatin1String("v2")) {
|
||||||
const double fr = parts.at(3).toDouble(&okFrac);
|
bool ok = false;
|
||||||
if (!okChapter || !okFrac)
|
const qint64 abs = parts.at(2).toLongLong(&ok);
|
||||||
|
if (!ok || abs < 0)
|
||||||
|
return result;
|
||||||
|
// trova il capitolo che contiene il carattere assoluto
|
||||||
|
int ch = 0;
|
||||||
|
qint64 start = 0;
|
||||||
|
for (int i = 0; i < m_textLengths.size(); ++i) {
|
||||||
|
if (start + m_textLengths.at(i) > abs) {
|
||||||
|
ch = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
start += m_textLengths.at(i);
|
||||||
|
ch = i + 1;
|
||||||
|
}
|
||||||
|
if (ch >= m_chapters.size())
|
||||||
|
ch = qMax(0, m_chapters.size() - 1);
|
||||||
|
result.insert(QStringLiteral("chapter"), ch);
|
||||||
|
result.insert(QStringLiteral("charOffset"),
|
||||||
|
qMax<qint64>(0, abs - chapterStartChar(ch)));
|
||||||
return result;
|
return result;
|
||||||
result.insert(QStringLiteral("chapter"), ch);
|
}
|
||||||
result.insert(QStringLiteral("fraction"), fr);
|
|
||||||
|
// v1 (vecchie chiavi, migrazione): "cw:v1:<cap>:<frac>"
|
||||||
|
if (parts.at(1) == QLatin1String("v1") && parts.size() == 4) {
|
||||||
|
bool okC = false, okF = false;
|
||||||
|
const int ch = parts.at(2).toInt(&okC);
|
||||||
|
const double fr = parts.at(3).toDouble(&okF);
|
||||||
|
if (!okC || !okF)
|
||||||
|
return result;
|
||||||
|
result.insert(QStringLiteral("chapter"), qMax(0, ch));
|
||||||
|
result.insert(QStringLiteral("charOffset"),
|
||||||
|
qRound64(fr * chapterTextLength(ch)));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-3
@@ -36,14 +36,21 @@ public:
|
|||||||
Q_INVOKABLE QString chapterTitle(int index) const;
|
Q_INVOKABLE QString chapterTitle(int index) const;
|
||||||
// elenco [{index, title}] per la lista capitoli
|
// elenco [{index, title}] per la lista capitoli
|
||||||
Q_INVOKABLE QVariantList chapterList() const;
|
Q_INVOKABLE QVariantList chapterList() const;
|
||||||
|
// lunghezza in caratteri del testo VISIBILE del capitolo (entità decodificate,
|
||||||
|
// head/script/style esclusi) — stesso criterio del conteggio JS nel lettore
|
||||||
|
Q_INVOKABLE int chapterTextLength(int index) const;
|
||||||
|
// caratteri di testo complessivi prima dell'inizio del capitolo index
|
||||||
|
Q_INVOKABLE qint64 chapterStartChar(int index) const;
|
||||||
// HTML del capitolo con lo stile di reflow iniettato (font-size e margini in px)
|
// HTML del capitolo con lo stile di reflow iniettato (font-size e margini in px)
|
||||||
Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const;
|
Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const;
|
||||||
// base URL (file://) per risolvere immagini/CSS relativi del capitolo
|
// base URL (file://) per risolvere immagini/CSS relativi del capitolo
|
||||||
Q_INVOKABLE QString chapterBaseUrl(int index) const;
|
Q_INVOKABLE QString chapterBaseUrl(int index) const;
|
||||||
|
|
||||||
// chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo)
|
// chiave segno locale v2, es. "cw:v2:12345" (posizione = carattere assoluto
|
||||||
Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, double fraction) const;
|
// nel libro, indipendente da font, margini e reflow)
|
||||||
// restituisce {chapter, fraction} oppure mappa vuota se la chiave non è valida
|
Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, qint64 charOffset) const;
|
||||||
|
// restituisce {chapter, charOffset} con charOffset NEL capitolo; accetta
|
||||||
|
// chiavi v2 ("cw:v2:<assoluto>") e v1 ("cw:v1:<cap>:<frac>", migrazione)
|
||||||
Q_INVOKABLE QVariantMap parseBookmarkKey(const QString &key) const;
|
Q_INVOKABLE QVariantMap parseBookmarkKey(const QString &key) const;
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
@@ -56,12 +63,15 @@ private:
|
|||||||
void parseTocEpub3(const QString &navPath);
|
void parseTocEpub3(const QString &navPath);
|
||||||
void parseTocEpub2(const QString &ncxPath);
|
void parseTocEpub2(const QString &ncxPath);
|
||||||
QString absolutePath(const QString &relativeToOpfDir) const;
|
QString absolutePath(const QString &relativeToOpfDir) const;
|
||||||
|
// lunghezze in caratteri del testo visibile, calcolate all'open
|
||||||
|
void computeTextLengths();
|
||||||
|
|
||||||
QString m_baseDir;
|
QString m_baseDir;
|
||||||
QString m_opfDir;
|
QString m_opfDir;
|
||||||
QString m_title;
|
QString m_title;
|
||||||
QString m_author;
|
QString m_author;
|
||||||
QList<EpubChapter> m_chapters;
|
QList<EpubChapter> m_chapters;
|
||||||
|
QList<int> m_textLengths;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // EPUB_H
|
#endif // EPUB_H
|
||||||
|
|||||||
Reference in New Issue
Block a user