From 60f53ab4e4b77b9a8b32d5f5bb4bd630181a5620 Mon Sep 17 00:00:00 2001
From: Carlo Baratto
Date: Fri, 21 Aug 2026 11:32:43 +0200
Subject: [PATCH] Lettore 0.4.0: rifatto senza WebView (Text rich text QML)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Il WebView di Sailfish non supporta lo scroll programmatico affidabile
(window.scrollY stale, contentHeight/scrollPosition non scrivibili): 0.3.x
non ha mai cambiato capitolo in modo affidabile. Ora i capitoli sono rich
text Qt in un Text QML dentro un SilicaFlickable: contentY/contentHeight
nativi, paginazione a tap deterministico, fine capitolo esatta. HTML->rich
text in C++ (tag base, entità, fallback). Bookmark v2 invariato (offset
caratteri stimato da frazione). Niente immagini inline (limite rich text).
---
qml/pages/ReaderPage.qml | 431 +++++++++++++-----------------------
rpm/harbour-calibreweb.spec | 17 +-
src/epub.cpp | 239 ++++++++++++++++++++
src/epub.h | 3 +
src/main.cpp | 4 +-
5 files changed, 414 insertions(+), 280 deletions(-)
diff --git a/qml/pages/ReaderPage.qml b/qml/pages/ReaderPage.qml
index fca4b98..0b8bf85 100644
--- a/qml/pages/ReaderPage.qml
+++ b/qml/pages/ReaderPage.qml
@@ -1,8 +1,13 @@
import QtQuick 2.0
import Sailfish.Silica 1.0
-import Sailfish.WebView 1.0
import harbour.calibreweb 1.0
+// Lettore senza WebView: il capitolo è renderizzato come rich text Qt in un
+// Text QML dentro un SilicaFlickable. Lo scroll (contentY/contentHeight) è
+// nativo QML e sempre affidabile — sul WebView di Sailfish lo scroll
+// programmatico e la lettura della posizione non funzionano (window.scrollY
+// stale, contentHeight/scrollPosition non scrivibili): con il WebView il
+// cambio capitolo non scattava mai.
Page {
id: page
@@ -12,13 +17,10 @@ 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)
+ // posizione di lettura nel capitolo corrente, stimata in caratteri di testo
+ // dalla frazione di scroll (stabile col reflow del font)
property int charOffset: 0
- // caratteri di testo per pagina (stimati dalla viewport corrente): usato
- // per il passo di pagina e per la label "pagina corrente/totale"
- property int charsPerPage: 1
- // offset da ripristinare al load del capitolo (-1 = nessuno)
+ // offset da ripristinare al cambio capitolo (-1 = nessuno)
property int restoreCharOffset: -1
// ultimo offset assoluto salvato (evita scritture continue)
property int lastSavedAbs: -1
@@ -26,10 +28,15 @@ Page {
property int pageIndex: 1
property int pageCount: 1
property bool ready: false
- property bool restoring: false
property string errorMessage: ""
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
property double fontScale: 1.0
+ // restore in sospeso: frazione da applicare quando il testo si ri-impagina
+ property bool pendingRestore: false
+ property double restoreFrac: 0.0
+ // anteprima margini dal dialog (valori temporanei, -1 = usa le impostazioni)
+ property int previewMarginL: -1
+ property int previewMarginT: -1
allowedOrientations: Orientation.All
@@ -38,8 +45,9 @@ Page {
}
SilicaFlickable {
+ id: readerFlick
anchors.fill: parent
- contentHeight: parent.height
+ contentHeight: readerContent.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
@@ -56,8 +64,9 @@ Page {
previewHandler: page.previewMargins
})
dlg.accepted.connect(function() {
- // i valori sono già persistiti da onAccepted; rinfresca subito
- applyMarginsJS()
+ // i valori sono già persistiti da onAccepted; esci dal preview
+ page.previewMarginL = -1
+ page.previewMarginT = -1
})
}
}
@@ -130,49 +139,43 @@ Page {
}
}
- WebView {
- id: web
- anchors.fill: parent
- active: true
- onLoadedChanged: {
- if (!loaded)
- return
- loadWatchdog.stop()
- page.ready = true
- // 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):
- // scrollTo oltre il limite porta in fondo al documento
- web.runJavaScript(
- "window.scrollTo(0,9999999);true",
- function() { readScrollPosition() })
- } else {
- web.runJavaScript(page.scrollToCharJs(off), function() {
- readScrollPosition()
- })
- }
- } else {
- readScrollPosition()
- }
- })
- page.restoring = false
+ // contenuto del capitolo: rich text Qt in un Text QML. Il cambio di
+ // currentChapter aggiorna il binding; il reflow (font/margini) è nativo.
+ Item {
+ id: readerContent
+ width: parent.width
+ height: Math.max(parent.height,
+ textItem.height + 2 * (page.previewMarginT >= 0
+ ? page.previewMarginT
+ : appSettings.readerMarginV))
+
+ Text {
+ id: textItem
+ x: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin
+ y: page.previewMarginT >= 0 ? page.previewMarginT : appSettings.readerMarginV
+ width: parent.width - 2 * (page.previewMarginL >= 0
+ ? page.previewMarginL
+ : appSettings.readerMargin)
+ text: book.chapterRichText(page.currentChapter)
+ textFormat: Text.RichText
+ wrapMode: Text.WrapAtWordBoundaryOrAnywhere
+ font.pixelSize: page.fontPx()
+ color: Theme.primaryColor
+ onContentHeightChanged: page.onTextReflowed()
}
}
// Paginazione a tap: niente scroll libero. La meta' destra va avanti di
// una pagina, la sinistra indietro. Le fasce superiore e inferiore sono
// lasciate ai menu (PullDown in alto, PushUp in basso).
+ // preventStealing: i drag nella zona centrale NON scrollano (paginazione
+ // a tap come richiesto), i pulley menu restano raggiungibili dai bordi.
MouseArea {
id: tapZone
anchors.fill: parent
anchors.topMargin: Theme.itemSizeLarge
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
+ preventStealing: true
onClicked: {
if (mouse.x > width / 2)
page.nextPage()
@@ -182,29 +185,11 @@ Page {
}
}
- BusyIndicator {
+ Label {
anchors.centerIn: parent
- running: !page.ready
- size: BusyIndicatorSize.Large
- }
-
- // watchdog: se il capitolo non si carica, niente spinner infinito
- Timer {
- id: loadWatchdog
- interval: 15000
- onTriggered: {
- if (!web.loaded && !page.ready) {
- page.errorMessage = qsTr("The reader is not responding (WebView). The EPUB file may be damaged.")
- page.ready = true
- }
- }
- }
-
- // ritardo di apertura: evita la race tra onCompleted e l'init del WebView
- Timer {
- id: openTimer
- interval: 100
- onTriggered: openChapter()
+ text: page.errorMessage
+ color: Theme.errorColor
+ visible: page.errorMessage.length > 0
}
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
@@ -214,19 +199,23 @@ Page {
onTriggered: pageStack.pop(null)
}
- Label {
- anchors.centerIn: parent
- text: page.errorMessage
- color: Theme.errorColor
- visible: page.errorMessage.length > 0
+ // rete di sicurezza per il restore: se il reflow non ha ancora cambiato
+ // contentHeight (capitoli con altezza identica), applica comunque
+ Timer {
+ id: restoreTimer
+ interval: 80
+ onTriggered: {
+ if (page.pendingRestore) {
+ page.pendingRestore = false
+ applyRestore()
+ }
+ }
}
- // ---- controlli spostati nel PushUpMenu (trascina in alto) ----
-
- // ---- timer di tracciamento ----
+ // tracciamento periodico (aggiorna label e bookmark anche senza tap)
Timer {
id: trackTimer
- interval: 2000
+ interval: 3000
running: page.ready
repeat: true
onTriggered: readScrollPosition()
@@ -235,110 +224,51 @@ Page {
function overallProgress() {
if (book.chapterCount <= 0)
return 0
- return (page.currentChapter + page.chapterFraction) / book.chapterCount
+ var total = book.chapterStartChar(book.chapterCount)
+ if (total <= 0)
+ return 0
+ var start = book.chapterStartChar(page.currentChapter)
+ return (start + page.charOffset) / total
}
- // 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= len = fondo)
+ function offsetToFrac(off, chapterIndex) {
+ var len = book.chapterTextLength(chapterIndex)
+ if (len <= 0)
+ return 0
+ if (off >= len)
+ return 1.0
+ return Math.max(0, Math.min(1, off / len))
}
- // JS che conta i caratteri di testo sopra la viewport (per il bookmark).
- // Lo scroll NON è gestito qui: pagina/frazione si leggono dalle proprietà
- // native del WebView (contentHeight/scrollPosition), sempre affidabili.
- function positionJs() {
- return "(function(){"
- + "var b=document.body;"
- + "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 o;})()"
+ function applyRestore() {
+ var max = Math.max(0, readerFlick.contentHeight - readerFlick.height)
+ readerFlick.contentY = page.restoreFrac * max
+ readScrollPosition()
}
- // JS che conta i caratteri di testo NELLA viewport corrente (per il passo
- // di pagina): stessa tecnica di positionJs, ma sulla fascia [0, innerHeight]
- function charsInViewportJs() {
- return "(function(){"
- + "var b=document.body;"
- + "var vh=window.innerHeight;"
- + "var c=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||rect.top>=vh)continue;"
- + "var top=Math.max(0,rect.top),bot=Math.min(vh,rect.bottom);"
- + "var h=rect.bottom-rect.top;"
- + "if(h>0)c+=Math.round(n.data.length*(bot-top)/h);"
- + "}"
- + "return c;})()"
- }
-
- // JS che porta in cima alla viewport il carattere 'off' del capitolo.
- // Usa scrollBy RELATIVO (rect.top - 8): non legge mai window.scrollY,
- // che sul WebView Sailfish non riflette lo scroll reale (con scrollTo +
- // scrollY il tap oscillava di pochi pixel invece di avanzare).
- 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();"
- + "window.scrollBy(0,rect.top-8);"
- + "return true;}"
- + "o+=n.data.length;}"
- + "return false;})()"
+ // il testo si è ri-impaginato (cambio capitolo, font, margini):
+ // applica la frazione di restore in sospeso
+ function onTextReflowed() {
+ if (page.pendingRestore) {
+ page.pendingRestore = false
+ applyRestore()
+ }
}
function readScrollPosition() {
- if (!web.loaded)
+ if (!page.ready)
return
- web.runJavaScript(page.positionJs(), function(o) {
- page.charOffset = o || 0
- // avanzamento per CARATTERI: stabile e indipendente dallo scroll reale
- var total = book.chapterStartChar(book.chapterCount)
- var start = book.chapterStartChar(page.currentChapter)
- if (total > 0)
- page.chapterFraction = (start + page.charOffset) / total
- var len = book.chapterTextLength(page.currentChapter)
- var pp = Math.max(1, page.charsPerPage)
- page.pageIndex = Math.floor(page.charOffset / pp) + 1
- page.pageCount = Math.max(1, Math.ceil(len / pp))
- maybeSave(false)
- })
+ var vh = Math.max(1, readerFlick.height)
+ var max = Math.max(0, readerFlick.contentHeight - vh)
+ var y = Math.max(0, Math.min(max, readerFlick.contentY))
+ var len = book.chapterTextLength(page.currentChapter)
+ page.chapterFraction = max > 0 ? y / max : 0
+ // posizione per caratteri (stima dalla frazione: stabile col reflow)
+ page.charOffset = Math.round(page.chapterFraction * len)
+ page.pageIndex = Math.floor(y / vh) + 1
+ page.pageCount = Math.max(1, Math.ceil(max / vh) + 1)
+ maybeSave(false)
}
function maybeSave(force) {
@@ -362,98 +292,73 @@ Page {
maybeSave(true)
page.chapterFraction = 0
page.charOffset = 0
- page.restoreCharOffset = -1
page.lastSavedAbs = -1
}
page.currentChapter = index
- loadChapter()
+ // il binding del Text si aggiorna da solo; restore all'inizio del capitolo
+ page.pendingRestore = true
+ page.restoreFrac = page.restoreCharOffset >= 0
+ ? offsetToFrac(page.restoreCharOffset, page.currentChapter)
+ : 0.0
+ page.restoreCharOffset = -1
+ restoreTimer.start()
}
function changeFontScale(delta) {
page.fontScale = Math.max(0.7, Math.min(2.5, page.fontScale + delta))
- // 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()
+ // il binding aggiorna il font; ripristina la posizione alla frazione
+ // corrente (l'offset in caratteri è stabile col reflow)
+ page.pendingRestore = true
+ page.restoreFrac = page.chapterFraction
+ restoreTimer.start()
}
- // pagina successiva: avanza di una "pagina di testo" (i caratteri visibili
- // nella viewport corrente). La fine del capitolo è determinata dal CONFRONTO
- // CON LA LUNGHEZZA IN CARATTERI (nota in C++), non dallo scroll reale del
- // WebView — quindi il passaggio al capitolo successivo è sempre affidabile.
+ // pagina successiva: una viewport avanti (contentY nativo, affidabile);
+ // a fine capitolo (o capitolo non scrollabile) passa al successivo
function nextPage() {
- if (!web.loaded || !page.ready)
+ if (!page.ready)
return
- web.runJavaScript(page.positionJs(), function(o) {
- page.charOffset = o || 0
- web.runJavaScript(page.charsInViewportJs(), function(c) {
- page.charsPerPage = Math.max(1, Math.round(c || 1))
- var target = page.charOffset + page.charsPerPage
- if (target >= book.chapterTextLength(page.currentChapter)) {
- // fine capitolo: segna la coda come letta e passa avanti
- page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
- maybeSave(true)
- gotoChapter(page.currentChapter + 1)
- } else {
- web.runJavaScript(page.scrollToCharJs(target), function() {
- readScrollPosition()
- })
- }
- })
- })
+ var vh = readerFlick.height
+ var max = Math.max(0, readerFlick.contentHeight - vh)
+ var y = readerFlick.contentY
+ if (y >= max - 2) {
+ // fine capitolo: segna la coda come letta e passa avanti
+ page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
+ maybeSave(true)
+ gotoChapter(page.currentChapter + 1)
+ } else {
+ readerFlick.contentY = Math.min(max, y + vh)
+ readScrollPosition()
+ }
}
- // pagina precedente: torna indietro di una pagina di testo; dall'inizio del
- // capitolo va al capitolo prima SULL'ULTIMA PAGINA (non salta la coda)
+ // pagina precedente: una viewport indietro; dall'inizio del capitolo va al
+ // capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo)
function prevPage() {
- if (!web.loaded || !page.ready)
+ if (!page.ready)
return
- web.runJavaScript(page.positionJs(), function(o) {
- page.charOffset = o || 0
- web.runJavaScript(page.charsInViewportJs(), function(c) {
- page.charsPerPage = Math.max(1, Math.round(c || 1))
- var target = page.charOffset - page.charsPerPage
- if (target <= 0) {
- if (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()
- }
- } else {
- web.runJavaScript(page.scrollToCharJs(target), function() {
- readScrollPosition()
- })
- }
- })
- })
+ var y = readerFlick.contentY
+ if (y > 1) {
+ readerFlick.contentY = Math.max(0, y - readerFlick.height)
+ readScrollPosition()
+ } else if (page.currentChapter > 0) {
+ maybeSave(true)
+ page.currentChapter -= 1
+ page.chapterFraction = 0
+ page.charOffset = 0
+ page.lastSavedAbs = -1
+ // sentinella "fine capitolo": va oltre la lunghezza, così
+ // offsetToFrac porta all'ultima pagina
+ page.pendingRestore = true
+ page.restoreFrac = 1.0
+ restoreTimer.start()
+ }
}
function fontPx() {
return Math.round(16 * page.fontScale * 100) / 100
}
- // carica il capitolo corrente via loadHtml: lo stile di reflow (font-size
- // e margini) è iniettato nell'HTML dal parser, quindi niente JS post-load
- function loadChapter() {
- var html = book.chapterHtml(page.currentChapter, page.fontPx(),
- appSettings.readerMargin, appSettings.readerMarginV)
- if (html.length === 0) {
- page.errorMessage = qsTr("Chapter unreadable")
- page.ready = true
- return
- }
- page.ready = false
- page.restoring = true
- loadWatchdog.restart()
- web.loadHtml(html, book.chapterBaseUrl(page.currentChapter))
- }
-
function showChapterList() {
var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"),
{ model: book.chapterList() })
@@ -462,32 +367,14 @@ Page {
})
}
- // JS che sovrascrive i margini sul documento aperto: stile inline con
- // 'important' batte lo stylesheet iniettato. Effetto immediato, niente reload.
- function marginJs(lr, tb) {
- return "var h=document.documentElement,b=document.body;"
- + "h.style.setProperty('margin-left','" + lr + "px','important');"
- + "h.style.setProperty('margin-right','" + lr + "px','important');"
- + "b.style.setProperty('margin-left','" + lr + "px','important');"
- + "b.style.setProperty('margin-right','" + lr + "px','important');"
- + "b.style.setProperty('margin-top','" + tb + "px','important');"
- + "b.style.setProperty('margin-bottom','" + tb + "px','important');"
- + "true"
- }
-
- // riapplica i margini salvati nelle impostazioni (senza ricaricare il libro)
- function applyMarginsJS() {
- if (!web.loaded || !page.ready)
- return
- web.runJavaScript(marginJs(appSettings.readerMargin, appSettings.readerMarginV),
- function() { })
- }
-
// anteprima live dal dialogo margini: valori non ancora persistiti
function previewMargins(lr, tb) {
- if (!web.loaded || !page.ready)
- return
- web.runJavaScript(marginJs(lr, tb), function() { })
+ page.previewMarginL = lr
+ page.previewMarginT = tb
+ // il reflow è immediato (binding); mantieni la frazione di lettura
+ page.pendingRestore = true
+ page.restoreFrac = page.chapterFraction
+ restoreTimer.start()
}
Component.onCompleted: {
@@ -497,10 +384,6 @@ Page {
page.ready = true
return
}
- openTimer.start()
- }
-
- function openChapter() {
// ripristina il segno locale: posizione per CARATTERE (v2),
// le vecchie chiavi v1 vengono migrate da parseBookmarkKey
var key = appSettings.bookmarkKey(page.bookId, page.bookFormat)
@@ -513,22 +396,16 @@ Page {
off = r.hasOwnProperty("charOffset") ? r.charOffset : -1
}
}
+ page.ready = true
page.currentChapter = ch
- page.chapterFraction = 0
- page.charOffset = 0
- page.restoreCharOffset = off
- page.lastSavedAbs = -1
- loadChapter()
+ page.pendingRestore = true
+ page.restoreFrac = off >= 0 ? offsetToFrac(off, ch) : 0.0
+ restoreTimer.start()
}
onStatusChanged: {
if (status === PageStatus.Inactive) {
maybeSave(true)
- } else if (status === PageStatus.Active) {
- // riapplica i margini se cambiati nelle impostazioni:
- // via JS sul documento aperto, senza ricaricare il capitolo
- if (page.ready && book.chapterCount() > 0)
- applyMarginsJS()
}
}
}
diff --git a/rpm/harbour-calibreweb.spec b/rpm/harbour-calibreweb.spec
index e9f4c8e..f23ac44 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.3.4
+Version: 0.4.0
Release: 1
Group: Qt/Qt
License: MIT
@@ -24,6 +24,21 @@ reflow del testo, margini configurabili e posizione salvata
in locale.
%changelog
+* Fri Aug 21 2026 Carlo Baratto - 0.4.0-1
+- LETTORE RIFATTO senza WebView: i capitoli sono rich text Qt in un Text QML
+ dentro un SilicaFlickable. Lo scroll (contentY/contentHeight) e' nativo QML
+ e sempre affidabile: sul WebView di Sailfish lo scroll programmatico non
+ funziona (window.scrollY stale, contentHeight/scrollPosition non scrivibili)
+ e il cambio capitolo non scattava mai (regressioni 0.3.x)
+- Paginazione a tap su contentY: avanti/indietro di una viewport, cambio
+ capitolo deterministico (fine = contentY >= contentHeight - height)
+- Bookmark v2 invariato (posizione per carattere, stimata dalla frazione di
+ scroll: stabile col reflow del font); migrazione v1 invariata
+- HTML->rich text in C++: tag di base (p, h1-h6, b/i/u/s, liste, tabelle,
+ blockquote), entita' HTML comuni decodificate, script/style/head/immagini
+ saltati, fallback per HTML malformato
+- NOTA: il lettore non mostra piu' le immagini inline dei capitoli (limite
+ del rich text Qt); niente scroll libero (paginazione a tap, come richiesto)
* Fri Aug 21 2026 Carlo Baratto - 0.3.4-1
- Fix tap avanti: scrollToCharJs usava window.scrollTo(0, rect.top+scrollY-8)
e window.scrollY e' stale sul WebView Sailfish -> il tap oscillava di pochi
diff --git a/src/epub.cpp b/src/epub.cpp
index ebb2c5f..4582d7b 100644
--- a/src/epub.cpp
+++ b/src/epub.cpp
@@ -193,6 +193,234 @@ int plainTextLength(const QString &html)
return count;
}
+// ---------------------------------------------------------------------------
+// HTML -> rich text Qt (per Text QML, niente WebView): tag di base convertiti,
+// blocchi raw (script/style/head/...) saltati, immagini ignorate, i tag non
+// supportati diventano trasparenti (resta il contenuto).
+// ---------------------------------------------------------------------------
+
+// Entità HTML nominate NON standard XML (QXmlStreamReader le rifiuta senza DTD):
+// decodificate prima del parse. Quelle XML standard (amp lt gt quot apos) e le
+// numeriche (NNN; HH;) le gestisce il parser.
+QString decodeHtmlEntities(const QString &s)
+{
+ static const QHash entities = [] {
+ QHash h;
+ h.insert(QStringLiteral("nbsp"), QChar(0x00A0));
+ h.insert(QStringLiteral("mdash"), QChar(0x2014));
+ h.insert(QStringLiteral("ndash"), QChar(0x2013));
+ h.insert(QStringLiteral("rsquo"), QChar(0x2019));
+ h.insert(QStringLiteral("lsquo"), QChar(0x2018));
+ h.insert(QStringLiteral("rdquo"), QChar(0x201D));
+ h.insert(QStringLiteral("ldquo"), QChar(0x201C));
+ h.insert(QStringLiteral("sbquo"), QChar(0x201A));
+ h.insert(QStringLiteral("bdquo"), QChar(0x201E));
+ h.insert(QStringLiteral("hellip"), QChar(0x2026));
+ h.insert(QStringLiteral("dagger"), QChar(0x2020));
+ h.insert(QStringLiteral("Dagger"), QChar(0x2021));
+ h.insert(QStringLiteral("bull"), QChar(0x2022));
+ h.insert(QStringLiteral("middot"), QChar(0x00B7));
+ h.insert(QStringLiteral("eacute"), QChar(0x00E9));
+ h.insert(QStringLiteral("egrave"), QChar(0x00E8));
+ h.insert(QStringLiteral("agrave"), QChar(0x00E0));
+ h.insert(QStringLiteral("ugrave"), QChar(0x00F9));
+ h.insert(QStringLiteral("igrave"), QChar(0x00EC));
+ h.insert(QStringLiteral("ograve"), QChar(0x00F2));
+ h.insert(QStringLiteral("aacute"), QChar(0x00E1));
+ h.insert(QStringLiteral("uacute"), QChar(0x00FA));
+ h.insert(QStringLiteral("iacute"), QChar(0x00ED));
+ h.insert(QStringLiteral("oacute"), QChar(0x00F3));
+ h.insert(QStringLiteral("auml"), QChar(0x00E4));
+ h.insert(QStringLiteral("euml"), QChar(0x00EB));
+ h.insert(QStringLiteral("iuml"), QChar(0x00EF));
+ h.insert(QStringLiteral("ouml"), QChar(0x00F6));
+ h.insert(QStringLiteral("uuml"), QChar(0x00FC));
+ h.insert(QStringLiteral("szlig"), QChar(0x00DF));
+ h.insert(QStringLiteral("deg"), QChar(0x00B0));
+ h.insert(QStringLiteral("plusmn"), QChar(0x00B1));
+ h.insert(QStringLiteral("times"), QChar(0x00D7));
+ h.insert(QStringLiteral("divide"), QChar(0x00F7));
+ h.insert(QStringLiteral("copy"), QChar(0x00A9));
+ h.insert(QStringLiteral("reg"), QChar(0x00AE));
+ h.insert(QStringLiteral("trade"), QChar(0x2122));
+ h.insert(QStringLiteral("euro"), QChar(0x20AC));
+ h.insert(QStringLiteral("pound"), QChar(0x00A3));
+ h.insert(QStringLiteral("cent"), QChar(0x00A2));
+ h.insert(QStringLiteral("yen"), QChar(0x00A5));
+ h.insert(QStringLiteral("para"), QChar(0x00B6));
+ h.insert(QStringLiteral("sect"), QChar(0x00A7));
+ h.insert(QStringLiteral("laquo"), QChar(0x00AB));
+ h.insert(QStringLiteral("raquo"), QChar(0x00BB));
+ h.insert(QStringLiteral("lsaquo"), QChar(0x2039));
+ h.insert(QStringLiteral("rsaquo"), QChar(0x203A));
+ h.insert(QStringLiteral("prime"), QChar(0x2032));
+ h.insert(QStringLiteral("Prime"), QChar(0x2033));
+ h.insert(QStringLiteral("permil"), QChar(0x2030));
+ return h;
+ }();
+
+ QString out;
+ out.reserve(s.size());
+ int i = 0;
+ const int n = s.size();
+ while (i < n) {
+ const QChar c = s.at(i);
+ if (c == QLatin1Char('&')) {
+ const int semi = s.indexOf(QLatin1Char(';'), i + 1);
+ if (semi > i && semi - i <= 12) {
+ const QString ent = s.mid(i + 1, semi - i - 1);
+ const QChar ch = entities.value(ent);
+ if (!ch.isNull()) {
+ out += ch;
+ i = semi + 1;
+ continue;
+ }
+ }
+ }
+ out += c;
+ ++i;
+ }
+ return out;
+}
+
+QString htmlToRichText(const QString &html)
+{
+ const int bodyStart = html.indexOf(QStringLiteral("= 0) {
+ // includi anche la chiusura : il frammento deve essere XML valido
+ const int end = (bodyEnd >= bodyStart) ? bodyEnd + 7 : html.size();
+ scope = html.mid(bodyStart, end - bodyStart);
+ } else {
+ scope = html;
+ }
+ scope = decodeHtmlEntities(scope);
+
+ QString out;
+ QStringList stack;
+ QXmlStreamReader r(scope);
+ while (!r.atEnd() && !r.hasError()) {
+ const QXmlStreamReader::TokenType t = r.readNext();
+ if (t == QXmlStreamReader::StartElement) {
+ const QString name = r.name().toString().toLower();
+ if (name == QLatin1String("script") || name == QLatin1String("style")
+ || name == QLatin1String("noscript") || name == QLatin1String("head")
+ || name == QLatin1String("title")) {
+ r.skipCurrentElement();
+ continue;
+ }
+ if (name == QLatin1String("br")) {
+ out += QStringLiteral("
");
+ stack.append(QString());
+ continue;
+ }
+ if (name == QLatin1String("img") || name == QLatin1String("hr")
+ || name == QLatin1String("input")) {
+ stack.append(QString());
+ continue;
+ }
+ QString tag;
+ if (name == QLatin1String("b") || name == QLatin1String("strong"))
+ tag = QStringLiteral("b");
+ else if (name == QLatin1String("i") || name == QLatin1String("em"))
+ tag = QStringLiteral("i");
+ else if (name == QLatin1String("u"))
+ tag = QStringLiteral("u");
+ else if (name == QLatin1String("s") || name == QLatin1String("strike")
+ || name == QLatin1String("del"))
+ tag = QStringLiteral("s");
+ else if (name == QLatin1String("ul"))
+ tag = QStringLiteral("ul");
+ else if (name == QLatin1String("ol"))
+ tag = QStringLiteral("ol");
+ else if (name == QLatin1String("li"))
+ tag = QStringLiteral("li");
+ else if (name == QLatin1String("table"))
+ tag = QStringLiteral("table border=\"1\" cellspacing=\"0\" cellpadding=\"4\"");
+ else if (name == QLatin1String("tr"))
+ tag = QStringLiteral("tr");
+ else if (name == QLatin1String("td") || name == QLatin1String("th"))
+ tag = QStringLiteral("td");
+ else if (name == QLatin1String("p") || name == QLatin1String("blockquote"))
+ tag = QStringLiteral("p");
+ else if (name == QLatin1String("h1") || name == QLatin1String("h2")
+ || name == QLatin1String("h3") || name == QLatin1String("h4")
+ || name == QLatin1String("h5") || name == QLatin1String("h6"))
+ tag = name;
+ // div/span/a/section/... -> trasparenti (tag vuoto)
+ if (!tag.isEmpty()) {
+ out += QLatin1Char('<') + tag + QLatin1Char('>');
+ stack.append(tag);
+ } else {
+ stack.append(QString());
+ }
+ } else if (t == QXmlStreamReader::EndElement) {
+ if (!stack.isEmpty()) {
+ const QString tag = stack.takeLast();
+ if (!tag.isEmpty()) {
+ const int sp = tag.indexOf(QLatin1Char(' '));
+ out += QStringLiteral("") + (sp > 0 ? tag.left(sp) : tag) + QLatin1Char('>');
+ }
+ }
+ } else if (t == QXmlStreamReader::Characters) {
+ out += r.text();
+ }
+ }
+
+ // HTML non ben formato (parse fallito): fallback grezzo — testo con
+ // sui newline e paragrafi sui doppi newline
+ if (r.hasError()) {
+ out.clear();
+ 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);
+ i = (close < 0) ? n : 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")) out += QLatin1Char('&');
+ else if (ent == QLatin1String("lt")) out += QLatin1Char('<');
+ else if (ent == QLatin1String("gt")) out += QLatin1Char('>');
+ else if (ent == QLatin1String("nbsp")) out += QLatin1Char(' ');
+ else if (ent.startsWith(QLatin1String("#"))) {
+ bool ok = false;
+ const int cp = ent.mid(1).toInt(&ok);
+ if (ok && cp > 0 && cp < 0x10FFFF)
+ out += QChar(cp);
+ } else {
+ out += c;
+ out += ent;
+ out += QLatin1Char(';');
+ }
+ i = semi + 1;
+ continue;
+ }
+ }
+ if (c == QLatin1Char('\n')) {
+ // doppio newline = paragrafo, singolo = a capo
+ if (i + 1 < n && scope.at(i + 1) == QLatin1Char('\n')) {
+ out += QStringLiteral("
");
+ ++i;
+ } else {
+ out += QStringLiteral("
");
+ }
+ ++i;
+ continue;
+ }
+ out += c;
+ ++i;
+ }
+ }
+ return out;
+}
+
} // namespace
// ---------------------------------------------------------------------------
@@ -524,6 +752,17 @@ QString EpubBook::chapterBaseUrl(int index) const
.toString();
}
+QString EpubBook::chapterRichText(int index) const
+{
+ if (index < 0 || index >= m_chapters.size())
+ return QString();
+ QFile f(m_chapters.at(index).path);
+ if (!f.open(QIODevice::ReadOnly))
+ return QString();
+ const QString html = QString::fromUtf8(f.readAll());
+ return htmlToRichText(html);
+}
+
void EpubBook::computeTextLengths()
{
m_textLengths.clear();
diff --git a/src/epub.h b/src/epub.h
index 0565fd2..fef98c9 100644
--- a/src/epub.h
+++ b/src/epub.h
@@ -45,6 +45,9 @@ public:
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;
+ // rich text Qt del capitolo (per Text QML: niente WebView, scroll affidabile).
+ // Converte i tag HTML di base, salta script/style/head/immagini.
+ Q_INVOKABLE QString chapterRichText(int index) const;
// chiave segno locale v2, es. "cw:v2:12345" (posizione = carattere assoluto
// nel libro, indipendente da font, margini e reflow)
diff --git a/src/main.cpp b/src/main.cpp
index 18213c2..385e824 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -22,7 +22,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
app->setOrganizationName(QStringLiteral("harbour"));
app->setApplicationName(QStringLiteral("calibreweb"));
// versione mostrata nelle impostazioni (tenere allineata allo spec)
- app->setApplicationVersion(QStringLiteral("0.3.4"));
+ app->setApplicationVersion(QStringLiteral("0.4.0"));
Settings settings;
@@ -56,7 +56,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
QQuickView *view = SailfishApp::createView();
- qDebug() << "harbour-calibreweb v0.3.4 build" << __DATE__ << __TIME__;
+ qDebug() << "harbour-calibreweb v0.4.0 build" << __DATE__ << __TIME__;
qmlRegisterType("harbour.calibreweb", 1, 0, "EpubBook");