import QtQuick 2.0 import Sailfish.Silica 1.0 import harbour.calibreweb 1.0 // Lettore a pagine REALI: il capitolo (rich text Qt) viene impaginato da // PagedTextItem (QTextDocument + QPainter, pattern QQuickPaintedItem come // harbour-books) in pagine misurate dal layout: niente scroll, niente stime, // niente testo che sborda e l'ultima pagina e' allineata da sola. // La finestra di lettura e' la GEOMETRIA dell'item: i margini (laterali, // alto/basso) e l'inset del notch valgono su OGNI pagina. // La posizione di lettura e' un indice di carattere: sopravvive al reflow del // font e alla rotazione senza timer ne' stime. Page { id: page property string epubPath: "" property int bookId: 0 property string bookFormat: "epub" property int currentChapter: 0 // posizione di lettura nel capitolo corrente in caratteri di testo // (metrica del capitolo, coerente coi segnalibri salvati) property int charOffset: 0 // caratteri da ripristinare nel capitolo appena aperto (-1 = nessuno) property int pendingChars: -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 string errorMessage: "" // reflow: scala il font (il testo si ri-impagina), persistita in appSettings property double fontScale: appSettings.readerFontScale // anteprima margini dal dialog (valori temporanei, -1 = usa le impostazioni) property int previewMarginL: -1 property int previewMarginT: -1 allowedOrientations: Orientation.All // Inset superiore per display con notch (Jolla Phone 2026): la finestra // dell'app inizia sopra la fine del ritaglio, quindi in portrait la zona di // lettura parte piu' in basso di un'altezza barra di stato, e il testo non // finisce mai sotto il notch (in landscape il notch e' laterale: gestito // dai margini orizzontali). Theme.statusBarHeight esiste nelle Silica // recenti: se manca si usa un valore prudente. function statusBarInset() { var h = Theme.statusBarHeight return (typeof h === "number" && h > 0) ? h : 48 } property int notchInset: page.isPortrait ? page.statusBarInset() : 0 EpubBook { id: book } SilicaFlickable { id: outerFlick anchors.fill: parent // NON scrollabile: il contenuto non supera mai la viewport contentHeight: outerFlick.height // il PullDownMenu va DENTRO una vista Silica, non a livello di Page PullDownMenu { id: pullMenu 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; esci dal preview page.previewMarginL = -1 page.previewMarginT = -1 }) } } 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.1) } Button { text: "A+" width: Theme.itemSizeSmall height: Theme.itemSizeMedium onClicked: page.changeFontScale(0.1) } 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 } } // Motore di paginazione: l'item impagina il rich text del capitolo e // disegna la pagina corrente. I margini sono anchors: la finestra di // lettura vive DENTRO i margini, quindi il testo non puo' uscirne. PagedTextItem { id: reader anchors.fill: parent anchors.leftMargin: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin anchors.rightMargin: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin anchors.topMargin: page.notchInset + (page.previewMarginT >= 0 ? page.previewMarginT : appSettings.readerMarginV) anchors.bottomMargin: page.previewMarginT >= 0 ? page.previewMarginT : appSettings.readerMarginV fontFamily: Theme.fontFamily fontPixelSize: page.fontPx() textColor: Theme.primaryColor // il reflow (font, geometria) mantiene la posizione internamente: // qui si aggiornano solo label, cover e segnalibro onPageChanged: page.syncState() onLayoutChanged: page.syncState() } // Paginazione a tap: 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() } } } Label { anchors.centerIn: parent text: page.errorMessage color: Theme.errorColor visible: page.errorMessage.length > 0 } // chiusura libro differita (pop dalla MainPage, non dall'handler del ReaderPage) Timer { id: closeBookTimer interval: 50 onTriggered: pageStack.pop(null) } // posizione di lettura nella metrica del capitolo (0..1), dal motore function readerFrac() { var n = reader.characterCount return n > 0 ? reader.position / n : 0.0 } // converte un offset di caratteri (metrica capitolo) nella posizione del // motore: le due metriche differiscono di poco, la frazione le allinea function posForOffset(off) { var len = book.chapterTextLength(page.currentChapter) var n = reader.characterCount if (len <= 0 || n <= 0) return 0 if (off >= len) return Math.max(0, n - 1) return Math.round((off / len) * n) } // offset di caratteri (metrica capitolo) della pagina corrente: e' quello // che finisce nel segnalibro, coerente con le chiavi salvate (v2) function offsetForPos() { return Math.round(readerFrac() * book.chapterTextLength(page.currentChapter)) } function overallProgress() { if (book.chapterCount <= 0) return 0 var total = book.chapterStartChar(book.chapterCount) if (total <= 0) return 0 var start = book.chapterStartChar(page.currentChapter) return (start + page.charOffset) / total } // aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow function syncState() { if (!page.ready) return page.pageIndex = reader.page + 1 page.pageCount = reader.pageCount page.charOffset = offsetForPos() updateCoverState() 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) 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.lastSavedAbs = -1 } page.currentChapter = index page.pendingChars = -1 // carica il capitolo e vai all'inizio (posizione esplicita) reader.html = book.chapterRichText(index) reader.goToPosition(0) syncState() } function changeFontScale(delta) { // limite ampio e passo 0.1: tanti ingrandimenti prima del limite appSettings.readerFontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta)) // il binding aggiorna fontPixelSize: il motore si ri-impagina e // mantiene da solo la posizione per carattere } // pagina successiva: a fine capitolo passa al successivo function nextPage() { if (!page.ready) return if (reader.page >= reader.pageCount - 1) { // fine capitolo: segna la coda e passa al capitolo dopo page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1) maybeSave(true) gotoChapter(page.currentChapter + 1) } else { reader.nextPage() } } // pagina precedente: dall'inizio del capitolo va al capitolo prima // SULL'ULTIMA PAGINA (non salta la coda del capitolo) function prevPage() { if (!page.ready) return if (reader.page > 0) { reader.previousPage() } else if (page.currentChapter > 0) { maybeSave(true) var prev = page.currentChapter - 1 page.currentChapter = prev reader.html = book.chapterRichText(prev) // posizione esplicita: fondo del capitolo precedente reader.goToPosition(Math.max(0, reader.characterCount - 1)) syncState() } } function fontPx() { return Math.round(16 * page.fontScale * 100) / 100 } // aggiorna lo stato per la cover (titolo + capitolo + percentuale) function updateCoverState() { if (book.chapterCount <= 0) return var pct = Math.round(page.overallProgress() * 100) readerState.setReading(book.title(), (page.currentChapter + 1) + "/" + book.chapterCount + " · " + book.chapterTitle(page.currentChapter) + " · " + pct + "%") } function showChapterList() { var dialog = pageStack.push(Qt.resolvedUrl("ChapterListDialog.qml"), { model: book.chapterList() }) dialog.accepted.connect(function() { gotoChapter(dialog.selectedIndex) }) } // anteprima live dal dialogo margini: la geometria dell'item cambia e il // motore si ri-impagina mantenendo la posizione di lettura function previewMargins(lr, tb) { page.previewMarginL = lr page.previewMarginT = tb } // apre un capitolo e va alla posizione indicata (metrica capitolo) function loadChapter(index, offset) { page.currentChapter = index reader.html = book.chapterRichText(index) var target = offset >= 0 ? Math.round((offset / Math.max(1, book.chapterTextLength(index))) * reader.characterCount) : 0 reader.goToPosition(target) syncState() } Component.onCompleted: { page.ready = false if (!book.open(page.epubPath)) { page.errorMessage = qsTr("Unable to open the EPUB file") page.ready = true return } // 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.ready = true loadChapter(ch, off) } Component.onDestruction: readerState.clearReading() onStatusChanged: { if (status === PageStatus.Active) { if (page.ready && book.chapterCount > 0) updateCoverState() } else if (status === PageStatus.Inactive) { maybeSave(true) } } }