857 lines
31 KiB
C++
857 lines
31 KiB
C++
#include "epub.h"
|
|
|
|
#include <QDir>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QHash>
|
|
#include <QStringList>
|
|
#include <QUrl>
|
|
#include <QXmlStreamReader>
|
|
|
|
#include <zlib.h>
|
|
#include <cstring>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lettore ZIP minimale (solo STORE e DEFLATE, niente crittografia)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
namespace {
|
|
|
|
struct ZipEntry
|
|
{
|
|
QString name;
|
|
quint16 method;
|
|
quint32 compSize;
|
|
quint32 uncompSize;
|
|
quint32 localOffset;
|
|
};
|
|
|
|
quint16 le16(const QByteArray &d, int off)
|
|
{
|
|
return (quint8)d.at(off) | ((quint8)d.at(off + 1) << 8);
|
|
}
|
|
|
|
quint32 le32(const QByteArray &d, int off)
|
|
{
|
|
return (quint32)(quint8)d.at(off)
|
|
| ((quint32)(quint8)d.at(off + 1) << 8)
|
|
| ((quint32)(quint8)d.at(off + 2) << 16)
|
|
| ((quint32)(quint8)d.at(off + 3) << 24);
|
|
}
|
|
|
|
bool readZipEntries(const QByteArray &data, QList<ZipEntry> *entries)
|
|
{
|
|
// EOCD: cerca la firma PK\x05\x06 negli ultimi 64 KB + commento
|
|
const int eocdMax = qMin(data.size(), 65557);
|
|
int eocd = -1;
|
|
for (int i = data.size() - eocdMax; i <= data.size() - 22; ++i) {
|
|
if (data.at(i) == 'P' && data.at(i + 1) == 'K'
|
|
&& data.at(i + 2) == '\x05' && data.at(i + 3) == '\x06') {
|
|
eocd = i;
|
|
break;
|
|
}
|
|
}
|
|
if (eocd < 0)
|
|
return false;
|
|
|
|
const quint32 cdOffset = le32(data, eocd + 16);
|
|
const quint16 cdCount = le16(data, eocd + 10);
|
|
|
|
int off = cdOffset;
|
|
for (int i = 0; i < cdCount; ++i) {
|
|
if (off + 46 > data.size()
|
|
|| data.at(off) != 'P' || data.at(off + 1) != 'K'
|
|
|| data.at(off + 2) != '\x01' || data.at(off + 3) != '\x02')
|
|
return false;
|
|
ZipEntry e;
|
|
e.method = le16(data, off + 10);
|
|
e.compSize = le32(data, off + 20);
|
|
e.uncompSize = le32(data, off + 24);
|
|
const quint16 nameLen = le16(data, off + 28);
|
|
const quint16 extraLen = le16(data, off + 30);
|
|
const quint16 commentLen = le16(data, off + 32);
|
|
e.localOffset = le32(data, off + 42);
|
|
e.name = QString::fromUtf8(data.mid(off + 46, nameLen));
|
|
entries->append(e);
|
|
off += 46 + nameLen + extraLen + commentLen;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
QByteArray extractEntryData(const QByteArray &data, const ZipEntry &e)
|
|
{
|
|
if ((int)e.localOffset + 30 > data.size())
|
|
return QByteArray();
|
|
const int lo = e.localOffset;
|
|
if (data.at(lo) != 'P' || data.at(lo + 1) != 'K'
|
|
|| data.at(lo + 2) != '\x03' || data.at(lo + 3) != '\x04')
|
|
return QByteArray();
|
|
const quint16 nameLen = le16(data, lo + 26);
|
|
const quint16 extraLen = le16(data, lo + 28);
|
|
const int dataOff = lo + 30 + nameLen + extraLen;
|
|
if (dataOff + (int)e.compSize > data.size())
|
|
return QByteArray();
|
|
const QByteArray raw = data.mid(dataOff, e.compSize);
|
|
|
|
if (e.method == 0) // STORE
|
|
return raw;
|
|
|
|
if (e.method == 8) { // DEFLATE (raw, senza header zlib)
|
|
QByteArray out;
|
|
out.resize(e.uncompSize);
|
|
z_stream zs;
|
|
std::memset(&zs, 0, sizeof(zs));
|
|
if (inflateInit2(&zs, -15) != Z_OK)
|
|
return QByteArray();
|
|
zs.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(raw.constData()));
|
|
zs.avail_in = raw.size();
|
|
zs.next_out = reinterpret_cast<Bytef *>(out.data());
|
|
zs.avail_out = out.size();
|
|
const int r = inflate(&zs, Z_FINISH);
|
|
inflateEnd(&zs);
|
|
if (r != Z_STREAM_END)
|
|
return QByteArray();
|
|
out.resize(zs.total_out);
|
|
return out;
|
|
}
|
|
return QByteArray(); // metodo non supportato
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lunghezza del testo VISIBILE di un capitolo (criterio DOM/JS):
|
|
// tag rimossi, entità decodificate (1 carattere), head/script/style/noscript/
|
|
// title esclusi, spazi e newline contati come nel text node del browser.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
int plainTextLength(const QString &html)
|
|
{
|
|
// conta solo il testo dentro <body> (come il walker JS che parte da
|
|
// document.body): l'header XML/DOCTYPE e il <head> non contano
|
|
const int bodyStart = html.indexOf(QStringLiteral("<body"), 0, Qt::CaseInsensitive);
|
|
const int bodyEnd = html.indexOf(QStringLiteral("</body"), 0, Qt::CaseInsensitive);
|
|
const QString scope = (bodyStart >= 0)
|
|
? html.mid(bodyStart, (bodyEnd >= bodyStart ? bodyEnd : html.size()) - bodyStart)
|
|
: html;
|
|
|
|
int count = 0;
|
|
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);
|
|
if (close < 0) {
|
|
++i;
|
|
continue;
|
|
}
|
|
QString tag = scope.mid(i + 1, close - i - 1).trimmed().toLower();
|
|
if (tag.startsWith(QLatin1String("!--"))) {
|
|
const int end = scope.indexOf(QLatin1String("-->"), close);
|
|
i = (end < 0) ? n : end + 3;
|
|
continue;
|
|
}
|
|
if (tag.startsWith(QLatin1Char('/')))
|
|
tag = tag.mid(1);
|
|
int sp = 0;
|
|
while (sp < tag.size() && tag.at(sp).isLetterOrNumber())
|
|
++sp;
|
|
const QString name = tag.left(sp);
|
|
if (name == QLatin1String("script") || name == QLatin1String("style")
|
|
|| name == QLatin1String("noscript") || name == QLatin1String("head")
|
|
|| name == QLatin1String("title")) {
|
|
// salta tutto il contenuto (e la chiusura) del blocco raw
|
|
const QString closeTag = QStringLiteral("</") + name;
|
|
const int end = scope.indexOf(closeTag, close, Qt::CaseInsensitive);
|
|
if (end < 0) {
|
|
i = n;
|
|
continue;
|
|
}
|
|
const int gt = scope.indexOf(QLatin1Char('>'), end);
|
|
i = (gt < 0) ? n : gt + 1;
|
|
continue;
|
|
}
|
|
i = 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") || ent == QLatin1String("lt")
|
|
|| ent == QLatin1String("gt") || ent == QLatin1String("quot")
|
|
|| ent == QLatin1String("apos") || ent == QLatin1String("nbsp")
|
|
|| ent.startsWith(QLatin1String("#"))) {
|
|
++count;
|
|
i = semi + 1;
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
++count;
|
|
++i;
|
|
}
|
|
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<QString, QChar> entities = [] {
|
|
QHash<QString, QChar> 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("<body"), 0, Qt::CaseInsensitive);
|
|
const int bodyEnd = html.indexOf(QStringLiteral("</body"), 0, Qt::CaseInsensitive);
|
|
QString scope;
|
|
if (bodyStart >= 0) {
|
|
// includi anche la chiusura </body>: 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("<br>");
|
|
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("</") + (sp > 0 ? tag.left(sp) : tag) + QLatin1Char('>');
|
|
}
|
|
}
|
|
} else if (t == QXmlStreamReader::Characters) {
|
|
out += r.text();
|
|
}
|
|
}
|
|
|
|
// HTML non ben formato (parse fallito): fallback grezzo — testo con <br>
|
|
// 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("</p><p>");
|
|
++i;
|
|
} else {
|
|
out += QStringLiteral("<br>");
|
|
}
|
|
++i;
|
|
continue;
|
|
}
|
|
out += c;
|
|
++i;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EpubBook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
EpubBook::EpubBook(QObject *parent)
|
|
: QObject(parent)
|
|
{
|
|
}
|
|
|
|
EpubBook::~EpubBook()
|
|
{
|
|
close();
|
|
}
|
|
|
|
void EpubBook::close()
|
|
{
|
|
if (!m_baseDir.isEmpty()) {
|
|
QDir(m_baseDir).removeRecursively();
|
|
m_baseDir.clear();
|
|
}
|
|
m_chapters.clear();
|
|
m_textLengths.clear();
|
|
m_title.clear();
|
|
m_author.clear();
|
|
}
|
|
|
|
bool EpubBook::open(const QString &epubPath)
|
|
{
|
|
close();
|
|
m_opfDir.clear();
|
|
|
|
QFile f(epubPath);
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return false;
|
|
const QByteArray data = f.readAll();
|
|
f.close();
|
|
|
|
QList<ZipEntry> entries;
|
|
if (!readZipEntries(data, &entries))
|
|
return false;
|
|
|
|
m_baseDir = QDir::tempPath() + QStringLiteral("/harbour-calibreweb/books/")
|
|
+ QString::number(qHash(epubPath), 16);
|
|
if (!QDir().mkpath(m_baseDir))
|
|
return false;
|
|
|
|
// estrai tutto (skip directory e path traversal)
|
|
for (const ZipEntry &e : entries) {
|
|
if (e.name.endsWith(QLatin1Char('/')) || e.name.contains(QStringLiteral("..")))
|
|
continue;
|
|
const QByteArray content = extractEntryData(data, e);
|
|
if (content.isNull())
|
|
continue;
|
|
const QString dest = m_baseDir + QLatin1Char('/') + e.name;
|
|
QDir().mkpath(QFileInfo(dest).absolutePath());
|
|
QFile out(dest);
|
|
if (out.open(QIODevice::WriteOnly)) {
|
|
out.write(content);
|
|
out.close();
|
|
}
|
|
}
|
|
|
|
if (!parseContainer())
|
|
return false;
|
|
if (!parseOpf())
|
|
return false;
|
|
const bool ok = !m_chapters.isEmpty();
|
|
if (ok) {
|
|
// lunghezze testo LAZY: -1 = da calcolare al primo accesso. L'apertura
|
|
// resta veloce anche per libri grossi (niente parse di tutti i capitoli).
|
|
m_textLengths.clear();
|
|
m_textLengths.reserve(m_chapters.size());
|
|
for (int i = 0; i < m_chapters.size(); ++i)
|
|
m_textLengths.append(-1);
|
|
}
|
|
emit changed();
|
|
return ok;
|
|
}
|
|
|
|
bool EpubBook::parseContainer()
|
|
{
|
|
QFile f(m_baseDir + QStringLiteral("/META-INF/container.xml"));
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return false;
|
|
QXmlStreamReader r(&f);
|
|
while (!r.atEnd() && !r.hasError()) {
|
|
if (r.readNextStartElement() && r.name() == QLatin1String("rootfile")) {
|
|
const QString fullPath = r.attributes().value(QLatin1String("full-path")).toString();
|
|
if (!fullPath.isEmpty()) {
|
|
// path RELATIVO alla root epub, non assoluto rispetto alla CWD
|
|
m_opfDir = QFileInfo(fullPath).path();
|
|
if (m_opfDir == QStringLiteral("."))
|
|
m_opfDir.clear();
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
QString EpubBook::absolutePath(const QString &relativeToOpfDir) const
|
|
{
|
|
QString rel = relativeToOpfDir;
|
|
rel.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
|
if (rel.contains(QLatin1String("..")))
|
|
return QString();
|
|
if (!m_opfDir.isEmpty())
|
|
rel = m_opfDir + QLatin1Char('/') + rel;
|
|
return QDir(m_baseDir).filePath(rel);
|
|
}
|
|
|
|
bool EpubBook::parseOpf()
|
|
{
|
|
QFile f(absolutePath(QStringLiteral("content.opf")));
|
|
if (!f.exists())
|
|
f.setFileName(absolutePath(QStringLiteral("package.opf")));
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return false;
|
|
|
|
QXmlStreamReader r(&f);
|
|
|
|
// manifest: id -> {href, media-type, properties}
|
|
QHash<QString, QString> manifestHref;
|
|
QHash<QString, QString> manifestType;
|
|
QHash<QString, QString> manifestProps;
|
|
// spine: idrefs in ordine
|
|
QStringList spine;
|
|
QString navItemId;
|
|
QString ncxItemId;
|
|
|
|
while (!r.atEnd() && !r.hasError()) {
|
|
const QXmlStreamReader::TokenType t = r.readNext();
|
|
if (t != QXmlStreamReader::StartElement)
|
|
continue;
|
|
const QStringRef name = r.name();
|
|
const QXmlStreamAttributes a = r.attributes();
|
|
|
|
if (name == QLatin1String("title") && m_title.isEmpty()) {
|
|
m_title = r.readElementText().trimmed();
|
|
} else if (name == QLatin1String("creator") && m_author.isEmpty()) {
|
|
m_author = r.readElementText().trimmed();
|
|
} else if (name == QLatin1String("item")) {
|
|
const QString id = a.value(QLatin1String("id")).toString();
|
|
manifestHref.insert(id, a.value(QLatin1String("href")).toString());
|
|
manifestType.insert(id, a.value(QLatin1String("media-type")).toString());
|
|
const QString props = a.value(QLatin1String("properties")).toString();
|
|
manifestProps.insert(id, props);
|
|
if (props.contains(QLatin1String("nav")))
|
|
navItemId = id;
|
|
if (a.value(QLatin1String("media-type")).toString()
|
|
== QLatin1String("application/x-dtbncx+xml"))
|
|
ncxItemId = id;
|
|
} else if (name == QLatin1String("itemref")) {
|
|
spine.append(a.value(QLatin1String("idref")).toString());
|
|
}
|
|
}
|
|
|
|
if (spine.isEmpty() || manifestHref.isEmpty())
|
|
return false;
|
|
|
|
// TOC: EPUB3 nav oppure EPUB2 NCX
|
|
if (!navItemId.isEmpty())
|
|
parseTocEpub3(absolutePath(manifestHref.value(navItemId)));
|
|
else if (!ncxItemId.isEmpty())
|
|
parseTocEpub2(absolutePath(manifestHref.value(ncxItemId)));
|
|
|
|
// mappa path assoluto -> titolo dal TOC
|
|
QHash<QString, QString> titleByPath;
|
|
for (const EpubChapter &c : m_chapters)
|
|
titleByPath.insert(c.path, c.title);
|
|
m_chapters.clear();
|
|
|
|
// costruisci i capitoli nell'ordine dello spine
|
|
int n = 0;
|
|
for (const QString &idref : spine) {
|
|
const QString href = manifestHref.value(idref);
|
|
if (href.isEmpty())
|
|
continue;
|
|
const QString abs = absolutePath(href);
|
|
if (abs.isEmpty() || !QFile::exists(abs))
|
|
continue;
|
|
EpubChapter c;
|
|
c.id = idref;
|
|
c.path = abs;
|
|
c.title = titleByPath.value(abs);
|
|
if (c.title.isEmpty())
|
|
c.title = QStringLiteral("Capitolo ") + QString::number(n + 1);
|
|
m_chapters.append(c);
|
|
++n;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void EpubBook::parseTocEpub3(const QString &navPath)
|
|
{
|
|
QFile f(navPath);
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return;
|
|
QXmlStreamReader r(&f);
|
|
bool inTocNav = false;
|
|
int depth = 0;
|
|
while (!r.atEnd() && !r.hasError()) {
|
|
const QXmlStreamReader::TokenType t = r.readNext();
|
|
if (t == QXmlStreamReader::StartElement) {
|
|
const QStringRef name = r.name();
|
|
if (name == QLatin1String("nav")) {
|
|
if (!inTocNav) {
|
|
const QString type = r.attributes()
|
|
.value(QLatin1String("http://www.idpf.org/2007/ops"),
|
|
QLatin1String("type")).toString();
|
|
if (type != QLatin1String("toc"))
|
|
continue;
|
|
inTocNav = true;
|
|
}
|
|
++depth;
|
|
continue;
|
|
}
|
|
if (!inTocNav || depth == 0)
|
|
continue;
|
|
if (name == QLatin1String("a")) {
|
|
const QString href = r.attributes().value(QLatin1String("href")).toString();
|
|
const QString text = r.readElementText().trimmed();
|
|
if (!href.isEmpty() && !text.isEmpty()) {
|
|
EpubChapter c;
|
|
c.path = absolutePath(href);
|
|
c.title = text;
|
|
m_chapters.append(c);
|
|
}
|
|
}
|
|
} else if (t == QXmlStreamReader::EndElement && r.name() == QLatin1String("nav")) {
|
|
if (inTocNav && --depth <= 0)
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
void EpubBook::parseTocEpub2(const QString &ncxPath)
|
|
{
|
|
QFile f(ncxPath);
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return;
|
|
QXmlStreamReader r(&f);
|
|
EpubChapter pending;
|
|
bool havePending = false;
|
|
while (!r.atEnd() && !r.hasError()) {
|
|
const QXmlStreamReader::TokenType t = r.readNext();
|
|
if (t == QXmlStreamReader::StartElement) {
|
|
const QStringRef name = r.name();
|
|
if (name == QLatin1String("navPoint")) {
|
|
// nel NCX il <text> (titolo) viene PRIMA del <content> (src):
|
|
// accumula in pending e chiudi alla fine del navPoint
|
|
pending = EpubChapter();
|
|
havePending = true;
|
|
} else if (name == QLatin1String("text")) {
|
|
if (havePending)
|
|
pending.title = r.readElementText().trimmed();
|
|
} else if (name == QLatin1String("content")) {
|
|
const QString src = r.attributes().value(QLatin1String("src")).toString();
|
|
if (havePending)
|
|
pending.path = absolutePath(src);
|
|
}
|
|
} else if (t == QXmlStreamReader::EndElement
|
|
&& r.name() == QLatin1String("navPoint")) {
|
|
if (havePending && !pending.path.isEmpty())
|
|
m_chapters.append(pending);
|
|
havePending = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
QString EpubBook::chapterFilePath(int index) const
|
|
{
|
|
if (index < 0 || index >= m_chapters.size())
|
|
return QString();
|
|
return m_chapters.at(index).path;
|
|
}
|
|
|
|
QString EpubBook::chapterTitle(int index) const
|
|
{
|
|
if (index < 0 || index >= m_chapters.size())
|
|
return QString();
|
|
return m_chapters.at(index).title;
|
|
}
|
|
|
|
QVariantList EpubBook::chapterList() const
|
|
{
|
|
QVariantList list;
|
|
for (int i = 0; i < m_chapters.size(); ++i) {
|
|
QVariantMap m;
|
|
m.insert(QStringLiteral("index"), i);
|
|
m.insert(QStringLiteral("title"), m_chapters.at(i).title);
|
|
list.append(m);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
QString EpubBook::chapterHtml(int index, int fontSizePx, int marginPx, int marginVerticalPx) const
|
|
{
|
|
if (index < 0 || index >= m_chapters.size())
|
|
return QString();
|
|
QFile f(m_chapters.at(index).path);
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return QString();
|
|
QString html = QString::fromUtf8(f.readAll());
|
|
|
|
// stile di reflow iniettato nel documento: !important vince sui CSS del libro.
|
|
// I margini si possono aggiornare a caldo via runJavaScript (setProperty con
|
|
// 'important' inline), senza ricaricare il capitolo.
|
|
// height:auto + overflow:visible forzano lo scroll anche quando il CSS del
|
|
// libro imposta height:100%/overflow:hidden (senza, il documento non scorre
|
|
// e ogni tap salta al capitolo successivo).
|
|
const QString style = QStringLiteral(
|
|
"<style id=\"cw-font\">html,body{font-size:%1px!important;"
|
|
"height:auto!important;overflow:visible!important;"
|
|
"margin-left:%2px!important;margin-right:%2px!important;"
|
|
"margin-top:%3px!important;margin-bottom:%3px!important}"
|
|
"p,div,li,td,blockquote,section{font-size:1em!important}</style>")
|
|
.arg(fontSizePx).arg(marginPx).arg(marginVerticalPx);
|
|
const int headEnd = html.indexOf(QStringLiteral("</head>"), 0, Qt::CaseInsensitive);
|
|
if (headEnd >= 0)
|
|
html.insert(headEnd, style);
|
|
else
|
|
html.prepend(style);
|
|
return html;
|
|
}
|
|
|
|
QString EpubBook::chapterBaseUrl(int index) const
|
|
{
|
|
if (index < 0 || index >= m_chapters.size())
|
|
return QString();
|
|
return QUrl::fromLocalFile(
|
|
QFileInfo(m_chapters.at(index).path).absolutePath() + QStringLiteral("/"))
|
|
.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);
|
|
}
|
|
|
|
// calcola (e cache) la lunghezza del capitolo index se non ancora nota
|
|
void EpubBook::ensureTextLength(int index) const
|
|
{
|
|
if (index < 0 || index >= m_textLengths.size() || m_textLengths.at(index) >= 0)
|
|
return;
|
|
const EpubChapter &c = m_chapters.at(index);
|
|
QFile f(c.path);
|
|
if (!f.open(QIODevice::ReadOnly)) {
|
|
m_textLengths[index] = 0;
|
|
return;
|
|
}
|
|
const QString html = QString::fromUtf8(f.readAll());
|
|
m_textLengths[index] = plainTextLength(html);
|
|
}
|
|
|
|
int EpubBook::chapterTextLength(int index) const
|
|
{
|
|
if (index < 0 || index >= m_textLengths.size())
|
|
return 0;
|
|
ensureTextLength(index);
|
|
return m_textLengths.at(index);
|
|
}
|
|
|
|
qint64 EpubBook::chapterStartChar(int index) const
|
|
{
|
|
qint64 start = 0;
|
|
const int end = qMin(index, m_textLengths.size());
|
|
for (int i = 0; i < end; ++i) {
|
|
ensureTextLength(i);
|
|
start += m_textLengths.at(i);
|
|
}
|
|
return start;
|
|
}
|
|
|
|
QString EpubBook::makeBookmarkKey(int chapterIndex, qint64 charOffset) const
|
|
{
|
|
const qint64 abs = chapterStartChar(chapterIndex) + qMax<qint64>(0, charOffset);
|
|
return QStringLiteral("cw:v2:%1").arg(abs);
|
|
}
|
|
|
|
QVariantMap EpubBook::parseBookmarkKey(const QString &key) const
|
|
{
|
|
QVariantMap result;
|
|
const QStringList parts = key.split(QLatin1Char(':'));
|
|
if (parts.size() < 3 || parts.at(0) != QLatin1String("cw"))
|
|
return result;
|
|
|
|
if (parts.at(1) == QLatin1String("v2")) {
|
|
bool ok = false;
|
|
const qint64 abs = parts.at(2).toLongLong(&ok);
|
|
if (!ok || abs < 0)
|
|
return result;
|
|
// trova il capitolo che contiene il carattere assoluto
|
|
int ch = 0;
|
|
qint64 start = 0;
|
|
for (int i = 0; i < m_textLengths.size(); ++i) {
|
|
if (start + m_textLengths.at(i) > abs) {
|
|
ch = i;
|
|
break;
|
|
}
|
|
start += m_textLengths.at(i);
|
|
ch = i + 1;
|
|
}
|
|
if (ch >= m_chapters.size())
|
|
ch = qMax(0, m_chapters.size() - 1);
|
|
result.insert(QStringLiteral("chapter"), ch);
|
|
result.insert(QStringLiteral("charOffset"),
|
|
qMax<qint64>(0, abs - chapterStartChar(ch)));
|
|
return result;
|
|
}
|
|
|
|
// v1 (vecchie chiavi, migrazione): "cw:v1:<cap>:<frac>"
|
|
if (parts.at(1) == QLatin1String("v1") && parts.size() == 4) {
|
|
bool okC = false, okF = false;
|
|
const int ch = parts.at(2).toInt(&okC);
|
|
const double fr = parts.at(3).toDouble(&okF);
|
|
if (!okC || !okF)
|
|
return result;
|
|
result.insert(QStringLiteral("chapter"), qMax(0, ch));
|
|
result.insert(QStringLiteral("charOffset"),
|
|
qRound64(fr * chapterTextLength(ch)));
|
|
return result;
|
|
}
|
|
return result;
|
|
}
|