0.5.0: nuovo motore di lettura PagedTextItem (QQuickPaintedItem + QTextDocument, pattern harbour-books): pagine misurate dal layout, margini e notch su ogni pagina, posizione per carattere stabile a reflow/rotazione
This commit is contained in:
@@ -11,7 +11,8 @@ SOURCES += \
|
|||||||
src/downloader.cpp \
|
src/downloader.cpp \
|
||||||
src/opdsparser.cpp \
|
src/opdsparser.cpp \
|
||||||
src/epub.cpp \
|
src/epub.cpp \
|
||||||
src/readerstate.cpp
|
src/readerstate.cpp \
|
||||||
|
src/pagedtextitem.cpp
|
||||||
|
|
||||||
HEADERS += \
|
HEADERS += \
|
||||||
src/settings.h \
|
src/settings.h \
|
||||||
@@ -19,7 +20,8 @@ HEADERS += \
|
|||||||
src/downloader.h \
|
src/downloader.h \
|
||||||
src/opdsparser.h \
|
src/opdsparser.h \
|
||||||
src/epub.h \
|
src/epub.h \
|
||||||
src/readerstate.h
|
src/readerstate.h \
|
||||||
|
src/pagedtextitem.h
|
||||||
|
|
||||||
LIBS += -lz
|
LIBS += -lz
|
||||||
|
|
||||||
|
|||||||
+101
-172
@@ -2,14 +2,14 @@ import QtQuick 2.0
|
|||||||
import Sailfish.Silica 1.0
|
import Sailfish.Silica 1.0
|
||||||
import harbour.calibreweb 1.0
|
import harbour.calibreweb 1.0
|
||||||
|
|
||||||
// Lettore senza WebView: il capitolo è rich text Qt in un Text QML.
|
// Lettore a pagine REALI: il capitolo (rich text Qt) viene impaginato da
|
||||||
// STRUTTURA A PAGINE RIGIDE:
|
// PagedTextItem (QTextDocument + QPainter, pattern QQuickPaintedItem come
|
||||||
// - SilicaFlickable ESTERNO con contentHeight = height: NON scrollabile,
|
// harbour-books) in pagine misurate dal layout: niente scroll, niente stime,
|
||||||
// serve solo per i pulley menu (PullDown/PushUp) che funzionano dal bordo
|
// niente testo che sborda e l'ultima pagina e' allineata da sola.
|
||||||
// - Flickable INTERNO con interactive:false: il dito non lo scrolla MAI,
|
// La finestra di lettura e' la GEOMETRIA dell'item: i margini (laterali,
|
||||||
// contentY cambia solo col tap (paginazione programmatica)
|
// alto/basso) e l'inset del notch valgono su OGNI pagina.
|
||||||
// Risultato: niente scroll libero, niente snap, menu sempre funzionanti,
|
// La posizione di lettura e' un indice di carattere: sopravvive al reflow del
|
||||||
// paginazione a tap affidabile al 100% (proprietà QML native).
|
// font e alla rotazione senza timer ne' stime.
|
||||||
Page {
|
Page {
|
||||||
id: page
|
id: page
|
||||||
|
|
||||||
@@ -18,11 +18,11 @@ Page {
|
|||||||
property string bookFormat: "epub"
|
property string bookFormat: "epub"
|
||||||
|
|
||||||
property int currentChapter: 0
|
property int currentChapter: 0
|
||||||
// posizione di lettura nel capitolo corrente, stimata in caratteri di testo
|
// posizione di lettura nel capitolo corrente in caratteri di testo
|
||||||
// dalla frazione di scroll (stabile col reflow del font)
|
// (metrica del capitolo, coerente coi segnalibri salvati)
|
||||||
property int charOffset: 0
|
property int charOffset: 0
|
||||||
// offset da ripristinare al cambio capitolo (-1 = nessuno)
|
// caratteri da ripristinare nel capitolo appena aperto (-1 = nessuno)
|
||||||
property int restoreCharOffset: -1
|
property int pendingChars: -1
|
||||||
// ultimo offset assoluto salvato (evita scritture continue)
|
// ultimo offset assoluto salvato (evita scritture continue)
|
||||||
property int lastSavedAbs: -1
|
property int lastSavedAbs: -1
|
||||||
// pagina corrente / pagine totali del capitolo (per la label del menu)
|
// pagina corrente / pagine totali del capitolo (per la label del menu)
|
||||||
@@ -30,11 +30,8 @@ Page {
|
|||||||
property int pageCount: 1
|
property int pageCount: 1
|
||||||
property bool ready: false
|
property bool ready: false
|
||||||
property string errorMessage: ""
|
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
|
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)
|
// anteprima margini dal dialog (valori temporanei, -1 = usa le impostazioni)
|
||||||
property int previewMarginL: -1
|
property int previewMarginL: -1
|
||||||
property int previewMarginT: -1
|
property int previewMarginT: -1
|
||||||
@@ -45,8 +42,14 @@ Page {
|
|||||||
// dell'app inizia sopra la fine del ritaglio, quindi in portrait la zona di
|
// 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
|
// 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
|
// finisce mai sotto il notch (in landscape il notch e' laterale: gestito
|
||||||
// dai margini orizzontali).
|
// dai margini orizzontali). Theme.statusBarHeight esiste nelle Silica
|
||||||
property int notchInset: page.isPortrait ? Theme.statusBarHeight : 0
|
// 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 {
|
EpubBook {
|
||||||
id: book
|
id: book
|
||||||
@@ -149,62 +152,33 @@ Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// contenuto: Flickable NON interattivo (lo scroll è solo programmatico).
|
// Motore di paginazione: l'item impagina il rich text del capitolo e
|
||||||
// La finestra di lettura VIVE DENTRO i margini verticali (top/bottom
|
// disegna la pagina corrente. I margini sono anchors: la finestra di
|
||||||
// margin): il testo non puo' fisicamente uscire dai bordi, quindi i
|
// lettura vive DENTRO i margini, quindi il testo non puo' uscirne.
|
||||||
// margini alto/basso sono identici su OGNI pagina (non solo a inizio
|
PagedTextItem {
|
||||||
// capitolo) e si aggiornano live con l'anteprima del dialog.
|
id: reader
|
||||||
Flickable {
|
|
||||||
id: innerFlick
|
|
||||||
anchors.fill: parent
|
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
|
anchors.topMargin: page.notchInset + (page.previewMarginT >= 0
|
||||||
? page.previewMarginT
|
? page.previewMarginT
|
||||||
: appSettings.readerMarginV)
|
: appSettings.readerMarginV)
|
||||||
anchors.bottomMargin: page.previewMarginT >= 0
|
anchors.bottomMargin: page.previewMarginT >= 0
|
||||||
? page.previewMarginT : appSettings.readerMarginV
|
? page.previewMarginT : appSettings.readerMarginV
|
||||||
contentHeight: readerContent.height
|
fontFamily: Theme.fontFamily
|
||||||
interactive: false
|
fontPixelSize: page.fontPx()
|
||||||
clip: true
|
textColor: Theme.primaryColor
|
||||||
|
// il reflow (font, geometria) mantiene la posizione internamente:
|
||||||
Item {
|
// qui si aggiornano solo label, cover e segnalibro
|
||||||
id: readerContent
|
onPageChanged: page.syncState()
|
||||||
width: parent.width
|
onLayoutChanged: page.syncState()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginazione a tap: la meta' destra va avanti di una pagina, la
|
// Paginazione a tap: la meta' destra va avanti di una pagina, la
|
||||||
// sinistra indietro. Le fasce superiore e inferiore sono lasciate ai
|
// sinistra indietro. Le fasce superiore e inferiore sono lasciate ai
|
||||||
// menu (PullDown in alto, PushUp in basso). NIENTE preventStealing:
|
// menu (PullDown in alto, PushUp in basso).
|
||||||
// il drag non ha effetti perche' il contenuto non e' scrollabile.
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: tapZone
|
id: tapZone
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -233,28 +207,28 @@ Page {
|
|||||||
onTriggered: pageStack.pop(null)
|
onTriggered: pageStack.pop(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rete di sicurezza per il restore: il caso normale è applicato da
|
// posizione di lettura nella metrica del capitolo (0..1), dal motore
|
||||||
// onTextReflowed appena il reflow cambia contentHeight; questo timer
|
function readerFrac() {
|
||||||
// copre i rari casi in cui l'altezza non cambia (capitoli identici).
|
var n = reader.characterCount
|
||||||
// Ritardo lungo: il reflow rich text può richiedere qualche centinaio di ms.
|
return n > 0 ? reader.position / n : 0.0
|
||||||
Timer {
|
|
||||||
id: restoreTimer
|
|
||||||
interval: 500
|
|
||||||
onTriggered: {
|
|
||||||
if (page.pendingRestore) {
|
|
||||||
page.pendingRestore = false
|
|
||||||
applyRestore()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracciamento periodico (aggiorna label e bookmark anche senza tap)
|
// converte un offset di caratteri (metrica capitolo) nella posizione del
|
||||||
Timer {
|
// motore: le due metriche differiscono di poco, la frazione le allinea
|
||||||
id: trackTimer
|
function posForOffset(off) {
|
||||||
interval: 3000
|
var len = book.chapterTextLength(page.currentChapter)
|
||||||
running: page.ready
|
var n = reader.characterCount
|
||||||
repeat: true
|
if (len <= 0 || n <= 0)
|
||||||
onTriggered: readScrollPosition()
|
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() {
|
function overallProgress() {
|
||||||
@@ -267,44 +241,13 @@ Page {
|
|||||||
return (start + page.charOffset) / total
|
return (start + page.charOffset) / total
|
||||||
}
|
}
|
||||||
|
|
||||||
// frazione di contenuto per un offset in caratteri (off >= len = fondo)
|
// aggiorna label, cover e segnalibro dopo un cambio pagina o un reflow
|
||||||
function offsetToFrac(off, chapterIndex) {
|
function syncState() {
|
||||||
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() {
|
|
||||||
if (!page.ready)
|
if (!page.ready)
|
||||||
return
|
return
|
||||||
// passo di pagina = altezza della finestra di lettura (i margini
|
page.pageIndex = reader.page + 1
|
||||||
// alto/basso sono FUORI dal Flickable: nessun testo li attraversa)
|
page.pageCount = reader.pageCount
|
||||||
var step = Math.max(1, innerFlick.height)
|
page.charOffset = offsetForPos()
|
||||||
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)
|
|
||||||
updateCoverState()
|
updateCoverState()
|
||||||
maybeSave(false)
|
maybeSave(false)
|
||||||
}
|
}
|
||||||
@@ -315,7 +258,7 @@ Page {
|
|||||||
// posizione assoluta nel libro = capitolo + caratteri del capitolo
|
// posizione assoluta nel libro = capitolo + caratteri del capitolo
|
||||||
var abs = book.chapterStartChar(page.currentChapter) + page.charOffset
|
var abs = book.chapterStartChar(page.currentChapter) + page.charOffset
|
||||||
// salva subito su forzatura, altrimenti solo se la posizione è cambiata
|
// 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) {
|
if (force || Math.abs(abs - page.lastSavedAbs) > 20) {
|
||||||
page.lastSavedAbs = abs
|
page.lastSavedAbs = abs
|
||||||
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
appSettings.setBookmarkKey(page.bookId, page.bookFormat,
|
||||||
@@ -328,71 +271,52 @@ Page {
|
|||||||
return
|
return
|
||||||
if (index !== page.currentChapter) {
|
if (index !== page.currentChapter) {
|
||||||
maybeSave(true)
|
maybeSave(true)
|
||||||
page.charOffset = 0
|
|
||||||
page.lastSavedAbs = -1
|
page.lastSavedAbs = -1
|
||||||
}
|
}
|
||||||
page.currentChapter = index
|
page.currentChapter = index
|
||||||
// il binding del Text si aggiorna da solo; restore all'inizio del capitolo
|
page.pendingChars = -1
|
||||||
page.pendingRestore = true
|
// carica il capitolo e vai all'inizio (posizione esplicita)
|
||||||
page.restoreFrac = page.restoreCharOffset >= 0
|
reader.html = book.chapterRichText(index)
|
||||||
? offsetToFrac(page.restoreCharOffset, page.currentChapter)
|
reader.goToPosition(0)
|
||||||
: 0.0
|
syncState()
|
||||||
page.restoreCharOffset = -1
|
|
||||||
restoreTimer.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeFontScale(delta) {
|
function changeFontScale(delta) {
|
||||||
// limite ampio e passo 0.1: tanti ingrandimenti prima del limite
|
// limite ampio e passo 0.1: tanti ingrandimenti prima del limite
|
||||||
page.fontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta))
|
appSettings.readerFontScale = Math.max(0.7, Math.min(3.5, page.fontScale + delta))
|
||||||
// persisti la scala: riparte da qui alla prossima apertura
|
// il binding aggiorna fontPixelSize: il motore si ri-impagina e
|
||||||
appSettings.readerFontScale = page.fontScale
|
// mantiene da solo la posizione per carattere
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagina successiva: una pagina di testo = altezza della finestra di
|
// pagina successiva: a fine capitolo passa al successivo
|
||||||
// lettura; a fine capitolo passa al successivo
|
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
if (!page.ready)
|
if (!page.ready)
|
||||||
return
|
return
|
||||||
var step = Math.max(1, innerFlick.height)
|
if (reader.page >= reader.pageCount - 1) {
|
||||||
var max = Math.max(0, innerFlick.contentHeight - innerFlick.height)
|
// fine capitolo: segna la coda e passa al capitolo dopo
|
||||||
var y = innerFlick.contentY
|
|
||||||
if (y >= max - 2) {
|
|
||||||
// fine capitolo (o capitolo non scrollabile): segna la coda e passa
|
|
||||||
page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
|
page.charOffset = Math.max(0, book.chapterTextLength(page.currentChapter) - 1)
|
||||||
maybeSave(true)
|
maybeSave(true)
|
||||||
gotoChapter(page.currentChapter + 1)
|
gotoChapter(page.currentChapter + 1)
|
||||||
} else {
|
} else {
|
||||||
innerFlick.contentY = Math.min(max, y + step)
|
reader.nextPage()
|
||||||
readScrollPosition()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pagina precedente: una pagina indietro; dall'inizio del capitolo va al
|
// pagina precedente: dall'inizio del capitolo va al capitolo prima
|
||||||
// capitolo prima SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
// SULL'ULTIMA PAGINA (non salta la coda del capitolo)
|
||||||
function prevPage() {
|
function prevPage() {
|
||||||
if (!page.ready)
|
if (!page.ready)
|
||||||
return
|
return
|
||||||
var step = Math.max(1, innerFlick.height)
|
if (reader.page > 0) {
|
||||||
var y = innerFlick.contentY
|
reader.previousPage()
|
||||||
if (y > 1) {
|
|
||||||
innerFlick.contentY = Math.max(0, y - step)
|
|
||||||
readScrollPosition()
|
|
||||||
} else if (page.currentChapter > 0) {
|
} else if (page.currentChapter > 0) {
|
||||||
maybeSave(true)
|
maybeSave(true)
|
||||||
page.currentChapter -= 1
|
var prev = page.currentChapter - 1
|
||||||
page.charOffset = 0
|
page.currentChapter = prev
|
||||||
page.lastSavedAbs = -1
|
reader.html = book.chapterRichText(prev)
|
||||||
// sentinella "fine capitolo": va oltre la lunghezza, così
|
// posizione esplicita: fondo del capitolo precedente
|
||||||
// offsetToFrac porta all'ultima pagina
|
reader.goToPosition(Math.max(0, reader.characterCount - 1))
|
||||||
page.pendingRestore = true
|
syncState()
|
||||||
page.restoreFrac = 1.0
|
|
||||||
restoreTimer.start()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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) {
|
function previewMargins(lr, tb) {
|
||||||
page.previewMarginL = lr
|
page.previewMarginL = lr
|
||||||
page.previewMarginT = tb
|
page.previewMarginT = tb
|
||||||
// il reflow è immediato (binding); mantieni la posizione per caratteri
|
}
|
||||||
page.restoreFrac = offsetToFrac(page.charOffset, page.currentChapter)
|
|
||||||
page.pendingRestore = true
|
// apre un capitolo e va alla posizione indicata (metrica capitolo)
|
||||||
restoreTimer.start()
|
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: {
|
Component.onCompleted: {
|
||||||
@@ -449,11 +382,7 @@ Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
page.ready = true
|
page.ready = true
|
||||||
page.currentChapter = ch
|
loadChapter(ch, off)
|
||||||
page.pendingRestore = true
|
|
||||||
page.restoreFrac = off >= 0 ? offsetToFrac(off, ch) : 0.0
|
|
||||||
updateCoverState()
|
|
||||||
restoreTimer.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onDestruction: readerState.clearReading()
|
Component.onDestruction: readerState.clearReading()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Name: harbour-calibreweb
|
Name: harbour-calibreweb
|
||||||
Summary: Client OPDS per Calibre Web
|
Summary: Client OPDS per Calibre Web
|
||||||
Version: 0.4.13
|
Version: 0.5.0
|
||||||
Release: 1
|
Release: 1
|
||||||
Group: Qt/Qt
|
Group: Qt/Qt
|
||||||
License: MIT
|
License: MIT
|
||||||
@@ -24,6 +24,14 @@ reflow del testo, margini configurabili e posizione salvata
|
|||||||
in locale.
|
in locale.
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Mon Sep 07 2026 Carlo Baratto <carlo@carlobaratto.it> - 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 <carlo@carlobaratto.it> - 0.4.13-1
|
* Mon Sep 07 2026 Carlo Baratto <carlo@carlobaratto.it> - 0.4.13-1
|
||||||
- Lettore: il testo non finisce piu' sotto il notch del display (Jolla Phone
|
- 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
|
2026): in portrait la finestra di lettura parte piu' in basso di un'altezza
|
||||||
|
|||||||
+4
-2
@@ -10,6 +10,7 @@
|
|||||||
#include "apiclient.h"
|
#include "apiclient.h"
|
||||||
#include "downloader.h"
|
#include "downloader.h"
|
||||||
#include "epub.h"
|
#include "epub.h"
|
||||||
|
#include "pagedtextitem.h"
|
||||||
#include "readerstate.h"
|
#include "readerstate.h"
|
||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
|
|||||||
app->setOrganizationName(QStringLiteral("harbour"));
|
app->setOrganizationName(QStringLiteral("harbour"));
|
||||||
app->setApplicationName(QStringLiteral("calibreweb"));
|
app->setApplicationName(QStringLiteral("calibreweb"));
|
||||||
// versione mostrata nelle impostazioni (tenere allineata allo spec)
|
// versione mostrata nelle impostazioni (tenere allineata allo spec)
|
||||||
app->setApplicationVersion(QStringLiteral("0.4.13"));
|
app->setApplicationVersion(QStringLiteral("0.5.0"));
|
||||||
|
|
||||||
Settings settings;
|
Settings settings;
|
||||||
|
|
||||||
@@ -57,9 +58,10 @@ Q_DECL_EXPORT int main(int argc, char *argv[])
|
|||||||
|
|
||||||
QQuickView *view = SailfishApp::createView();
|
QQuickView *view = SailfishApp::createView();
|
||||||
|
|
||||||
qDebug() << "harbour-calibreweb v0.4.13 build" << __DATE__ << __TIME__;
|
qDebug() << "harbour-calibreweb v0.5.0 build" << __DATE__ << __TIME__;
|
||||||
|
|
||||||
qmlRegisterType<EpubBook>("harbour.calibreweb", 1, 0, "EpubBook");
|
qmlRegisterType<EpubBook>("harbour.calibreweb", 1, 0, "EpubBook");
|
||||||
|
qmlRegisterType<PagedTextItem>("harbour.calibreweb", 1, 0, "PagedTextItem");
|
||||||
|
|
||||||
ApiClient apiClient(&settings);
|
ApiClient apiClient(&settings);
|
||||||
Downloader downloader(&settings);
|
Downloader downloader(&settings);
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
#include "pagedtextitem.h"
|
||||||
|
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QAbstractTextDocumentLayout>
|
||||||
|
#include <QTextBlock>
|
||||||
|
#include <QTextLayout>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QRectF>
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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 <QQuickPaintedItem>
|
||||||
|
#include <QTextDocument>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -138,27 +138,27 @@
|
|||||||
<context>
|
<context>
|
||||||
<name>ReaderPage</name>
|
<name>ReaderPage</name>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../qml/pages/ReaderPage.qml" line="58"/>
|
<location filename="../qml/pages/ReaderPage.qml" line="68"/>
|
||||||
<source>Table of contents</source>
|
<source>Table of contents</source>
|
||||||
<translation>Indice capitoli</translation>
|
<translation>Indice capitoli</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../qml/pages/ReaderPage.qml" line="62"/>
|
<location filename="../qml/pages/ReaderPage.qml" line="72"/>
|
||||||
<source>Text margins</source>
|
<source>Text margins</source>
|
||||||
<translation>Margini del testo</translation>
|
<translation>Margini del testo</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../qml/pages/ReaderPage.qml" line="77"/>
|
<location filename="../qml/pages/ReaderPage.qml" line="87"/>
|
||||||
<source>Settings</source>
|
<source>Settings</source>
|
||||||
<translation>Impostazioni</translation>
|
<translation>Impostazioni</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../qml/pages/ReaderPage.qml" line="81"/>
|
<location filename="../qml/pages/ReaderPage.qml" line="91"/>
|
||||||
<source>Close book</source>
|
<source>Close book</source>
|
||||||
<translation>Chiudi libro</translation>
|
<translation>Chiudi libro</translation>
|
||||||
</message>
|
</message>
|
||||||
<message>
|
<message>
|
||||||
<location filename="../qml/pages/ReaderPage.qml" line="427"/>
|
<location filename="../qml/pages/ReaderPage.qml" line="368"/>
|
||||||
<source>Unable to open the EPUB file</source>
|
<source>Unable to open the EPUB file</source>
|
||||||
<translation>Impossibile aprire il libro EPUB</translation>
|
<translation>Impossibile aprire il libro EPUB</translation>
|
||||||
</message>
|
</message>
|
||||||
|
|||||||
Reference in New Issue
Block a user