From ae15c39421e5a51bf89294eefa75f220a1a72e15 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Wed, 12 Aug 2026 14:18:40 +0200 Subject: [PATCH] =?UTF-8?q?Parser=20EPUB=20in=20C++:=20ZIP=20minimale=20(S?= =?UTF-8?q?TORE/DEFLATE=20via=20zlib),=20container/OPF/spine,=20TOC=20EPUB?= =?UTF-8?q?2+3,=20chiavi=20segno=20=E2=80=94=20testato=20su=20EPUB=20di=20?= =?UTF-8?q?esempio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- harbour-calibreweb.pro | 8 +- src/epub.cpp | 421 +++++++++++++++++++++++++++++++++++++++++ src/epub.h | 51 +++++ 3 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 src/epub.cpp create mode 100644 src/epub.h diff --git a/harbour-calibreweb.pro b/harbour-calibreweb.pro index eeb01e8..cb20a98 100644 --- a/harbour-calibreweb.pro +++ b/harbour-calibreweb.pro @@ -9,13 +9,17 @@ SOURCES += \ src/settings.cpp \ src/apiclient.cpp \ src/downloader.cpp \ - src/opdsparser.cpp + src/opdsparser.cpp \ + src/epub.cpp HEADERS += \ src/settings.h \ src/apiclient.h \ src/downloader.h \ - src/opdsparser.h + src/opdsparser.h \ + src/epub.h + +LIBS += -lz OTHER_FILES += \ qml/harbour-calibreweb.qml \ diff --git a/src/epub.cpp b/src/epub.cpp new file mode 100644 index 0000000..528242a --- /dev/null +++ b/src/epub.cpp @@ -0,0 +1,421 @@ +#include "epub.h" + +#include +#include +#include +#include +#include + +#include +#include + +// --------------------------------------------------------------------------- +// 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 *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(const_cast(raw.constData())); + zs.avail_in = raw.size(); + zs.next_out = reinterpret_cast(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 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 manifestHref; + QHash manifestType; + QHash 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 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 (titolo) viene PRIMA del (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; +} diff --git a/src/epub.h b/src/epub.h new file mode 100644 index 0000000..baa1b4c --- /dev/null +++ b/src/epub.h @@ -0,0 +1,51 @@ +#ifndef EPUB_H +#define EPUB_H + +#include +#include + +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 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 m_chapters; +}; + +#endif // EPUB_H