Files
harbour-calibreweb/qml/pages/ReaderPage.qml
T
kaneda d418660a2c 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
2026-08-21 07:57:57 +02:00

514 lines
20 KiB
QML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import QtQuick 2.0
import Sailfish.Silica 1.0
import Sailfish.WebView 1.0
import harbour.calibreweb 1.0
Page {
id: page
property string epubPath: ""
property int bookId: 0
property string bookFormat: "epub"
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 string errorMessage: ""
// reflow: scala il font (il testo si ri-avvolge), non lo zoom grafico
property double fontScale: 1.0
allowedOrientations: Orientation.All
EpubBook {
id: book
}
SilicaFlickable {
anchors.fill: parent
contentHeight: parent.height
// il PullDownMenu va DENTRO una vista Silica, non a livello di Page
PullDownMenu {
MenuItem {
text: qsTr("Table of contents")
onClicked: showChapterList()
}
MenuItem {
text: qsTr("Text margins")
onClicked: {
var dlg = pageStack.push(Qt.resolvedUrl("MarginDialog.qml"), {
marginLr: appSettings.readerMargin,
marginTb: appSettings.readerMarginV,
previewHandler: page.previewMargins
})
dlg.accepted.connect(function() {
// i valori sono già persistiti da onAccepted; rinfresca subito
applyMarginsJS()
})
}
}
MenuItem {
text: qsTr("Settings")
onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
MenuItem {
text: qsTr("Close book")
onClicked: {
page.maybeSave(true)
// differito: pop() subito dall'handler della pagina che viene
// distrutta crasha; pop(null) = torna alla prima pagina
closeBookTimer.start()
}
}
}
// controlli del lettore: un solo trascinamento in alto li apre
PushUpMenu {
id: readerMenu
Label {
anchors.horizontalCenter: parent.horizontalCenter
width: parent.width - 2 * Theme.horizontalPageMargin
text: (book.chapterCount > 0)
? page.pageIndex + "/" + page.pageCount + " · "
+ (page.currentChapter + 1) + "/" + book.chapterCount
+ " · " + book.chapterTitle(page.currentChapter)
+ " · " + Math.round(page.overallProgress() * 100) + "%"
: "…"
truncationMode: TruncationMode.Elide
color: Theme.primaryColor
horizontalAlignment: Text.AlignHCenter
font.pixelSize: Theme.fontSizeSmall
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.paddingSmall
IconButton {
icon.source: "image://theme/icon-m-left"
enabled: page.currentChapter > 0
onClicked: gotoChapter(page.currentChapter - 1)
}
Button {
text: "A−"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(-0.15)
}
Button {
text: "A+"
width: Theme.itemSizeSmall
height: Theme.itemSizeMedium
onClicked: page.changeFontScale(0.15)
}
IconButton {
icon.source: "image://theme/icon-m-right"
enabled: page.currentChapter < book.chapterCount - 1
onClicked: gotoChapter(page.currentChapter + 1)
}
}
ProgressBar {
width: parent.width
value: page.overallProgress()
minimumValue: 0
maximumValue: 1
}
}
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)
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
}
}
// 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).
MouseArea {
id: tapZone
anchors.fill: parent
anchors.topMargin: Theme.itemSizeLarge
anchors.bottomMargin: Theme.itemSizeLarge + Theme.paddingLarge
onClicked: {
if (mouse.x > width / 2)
page.nextPage()
else
page.prevPage()
}
}
}
BusyIndicator {
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()
}
// chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage)
Timer {
id: closeBookTimer
interval: 50
onTriggered: pageStack.pop(null)
}
Label {
anchors.centerIn: parent
text: page.errorMessage
color: Theme.errorColor
visible: page.errorMessage.length > 0
}
// ---- controlli spostati nel PushUpMenu (trascina in alto) ----
// ---- timer di tracciamento ----
Timer {
id: trackTimer
interval: 2000
running: page.ready
repeat: true
onTriggered: readScrollPosition()
}
function overallProgress() {
if (book.chapterCount <= 0)
return 0
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() {
if (!web.loaded)
return
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
// 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.charOffset))
}
}
function gotoChapter(index) {
if (index < 0 || index >= book.chapterCount)
return
if (index !== page.currentChapter) {
maybeSave(true)
page.chapterFraction = 0
page.charOffset = 0
page.restoreCharOffset = -1
page.lastSavedAbs = -1
}
page.currentChapter = index
loadChapter()
}
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()
}
// 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(
"(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 if (r.atEnd) {
gotoChapter(page.currentChapter + 1)
}
})
}
// 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(
"(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 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()
}
})
}
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() })
dialog.accepted.connect(function() {
gotoChapter(dialog.selectedIndex)
})
}
// 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() { })
}
Component.onCompleted: {
page.ready = false
if (!book.open(page.epubPath)) {
page.errorMessage = qsTr("Unable to open the EPUB file")
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)
var ch = 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))
off = r.hasOwnProperty("charOffset") ? r.charOffset : -1
}
}
page.currentChapter = ch
page.chapterFraction = 0
page.charOffset = 0
page.restoreCharOffset = off
page.lastSavedAbs = -1
loadChapter()
}
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()
}
}
}