From d418660a2ccdcc2e540532d2c69c989b013a4554 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Fri, 21 Aug 2026 07:57:57 +0200 Subject: [PATCH] Lettore 0.3.0: bookmark per posizione (carattere) e paginazione corretta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bookmark cw:v2: 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 --- qml/pages/ReaderPage.qml | 219 ++++++++++++++++++++++++++++-------- rpm/harbour-calibreweb.spec | 20 +++- rpm/harbour-calibreweb.yaml | 2 +- src/epub.cpp | 163 +++++++++++++++++++++++++-- src/epub.h | 16 ++- 5 files changed, 361 insertions(+), 59 deletions(-) diff --git a/qml/pages/ReaderPage.qml b/qml/pages/ReaderPage.qml index 2bacc2b..07a2c51 100644 --- a/qml/pages/ReaderPage.qml +++ b/qml/pages/ReaderPage.qml @@ -12,9 +12,18 @@ Page { property int currentChapter: 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 restoring: false - property int saveCounter: 0 property string errorMessage: "" // reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico property double fontScale: 1.0 @@ -72,7 +81,8 @@ Page { anchors.horizontalCenter: parent.horizontalCenter width: parent.width - 2 * Theme.horizontalPageMargin text: (book.chapterCount > 0) - ? (page.currentChapter + 1) + "/" + book.chapterCount + ? page.pageIndex + "/" + page.pageCount + " · " + + (page.currentChapter + 1) + "/" + book.chapterCount + " · " + book.chapterTitle(page.currentChapter) + " · " + Math.round(page.overallProgress() * 100) + "%" : "…" @@ -126,13 +136,28 @@ Page { return loadWatchdog.stop() page.ready = true - // ripristina la posizione in frazione sull'altezza ricalcolata - if (page.chapterFraction > 0.01) { - web.runJavaScript( - "window.scrollTo(0," + page.chapterFraction - + "*Math.max(1,document.documentElement.scrollHeight-window.innerHeight));true", - function() { }) - } + // sblocca lo scroll se il CSS del libro lo blocca, poi ripristina + // la posizione per OFFSET DI CARATTERE (stabile con font/margini) + web.runJavaScript(page.unlockScrollJs(), function() { + if (page.restoreCharOffset >= 0) { + var off = page.restoreCharOffset + 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 } } @@ -210,28 +235,95 @@ Page { 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=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() { if (!web.loaded) return - web.runJavaScript( - "window.scrollY / Math.max(1, document.documentElement.scrollHeight - window.innerHeight)", - function(f) { - var v = Math.max(0, Math.min(1, f)) - if (Math.abs(v - page.chapterFraction) > 0.005) { - page.chapterFraction = v - maybeSave(true) - } - }) + web.runJavaScript(page.positionJs(), function(res) { + var r = JSON.parse(res) + page.charOffset = r.o + page.chapterFraction = r.f + page.pageIndex = r.p + page.pageCount = r.pc + maybeSave(false) + }) } function maybeSave(force) { if (bookId <= 0) return - page.saveCounter++ - // salva in locale ogni ~10 s (o subito su forzatura) - if (force || page.saveCounter % 5 === 0) { + // posizione assoluta nel libro = capitolo + caratteri del capitolo + var abs = book.chapterStartChar(page.currentChapter) + page.charOffset + // 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, - book.makeBookmarkKey(page.currentChapter, page.chapterFraction)) + book.makeBookmarkKey(page.currentChapter, page.charOffset)) } } @@ -241,6 +333,9 @@ Page { if (index !== page.currentChapter) { maybeSave(true) page.chapterFraction = 0 + page.charOffset = 0 + page.restoreCharOffset = -1 + page.lastSavedAbs = -1 } page.currentChapter = index loadChapter() @@ -248,41 +343,73 @@ Page { function changeFontScale(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() } - // 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() { if (!web.loaded || !page.ready) return web.runJavaScript( - "var vh=window.innerHeight;" + - "var cur=Math.floor(window.scrollY/vh);" + - "var max=Math.max(0,document.documentElement.scrollHeight-window.innerHeight);" + - "var target=(cur+1)*vh;" + - "if(target<=max){window.scrollTo(0,target);true}else{false}", - function(hasNext) { - if (hasNext) + "(function(){" + + "var de=document.documentElement,b=document.body;" + + "var max=Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight;" + + "if(max<=1){" + + "de.style.setProperty('overflow','visible','important');" + + "b.style.setProperty('overflow','visible','important');" + + "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() - else + } else if (r.atEnd) { 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() { if (!web.loaded || !page.ready) return web.runJavaScript( - "var vh=window.innerHeight;" + - "var cur=Math.floor(window.scrollY/vh);" + - "if(cur>0){window.scrollTo(0,(cur-1)*vh);true}else{false}", - function(hasPrev) { - if (hasPrev) + "(function(){" + + "var de=document.documentElement,b=document.body;" + + "var max=Math.max(de.scrollHeight,b.scrollHeight)-window.innerHeight;" + + "var vh=window.innerHeight;" + + "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() - else - gotoChapter(page.currentChapter - 1) + } else if (r.atStart && page.currentChapter > 0) { + 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() { - // 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 ch = 0 - var fr = 0.0 + var off = -1 if (key.length > 0) { var r = book.parseBookmarkKey(key) if (r && r.hasOwnProperty("chapter")) { 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.chapterFraction = fr + page.chapterFraction = 0 + page.charOffset = 0 + page.restoreCharOffset = off + page.lastSavedAbs = -1 loadChapter() } diff --git a/rpm/harbour-calibreweb.spec b/rpm/harbour-calibreweb.spec index ba22f81..8f9aaa7 100644 --- a/rpm/harbour-calibreweb.spec +++ b/rpm/harbour-calibreweb.spec @@ -1,6 +1,6 @@ Name: harbour-calibreweb Summary: Client OPDS per Calibre Web -Version: 0.2.0 +Version: 0.3.0 Release: 1 Group: Qt/Qt License: MIT @@ -24,6 +24,24 @@ reflow del testo, margini configurabili e posizione salvata in locale. %changelog +* Fri Aug 21 2026 Carlo Baratto - 0.3.0-1 +- Lettore: bookmark per POSIZIONE (numero di carattere, chiave cw:v2:) + 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 - 0.2.0-1 - Sandbox SailJail (Sailfish 4.4+/5.x): profilo X-Sailjail esplicito nel desktop file (Internet;Downloads, org harbour/app calibreweb) e config in diff --git a/rpm/harbour-calibreweb.yaml b/rpm/harbour-calibreweb.yaml index 8fd7b3f..b31872a 100644 --- a/rpm/harbour-calibreweb.yaml +++ b/rpm/harbour-calibreweb.yaml @@ -1,6 +1,6 @@ Name: harbour-calibreweb Summary: Client OPDS per Calibre Web -Version: 0.1.0 +Version: 0.3.0 Release: 1 Group: Qt/Qt License: MIT diff --git a/src/epub.cpp b/src/epub.cpp index 558b447..ebb2c5f 100644 --- a/src/epub.cpp +++ b/src/epub.cpp @@ -117,6 +117,82 @@ QByteArray extractEntryData(const QByteArray &data, const ZipEntry &e) 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 (come il walker JS che parte da + // document.body): l'header XML/DOCTYPE e il non contano + const int bodyStart = html.indexOf(QStringLiteral("= 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("'), 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 // --------------------------------------------------------------------------- @@ -140,6 +216,7 @@ void EpubBook::close() m_baseDir.clear(); } m_chapters.clear(); + m_textLengths.clear(); m_title.clear(); m_author.clear(); } @@ -185,6 +262,8 @@ bool EpubBook::open(const QString &epubPath) if (!parseOpf()) return false; const bool ok = !m_chapters.isEmpty(); + if (ok) + computeTextLengths(); emit changed(); 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. // I margini si possono aggiornare a caldo via runJavaScript (setProperty con // '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( "") @@ -441,24 +524,84 @@ QString EpubBook::chapterBaseUrl(int index) const .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(0, charOffset); + return QStringLiteral("cw:v2:%1").arg(abs); } QVariantMap EpubBook::parseBookmarkKey(const QString &key) const { QVariantMap result; const QStringList parts = key.split(QLatin1Char(':')); - if (parts.size() != 4 || parts.at(0) != QLatin1String("cw") - || parts.at(1) != QLatin1String("v1")) + if (parts.size() < 3 || parts.at(0) != QLatin1String("cw")) return result; - bool okChapter = false, okFrac = false; - const int ch = parts.at(2).toInt(&okChapter); - const double fr = parts.at(3).toDouble(&okFrac); - if (!okChapter || !okFrac) + + if (parts.at(1) == QLatin1String("v2")) { + bool ok = false; + 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(0, abs - chapterStartChar(ch))); return result; - result.insert(QStringLiteral("chapter"), ch); - result.insert(QStringLiteral("fraction"), fr); + } + + // v1 (vecchie chiavi, migrazione): "cw:v1::" + 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; } diff --git a/src/epub.h b/src/epub.h index 4520126..0565fd2 100644 --- a/src/epub.h +++ b/src/epub.h @@ -36,14 +36,21 @@ public: Q_INVOKABLE QString chapterTitle(int index) const; // elenco [{index, title}] per la lista capitoli 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) Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const; // base URL (file://) per risolvere immagini/CSS relativi del capitolo Q_INVOKABLE QString chapterBaseUrl(int index) const; - // chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo) - Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, double fraction) const; - // restituisce {chapter, fraction} oppure mappa vuota se la chiave non è valida + // chiave segno locale v2, es. "cw:v2:12345" (posizione = carattere assoluto + // nel libro, indipendente da font, margini e reflow) + Q_INVOKABLE QString makeBookmarkKey(int chapterIndex, qint64 charOffset) const; + // restituisce {chapter, charOffset} con charOffset NEL capitolo; accetta + // chiavi v2 ("cw:v2:") e v1 ("cw:v1::", migrazione) Q_INVOKABLE QVariantMap parseBookmarkKey(const QString &key) const; signals: @@ -56,12 +63,15 @@ private: void parseTocEpub3(const QString &navPath); void parseTocEpub2(const QString &ncxPath); QString absolutePath(const QString &relativeToOpfDir) const; + // lunghezze in caratteri del testo visibile, calcolate all'open + void computeTextLengths(); QString m_baseDir; QString m_opfDir; QString m_title; QString m_author; QList m_chapters; + QList m_textLengths; }; #endif // EPUB_H