diff --git a/harbour-calibreweb.pro b/harbour-calibreweb.pro index 8abe925..da9f85f 100644 --- a/harbour-calibreweb.pro +++ b/harbour-calibreweb.pro @@ -11,7 +11,8 @@ SOURCES += \ src/downloader.cpp \ src/opdsparser.cpp \ src/epub.cpp \ - src/readerstate.cpp + src/readerstate.cpp \ + src/pagedtextitem.cpp HEADERS += \ src/settings.h \ @@ -19,7 +20,8 @@ HEADERS += \ src/downloader.h \ src/opdsparser.h \ src/epub.h \ - src/readerstate.h + src/readerstate.h \ + src/pagedtextitem.h LIBS += -lz diff --git a/qml/pages/ReaderPage.qml b/qml/pages/ReaderPage.qml index 2649037..6665951 100644 --- a/qml/pages/ReaderPage.qml +++ b/qml/pages/ReaderPage.qml @@ -2,14 +2,14 @@ import QtQuick 2.0 import Sailfish.Silica 1.0 import harbour.calibreweb 1.0 -// Lettore senza WebView: il capitolo è rich text Qt in un Text QML. -// STRUTTURA A PAGINE RIGIDE: -// - SilicaFlickable ESTERNO con contentHeight = height: NON scrollabile, -// serve solo per i pulley menu (PullDown/PushUp) che funzionano dal bordo -// - Flickable INTERNO con interactive:false: il dito non lo scrolla MAI, -// contentY cambia solo col tap (paginazione programmatica) -// Risultato: niente scroll libero, niente snap, menu sempre funzionanti, -// paginazione a tap affidabile al 100% (proprietà QML native). +// 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 @@ -18,11 +18,11 @@ Page { property string bookFormat: "epub" property int currentChapter: 0 - // posizione di lettura nel capitolo corrente, stimata in caratteri di testo - // dalla frazione di scroll (stabile col reflow del font) + // posizione di lettura nel capitolo corrente in caratteri di testo + // (metrica del capitolo, coerente coi segnalibri salvati) property int charOffset: 0 - // offset da ripristinare al cambio capitolo (-1 = nessuno) - property int restoreCharOffset: -1 + // 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) @@ -30,11 +30,8 @@ Page { property int pageCount: 1 property bool ready: false property string errorMessage: "" - // reflow: scala il font (il testo si ri-avvolge), persistita in appSettings + // reflow: scala il font (il testo si ri-impagina), persistita in appSettings property double fontScale: appSettings.readerFontScale - // 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 @@ -45,8 +42,14 @@ Page { // 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). - property int notchInset: page.isPortrait ? Theme.statusBarHeight : 0 + // 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 @@ -149,62 +152,33 @@ Page { } } - // contenuto: Flickable NON interattivo (lo scroll è solo programmatico). - // La finestra di lettura VIVE DENTRO i margini verticali (top/bottom - // margin): il testo non puo' fisicamente uscire dai bordi, quindi i - // margini alto/basso sono identici su OGNI pagina (non solo a inizio - // capitolo) e si aggiornano live con l'anteprima del dialog. - Flickable { - id: innerFlick + // 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 - contentHeight: readerContent.height - interactive: false - clip: true - - Item { - id: readerContent - width: parent.width - // Altezza = testo + "deficit" dell'ultima pagina: max-scroll - // diventa k*step esatto e l'ultima pagina e' allineata (niente - // testo ripetuto tra penultima e ultima). Calcolo INLINE nel - // binding: si rivaluta quando cambia la finestra (rotazione, - // anteprima margini) o l'altezza del testo (reflow). - height: { - var step = Math.max(1, parent.height) - var t = textItem.height - var pad = 0 - if (t > 0) { - var r = t % step - pad = r === 0 ? 0 : step - r - } - return Math.max(parent.height, t + pad) - } - - Text { - id: textItem - x: page.previewMarginL >= 0 ? page.previewMarginL : appSettings.readerMargin - 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() - } - } + 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). NIENTE preventStealing: - // il drag non ha effetti perche' il contenuto non e' scrollabile. + // menu (PullDown in alto, PushUp in basso). MouseArea { id: tapZone anchors.fill: parent @@ -233,28 +207,28 @@ Page { onTriggered: pageStack.pop(null) } - // rete di sicurezza per il restore: il caso normale è applicato da - // onTextReflowed appena il reflow cambia contentHeight; questo timer - // copre i rari casi in cui l'altezza non cambia (capitoli identici). - // Ritardo lungo: il reflow rich text può richiedere qualche centinaio di ms. - Timer { - id: restoreTimer - interval: 500 - onTriggered: { - if (page.pendingRestore) { - page.pendingRestore = false - applyRestore() - } - } + // 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 } - // tracciamento periodico (aggiorna label e bookmark anche senza tap) - Timer { - id: trackTimer - interval: 3000 - running: page.ready - repeat: true - onTriggered: readScrollPosition() + // 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() { @@ -267,44 +241,13 @@ Page { return (start + page.charOffset) / total } - // frazione di contenuto per un offset in caratteri (off >= 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)) - } - - function applyRestore() { - var max = Math.max(0, innerFlick.contentHeight - innerFlick.height) - innerFlick.contentY = page.restoreFrac * max - readScrollPosition() - } - - // 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() { + // aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow + function syncState() { if (!page.ready) return - // passo di pagina = altezza della finestra di lettura (i margini - // alto/basso sono FUORI dal Flickable: nessun testo li attraversa) - var step = Math.max(1, innerFlick.height) - var max = Math.max(0, innerFlick.contentHeight - innerFlick.height) - var y = Math.max(0, Math.min(max, innerFlick.contentY)) - var len = book.chapterTextLength(page.currentChapter) - // posizione per caratteri (stima dalla frazione: stabile col reflow) - page.charOffset = Math.round((max > 0 ? y / max : 0) * len) - page.pageIndex = Math.floor(y / step) + 1 - page.pageCount = Math.max(1, Math.ceil(max / step) + 1) + page.pageIndex = reader.page + 1 + page.pageCount = reader.pageCount + page.charOffset = offsetForPos() updateCoverState() maybeSave(false) } @@ -315,7 +258,7 @@ Page { // 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) + // di almeno 20 caratteri (evita scritture continue) if (force || Math.abs(abs - page.lastSavedAbs) > 20) { page.lastSavedAbs = abs appSettings.setBookmarkKey(page.bookId, page.bookFormat, @@ -328,71 +271,52 @@ Page { return if (index !== page.currentChapter) { maybeSave(true) - page.charOffset = 0 page.lastSavedAbs = -1 } page.currentChapter = index - // 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() + 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 - page.fontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta)) - // persisti la scala: riparte da qui alla prossima apertura - appSettings.readerFontScale = page.fontScale - // il binding aggiorna il font; ripristina la posizione per CARATTERI - // (stabile col reflow, a differenza della frazione di scroll: con il - // testo più alto la stessa frazione mostrerebbe un punto diverso) - page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter) - page.pendingRestore = true - restoreTimer.start() + 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: una pagina di testo = altezza della finestra di - // lettura; a fine capitolo passa al successivo + // pagina successiva: a fine capitolo passa al successivo function nextPage() { if (!page.ready) return - var step = Math.max(1, innerFlick.height) - var max = Math.max(0, innerFlick.contentHeight - innerFlick.height) - var y = innerFlick.contentY - if (y >= max - 2) { - // fine capitolo (o capitolo non scrollabile): segna la coda e passa + 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 { - innerFlick.contentY = Math.min(max, y + step) - readScrollPosition() + reader.nextPage() } } - // pagina precedente: una pagina indietro; dall'inizio del capitolo va al - // capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo) + // 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 - var step = Math.max(1, innerFlick.height) - var y = innerFlick.contentY - if (y > 1) { - innerFlick.contentY = Math.max(0, y - step) - readScrollPosition() + if (reader.page > 0) { + reader.previousPage() } else if (page.currentChapter > 0) { maybeSave(true) - page.currentChapter -= 1 - 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() + 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() } } @@ -419,14 +343,23 @@ Page { }) } - // anteprima live dal dialogo margini: valori non ancora persistiti + // 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 - // il reflow è immediato (binding); mantieni la posizione per caratteri - page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter) - page.pendingRestore = true - restoreTimer.start() + } + + // 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: { @@ -449,11 +382,7 @@ Page { } } page.ready = true - page.currentChapter = ch - page.pendingRestore = true - page.restoreFrac = off >= 0 ? offsetToFrac(off, ch) : 0.0 - updateCoverState() - restoreTimer.start() + loadChapter(ch, off) } Component.onDestruction: readerState.clearReading() diff --git a/rpm/harbour-calibreweb.spec b/rpm/harbour-calibreweb.spec index 329c9e6..fb783aa 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.4.13 +Version: 0.5.0 Release: 1 Group: Qt/Qt License: MIT @@ -24,6 +24,14 @@ reflow del testo, margini configurabili e posizione salvata in locale. %changelog +* Mon Sep 07 2026 Carlo Baratto - 0.5.0-1 +- Nuovo motore di lettura: PagedTextItem (QQuickPaintedItem + QTextDocument, + pattern di harbour-books) impagina il capitolo in pagine misurate dal layout. + Niente piu' Flickable a step ne' "deficit": pagine esatte, ultima pagina + allineata, margini e inset notch validi su ogni pagina, posizione di lettura + per carattere che sopravvive a reflow del font e rotazione (senza timer). + Segnalibri invariati (stessa metrica, chiavi v2 compatibili) + * Mon Sep 07 2026 Carlo Baratto - 0.4.13-1 - Lettore: il testo non finisce piu' sotto il notch del display (Jolla Phone 2026): in portrait la finestra di lettura parte piu' in basso di un'altezza diff --git a/src/main.cpp b/src/main.cpp index d96dce1..4b5b4be 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -10,6 +10,7 @@ #include "apiclient.h" #include "downloader.h" #include "epub.h" +#include "pagedtextitem.h" #include "readerstate.h" #include "settings.h" @@ -23,7 +24,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.4.13")); + app->setApplicationVersion(QStringLiteral("0.5.0")); Settings settings; @@ -57,9 +58,10 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) QQuickView *view = SailfishApp::createView(); - qDebug() << "harbour-calibreweb v0.4.13 build" << __DATE__ << __TIME__; + qDebug() << "harbour-calibreweb v0.5.0 build" << __DATE__ << __TIME__; qmlRegisterType("harbour.calibreweb", 1, 0, "EpubBook"); + qmlRegisterType("harbour.calibreweb", 1, 0, "PagedTextItem"); ApiClient apiClient(&settings); Downloader downloader(&settings); diff --git a/src/pagedtextitem.cpp b/src/pagedtextitem.cpp new file mode 100644 index 0000000..2c5e9ae --- /dev/null +++ b/src/pagedtextitem.cpp @@ -0,0 +1,246 @@ +#include "pagedtextitem.h" + +#include +#include +#include +#include +#include +#include +#include + +PagedTextItem::PagedTextItem(QQuickItem *parent) + : QQuickPaintedItem(parent) + , m_fontPixelSize(16) + , m_page(0) + , m_pendingPosition(-1) + , m_layoutPageHeight(1) +{ + // nessun margine interno: i margini li impone la geometria dell'item in QML + m_doc.setDocumentMargin(0); + applyFont(); +} + +void PagedTextItem::applyFont() +{ + QFont f = m_doc.defaultFont(); + if (!m_fontFamily.isEmpty()) + f.setFamily(m_fontFamily); + f.setPixelSize(qMax(4, m_fontPixelSize)); + m_doc.setDefaultFont(f); +} + +void PagedTextItem::setHtml(const QString &html) +{ + if (m_html == html) + return; + m_html = html; + m_page = 0; + m_doc.setHtml(m_html); + if (!m_baseUrl.isEmpty()) + m_doc.setBaseUrl(QUrl(m_baseUrl)); + relayout(false); + emit htmlChanged(); +} + +void PagedTextItem::setBaseUrl(const QString &url) +{ + if (m_baseUrl == url) + return; + m_baseUrl = url; + m_doc.setBaseUrl(QUrl(url)); + emit baseUrlChanged(); + update(); +} + +void PagedTextItem::setFontFamily(const QString &family) +{ + if (m_fontFamily == family) + return; + // posizione letta PRIMA di toccare il font: il layout e' ancora coerente + const int keep = positionAtPage(m_page); + m_fontFamily = family; + applyFont(); + relayoutKeeping(keep); + emit fontFamilyChanged(); +} + +void PagedTextItem::setFontPixelSize(int px) +{ + px = qMax(4, px); + if (m_fontPixelSize == px) + return; + // posizione letta PRIMA del reflow (vedi setFontFamily) + const int keep = positionAtPage(m_page); + m_fontPixelSize = px; + applyFont(); + // reflow: mantiene la posizione di lettura per carattere + relayoutKeeping(keep); + emit fontPixelSizeChanged(); +} + +void PagedTextItem::setTextColor(const QColor &color) +{ + if (m_textColor == color) + return; + m_textColor = color; + emit textColorChanged(); + update(); +} + +void PagedTextItem::setPage(int page) +{ + const int last = pageCount() - 1; + page = qBound(0, page, qMax(0, last)); + if (m_page == page) + return; + m_page = page; + emit pageChanged(); + emit positionChanged(); + update(); +} + +void PagedTextItem::nextPage() +{ + setPage(m_page + 1); +} + +void PagedTextItem::previousPage() +{ + setPage(m_page - 1); +} + +void PagedTextItem::goToPosition(int pos) +{ + if (width() <= 0 || height() <= 0) { + // geometria non ancora risolta: applica al primo layout + m_pendingPosition = pos; + return; + } + setPage(pageForPosition(pos)); +} + +int PagedTextItem::pageCount() const +{ + const int n = m_doc.pageCount(); + return n > 0 ? n : 1; +} + +int PagedTextItem::characterCount() const +{ + return m_doc.characterCount(); +} + +int PagedTextItem::position() const +{ + return positionAtPage(m_page); +} + +qreal PagedTextItem::pageHeight() const +{ + // altezza dell'ULTIMO layout: se la geometria e' appena cambiata ma il + // documento non e' ancora stato ri-impaginato, le letture restano coerenti + return m_layoutPageHeight > 0 ? m_layoutPageHeight : qMax(qreal(1), height()); +} + +int PagedTextItem::positionAtPage(int page) const +{ + if (m_doc.isEmpty() || width() <= 0 || height() <= 0) + return 0; + QAbstractTextDocumentLayout *layout = m_doc.documentLayout(); + if (!layout) + return 0; + const qreal ph = pageHeight(); + // punto appena dentro la pagina: hitTest dà il carattere piu' vicino + const QPointF probe(qMin(qreal(4), width() / 2), page * ph + 1); + const int pos = layout->hitTest(probe, Qt::FuzzyHit); + if (pos < 0) + return 0; + return qBound(0, pos, qMax(0, m_doc.characterCount() - 1)); +} + +int PagedTextItem::pageForPosition(int pos) const +{ + if (m_doc.isEmpty() || width() <= 0 || height() <= 0) + return 0; + const int len = m_doc.characterCount(); + const int at = qBound(0, pos, qMax(0, len - 1)); + // findBlock invece di QTextCursor: metodo const (niente cast sul documento) + const QTextBlock block = m_doc.findBlock(at); + QRectF rect; + if (QAbstractTextDocumentLayout *layout = m_doc.documentLayout()) + rect = layout->blockBoundingRect(block); + qreal y = rect.top(); + if (block.layout()) { + // y della riga che contiene il carattere, relativa al blocco: cosi' + // anche i paragrafi lunghi (spezzati su piu' pagine) sono precisi + const int inBlock = qBound(0, at - block.position(), + qMax(0, block.length() - 1)); + const QTextLine line = block.layout()->lineForTextPosition(inBlock); + if (line.isValid()) + y += line.y(); + } + return qMax(0, int(y / pageHeight())); +} + +void PagedTextItem::clampPage() +{ + const int last = pageCount() - 1; + m_page = qBound(0, m_page, qMax(0, last)); +} + +void PagedTextItem::relayoutKeeping(int pos) +{ + if (width() <= 0 || height() <= 0) + return; + m_doc.setPageSize(QSizeF(width(), height())); + m_layoutPageHeight = qMax(qreal(1), height()); + if (m_pendingPosition >= 0) { + // posizione chiesta prima del layout (ripristino all'apertura) + m_page = pageForPosition(m_pendingPosition); + m_pendingPosition = -1; + } else if (pos >= 0) { + m_page = pageForPosition(pos); + } + clampPage(); + emit layoutChanged(); + emit positionChanged(); + update(); +} + +void PagedTextItem::relayout(bool keepPosition) +{ + relayoutKeeping(keepPosition ? positionAtPage(m_page) : -1); +} + +void PagedTextItem::geometryChanged(const QRectF &newGeometry, + const QRectF &oldGeometry) +{ + QQuickPaintedItem::geometryChanged(newGeometry, oldGeometry); + if (newGeometry.size() != oldGeometry.size()) + relayout(true); +} + +void PagedTextItem::renderPage(QPainter *painter) const +{ + if (!painter || width() <= 0 || height() <= 0 || m_doc.isEmpty()) + return; + const qreal ph = pageHeight(); + painter->save(); + painter->setRenderHint(QPainter::TextAntialiasing, true); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setClipRect(QRectF(0, 0, width(), ph)); + painter->translate(0, -m_page * ph); + + QAbstractTextDocumentLayout::PaintContext ctx; + // il clip del contesto e' in coordinate documento + ctx.clip = QRectF(0, m_page * ph, width(), ph); + if (m_textColor.isValid()) + ctx.palette.setColor(QPalette::Text, m_textColor); + m_doc.documentLayout()->draw(painter, ctx); + painter->restore(); +} + +void PagedTextItem::paint(QPainter *painter) +{ + renderPage(painter); +} diff --git a/src/pagedtextitem.h b/src/pagedtextitem.h new file mode 100644 index 0000000..b2b8a0d --- /dev/null +++ b/src/pagedtextitem.h @@ -0,0 +1,105 @@ +#ifndef PAGEDTEXTITEM_H +#define PAGEDTEXTITEM_H + +// Motore di paginazione del lettore: pagina il rich text del capitolo in +// pagine ESATTE usando QTextDocument, e lo disegna con QPainter dentro un item +// QML (pattern QQuickPaintedItem, come harbour-books di Slava Monich). +// +// Perche': QTextDocument con pageSize imposta il "page breaking" del layout, +// quindi le pagine sono misurate dal layout stesso (niente stime, niente +// scroll, niente testo che sborda) e hitTest mappa pagina <-> carattere, che +// serve ai segnalibri e al ripristino della posizione dopo un reflow. +#include +#include +#include +#include + +class PagedTextItem : public QQuickPaintedItem +{ + Q_OBJECT + Q_PROPERTY(QString html READ html WRITE setHtml NOTIFY htmlChanged) + Q_PROPERTY(QString baseUrl READ baseUrl WRITE setBaseUrl NOTIFY baseUrlChanged) + Q_PROPERTY(QString fontFamily READ fontFamily WRITE setFontFamily + NOTIFY fontFamilyChanged) + Q_PROPERTY(int fontPixelSize READ fontPixelSize WRITE setFontPixelSize + NOTIFY fontPixelSizeChanged) + Q_PROPERTY(QColor textColor READ textColor WRITE setTextColor + NOTIFY textColorChanged) + // pagina corrente (0-based) e numero di pagine del capitolo caricato + Q_PROPERTY(int page READ page WRITE setPage NOTIFY pageChanged) + Q_PROPERTY(int pageCount READ pageCount NOTIFY layoutChanged) + // primo carattere visibile nella pagina corrente (per i segnalibri) + Q_PROPERTY(int position READ position NOTIFY positionChanged) + Q_PROPERTY(int characterCount READ characterCount NOTIFY layoutChanged) + +public: + explicit PagedTextItem(QQuickItem *parent = nullptr); + + void paint(QPainter *painter) override; + // disegno della pagina corrente; pubblico per i test headless + void renderPage(QPainter *painter) const; + + QString html() const { return m_html; } + void setHtml(const QString &html); + QString baseUrl() const { return m_baseUrl; } + void setBaseUrl(const QString &url); + QString fontFamily() const { return m_fontFamily; } + void setFontFamily(const QString &family); + int fontPixelSize() const { return m_fontPixelSize; } + void setFontPixelSize(int px); + QColor textColor() const { return m_textColor; } + void setTextColor(const QColor &color); + + int page() const { return m_page; } + void setPage(int page); + int pageCount() const; + int position() const; + int characterCount() const; + + Q_INVOKABLE void nextPage(); + Q_INVOKABLE void previousPage(); + // sposta la vista sul carattere indicato (ripristino posizione/reflow) + Q_INVOKABLE void goToPosition(int pos); + Q_INVOKABLE int positionAtPage(int page) const; + Q_INVOKABLE int pageForPosition(int pos) const; + +signals: + void htmlChanged(); + void baseUrlChanged(); + void fontFamilyChanged(); + void fontPixelSizeChanged(); + void textColorChanged(); + void pageChanged(); + void positionChanged(); + // il documento e' stato ri-impaginato (resize, font, nuovo capitolo) + void layoutChanged(); + +protected: + void geometryChanged(const QRectF &newGeometry, + const QRectF &oldGeometry) override; + +private: + void applyFont(); + // ri-impagina e (se pos >= 0) riporta la vista sul carattere indicato + void relayoutKeeping(int pos); + void relayout(bool keepPosition); + void clampPage(); + qreal pageHeight() const; + + QTextDocument m_doc; + QString m_html; + QString m_baseUrl; + QString m_fontFamily; + int m_fontPixelSize; + QColor m_textColor; + int m_page; + // posizione richiesta prima che l'item avesse una geometria valida: + // applicata al primo layout (senza, il ripristino del segnalibro + // atterrerebbe sempre a inizio capitolo) + int m_pendingPosition; + // altezza pagina dell'ultimo layout: le letture di posizione restano + // coerenti anche se la geometria dell'item e' appena cambiata + qreal m_layoutPageHeight; +}; + +#endif // PAGEDTEXTITEM_H diff --git a/translations/harbour-calibreweb_it.ts b/translations/harbour-calibreweb_it.ts index 548ab8b..9b57a54 100644 --- a/translations/harbour-calibreweb_it.ts +++ b/translations/harbour-calibreweb_it.ts @@ -138,27 +138,27 @@ ReaderPage - + Table of contents Indice capitoli - + Text margins Margini del testo - + Settings Impostazioni - + Close book Chiudi libro - + Unable to open the EPUB file Impossibile aprire il libro EPUB