Parser EPUB in C++: ZIP minimale (STORE/DEFLATE via zlib), container/OPF/spine, TOC EPUB2+3, chiavi segno — testato su EPUB di esempio
This commit is contained in:
@@ -9,13 +9,17 @@ SOURCES += \
|
|||||||
src/settings.cpp \
|
src/settings.cpp \
|
||||||
src/apiclient.cpp \
|
src/apiclient.cpp \
|
||||||
src/downloader.cpp \
|
src/downloader.cpp \
|
||||||
src/opdsparser.cpp
|
src/opdsparser.cpp \
|
||||||
|
src/epub.cpp
|
||||||
|
|
||||||
HEADERS += \
|
HEADERS += \
|
||||||
src/settings.h \
|
src/settings.h \
|
||||||
src/apiclient.h \
|
src/apiclient.h \
|
||||||
src/downloader.h \
|
src/downloader.h \
|
||||||
src/opdsparser.h
|
src/opdsparser.h \
|
||||||
|
src/epub.h
|
||||||
|
|
||||||
|
LIBS += -lz
|
||||||
|
|
||||||
OTHER_FILES += \
|
OTHER_FILES += \
|
||||||
qml/harbour-calibreweb.qml \
|
qml/harbour-calibreweb.qml \
|
||||||
|
|||||||
+421
@@ -0,0 +1,421 @@
|
|||||||
|
#include "epub.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QStringList>
|
||||||
|
#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
|
||||||
|
}
|
||||||
|
|
||||||
|
QString readTextFile(const QString &path)
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return QString();
|
||||||
|
return QString::fromUtf8(f.readAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EpubBook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
EpubBook::EpubBook()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
EpubBook::~EpubBook()
|
||||||
|
{
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EpubBook::close()
|
||||||
|
{
|
||||||
|
if (!m_baseDir.isEmpty()) {
|
||||||
|
QDir(m_baseDir).removeRecursively();
|
||||||
|
m_baseDir.clear();
|
||||||
|
}
|
||||||
|
m_chapters.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;
|
||||||
|
return !m_chapters.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString EpubBook::makeBookmarkKey(int chapterIndex, double fraction) const
|
||||||
|
{
|
||||||
|
return QStringLiteral("cw:v1:%1:%2").arg(chapterIndex).arg(fraction, 0, 'f', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EpubBook::parseBookmarkKey(const QString &key, int *chapterIndex, double *fraction) const
|
||||||
|
{
|
||||||
|
const QStringList parts = key.split(QLatin1Char(':'));
|
||||||
|
if (parts.size() != 4 || parts.at(0) != QLatin1String("cw")
|
||||||
|
|| parts.at(1) != QLatin1String("v1"))
|
||||||
|
return false;
|
||||||
|
bool okChapter = false, okFrac = false;
|
||||||
|
const int ch = parts.at(2).toInt(&okChapter);
|
||||||
|
const double fr = parts.at(3).toDouble(&okFrac);
|
||||||
|
if (!okChapter || !okFrac)
|
||||||
|
return false;
|
||||||
|
if (chapterIndex)
|
||||||
|
*chapterIndex = ch;
|
||||||
|
if (fraction)
|
||||||
|
*fraction = fr;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#ifndef EPUB_H
|
||||||
|
#define EPUB_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
struct EpubChapter
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString path; // path assoluto del file HTML estratto
|
||||||
|
QString title;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EpubBook
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
EpubBook();
|
||||||
|
~EpubBook();
|
||||||
|
|
||||||
|
bool open(const QString &epubPath);
|
||||||
|
void close();
|
||||||
|
|
||||||
|
QString title() const { return m_title; }
|
||||||
|
QString author() const { return m_author; }
|
||||||
|
QList<EpubChapter> chapters() const { return m_chapters; }
|
||||||
|
QString baseDir() const { return m_baseDir; }
|
||||||
|
int chapterCount() const { return m_chapters.size(); }
|
||||||
|
|
||||||
|
QString chapterFilePath(int index) const;
|
||||||
|
QString chapterTitle(int index) const;
|
||||||
|
|
||||||
|
// chiave segno locale, es. "cw:v1:3:0.42" (capitolo 3, 42% del capitolo)
|
||||||
|
QString makeBookmarkKey(int chapterIndex, double fraction) const;
|
||||||
|
bool parseBookmarkKey(const QString &key, int *chapterIndex, double *fraction) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool extractZip(const QString &epubPath);
|
||||||
|
bool parseContainer();
|
||||||
|
bool parseOpf();
|
||||||
|
void parseTocEpub3(const QString &navPath);
|
||||||
|
void parseTocEpub2(const QString &ncxPath);
|
||||||
|
QString absolutePath(const QString &relativeToOpfDir) const;
|
||||||
|
|
||||||
|
QString m_baseDir;
|
||||||
|
QString m_opfDir;
|
||||||
|
QString m_title;
|
||||||
|
QString m_author;
|
||||||
|
QList<EpubChapter> m_chapters;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // EPUB_H
|
||||||
Reference in New Issue
Block a user