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

"); + ++i; + } else { + out += QStringLiteral("
"); + } + ++i; + continue; + } + out += c; + ++i; + } + } + return out; +} + } // namespace // --------------------------------------------------------------------------- @@ -524,6 +752,17 @@ QString EpubBook::chapterBaseUrl(int index) const .toString(); } +QString EpubBook::chapterRichText(int index) const +{ + if (index < 0 || index >= m_chapters.size()) + return QString(); + QFile f(m_chapters.at(index).path); + if (!f.open(QIODevice::ReadOnly)) + return QString(); + const QString html = QString::fromUtf8(f.readAll()); + return htmlToRichText(html); +} + void EpubBook::computeTextLengths() { m_textLengths.clear(); diff --git a/src/epub.h b/src/epub.h index 0565fd2..fef98c9 100644 --- a/src/epub.h +++ b/src/epub.h @@ -45,6 +45,9 @@ public: Q_INVOKABLE QString chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const; // base URL (file://) per risolvere immagini/CSS relativi del capitolo Q_INVOKABLE QString chapterBaseUrl(int index) const; + // rich text Qt del capitolo (per Text QML: niente WebView, scroll affidabile). + // Converte i tag HTML di base, salta script/style/head/immagini. + Q_INVOKABLE QString chapterRichText(int index) const; // chiave segno locale v2, es. "cw:v2:12345" (posizione = carattere assoluto // nel libro, indipendente da font, margini e reflow) diff --git a/src/main.cpp b/src/main.cpp index 18213c2..385e824 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -22,7 +22,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) app->setOrganizationName(QStringLiteral("harbour")); app->setApplicationName(QStringLiteral("calibreweb")); // versione mostrata nelle impostazioni (tenere allineata allo spec) - app->setApplicationVersion(QStringLiteral("0.3.4")); + app->setApplicationVersion(QStringLiteral("0.4.0")); Settings settings; @@ -56,7 +56,7 @@ Q_DECL_EXPORT int main(int argc, char *argv[]) QQuickView *view = SailfishApp::createView(); - qDebug() << "harbour-calibreweb v0.3.4 build" << __DATE__ << __TIME__; + qDebug() << "harbour-calibreweb v0.4.0 build" << __DATE__ << __TIME__; qmlRegisterType("harbour.calibreweb", 1, 0, "EpubBook");