File indexing completed on 2024-04-28 04:58:03

0001 /*
0002  * SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@broulik.de>
0003  *
0004  * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
0005  */
0006 
0007 #include "ebookcreator.h"
0008 
0009 #include <QFile>
0010 #include <QImage>
0011 #include <QMap>
0012 #include <QMimeDatabase>
0013 #include <QUrl>
0014 #include <QXmlStreamReader>
0015 
0016 #include <KPluginFactory>
0017 #include <KZip>
0018 
0019 K_PLUGIN_CLASS_WITH_JSON(EbookCreator, "ebookthumbnail.json")
0020 
0021 EbookCreator::EbookCreator(QObject *parent, const QVariantList &args)
0022     : KIO::ThumbnailCreator(parent, args)
0023 {
0024 }
0025 
0026 EbookCreator::~EbookCreator() = default;
0027 
0028 KIO::ThumbnailResult EbookCreator::create(const KIO::ThumbnailRequest &request)
0029 {
0030     const QString path = request.url().toLocalFile();
0031 
0032     if (request.mimeType() == QLatin1String("application/epub+zip")) {
0033         return createEpub(path);
0034 
0035     } else if (request.mimeType() == QLatin1String("application/x-fictionbook+xml")) {
0036         QFile file(path);
0037         if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
0038             return KIO::ThumbnailResult::fail();
0039         }
0040 
0041         return createFb2(&file);
0042 
0043     } else if (request.mimeType() == QLatin1String("application/x-zip-compressed-fb2")) {
0044         KZip zip(path);
0045         if (!zip.open(QIODevice::ReadOnly)) {
0046             return KIO::ThumbnailResult::fail();
0047         }
0048 
0049         QScopedPointer<QIODevice> zipDevice;
0050 
0051         const auto entries = zip.directory()->entries();
0052         for (const QString &entryPath : entries) {
0053             if (entries.count() > 1 && !entryPath.endsWith(QLatin1String(".fb2"))) { // can this done a bit more cleverly?
0054                 continue;
0055             }
0056 
0057             const auto *entry = zip.directory()->file(entryPath);
0058             if (!entry) {
0059                 return KIO::ThumbnailResult::fail();
0060             }
0061 
0062             zipDevice.reset(entry->createDevice());
0063         }
0064 
0065         return createFb2(zipDevice.data());
0066     }
0067 
0068     return KIO::ThumbnailResult::fail();
0069 }
0070 
0071 KIO::ThumbnailResult EbookCreator::createEpub(const QString &path)
0072 {
0073     KZip zip(path);
0074     if (!zip.open(QIODevice::ReadOnly)) {
0075         return KIO::ThumbnailResult::fail();
0076     }
0077 
0078     QScopedPointer<QIODevice> zipDevice;
0079     QString opfPath;
0080     QString coverHref;
0081 
0082     // First figure out where the OPF file with metadata is
0083     const auto *entry = zip.directory()->file(QStringLiteral("META-INF/container.xml"));
0084 
0085     if (!entry) {
0086         return KIO::ThumbnailResult::fail();
0087     }
0088 
0089     zipDevice.reset(entry->createDevice());
0090 
0091     QXmlStreamReader xml(zipDevice.data());
0092     while (!xml.atEnd() && !xml.hasError()) {
0093         xml.readNext();
0094 
0095         if (xml.isStartElement() && xml.name() == QLatin1String("rootfile")) {
0096             opfPath = xml.attributes().value(QStringLiteral("full-path")).toString();
0097             break;
0098         }
0099     }
0100 
0101     if (opfPath.isEmpty()) {
0102         return KIO::ThumbnailResult::fail();
0103     }
0104 
0105     // Now read the OPF file and look for a <meta name="cover" content="...">
0106     entry = zip.directory()->file(opfPath);
0107     if (!entry) {
0108         return KIO::ThumbnailResult::fail();
0109     }
0110 
0111     zipDevice.reset(entry->createDevice());
0112 
0113     xml.setDevice(zipDevice.data());
0114 
0115     bool inMetadata = false;
0116     bool inManifest = false;
0117     QString coverId;
0118     QMap<QString, QString> itemHrefs;
0119 
0120     while (!xml.atEnd() && !xml.hasError()) {
0121         xml.readNext();
0122 
0123         if (xml.name() == QLatin1String("metadata")) {
0124             if (xml.isStartElement()) {
0125                 inMetadata = true;
0126             } else if (xml.isEndElement()) {
0127                 inMetadata = false;
0128             }
0129             continue;
0130         }
0131 
0132         if (xml.name() == QLatin1String("manifest")) {
0133             if (xml.isStartElement()) {
0134                 inManifest = true;
0135             } else if (xml.isEndElement()) {
0136                 inManifest = false;
0137             }
0138             continue;
0139         }
0140 
0141         if (xml.isStartElement()) {
0142             if (inMetadata && (xml.name() == QLatin1String("meta"))) {
0143                 const auto attributes = xml.attributes();
0144                 if (attributes.value(QStringLiteral("name")) == QLatin1String("cover")) {
0145                     coverId = attributes.value(QStringLiteral("content")).toString();
0146                 }
0147             } else if (inManifest && (xml.name() == QLatin1String("item"))) {
0148                 const auto attributes = xml.attributes();
0149                 const QString href = attributes.value(QStringLiteral("href")).toString();
0150                 const QString id = attributes.value(QStringLiteral("id")).toString();
0151                 if (!id.isEmpty() && !href.isEmpty()) {
0152                     // EPUB 3 has the "cover-image" property set
0153                     const auto properties = attributes.value(QStringLiteral("properties")).toString();
0154                     const auto propertyList = properties.split(QChar(' '), Qt::SkipEmptyParts);
0155                     if (propertyList.contains(QLatin1String("cover-image"))) {
0156                         coverHref = href;
0157                         break;
0158                     }
0159                     itemHrefs[id] = href;
0160                 }
0161             } else {
0162                 continue;
0163             }
0164 
0165             if (!coverId.isEmpty() && itemHrefs.contains(coverId)) {
0166                 coverHref = itemHrefs[coverId];
0167                 break;
0168             }
0169         }
0170     }
0171 
0172     if (coverHref.isEmpty()) {
0173         // Maybe we're lucky and the archive contains an iTunesArtwork file from iBooks
0174         entry = zip.directory()->file(QStringLiteral("iTunesArtwork"));
0175         if (entry) {
0176             zipDevice.reset(entry->createDevice());
0177 
0178             QImage image;
0179             bool okay = image.load(zipDevice.data(), "");
0180 
0181             return okay ? KIO::ThumbnailResult::pass(image) : KIO::ThumbnailResult::fail();
0182         }
0183 
0184         // Maybe there's a file called "cover" somewhere
0185         const QStringList entries = getEntryList(zip.directory(), QString());
0186 
0187         for (const QString &name : entries) {
0188             if (!name.contains(QLatin1String("cover"), Qt::CaseInsensitive)) {
0189                 continue;
0190             }
0191 
0192             entry = zip.directory()->file(name);
0193             if (!entry) {
0194                 continue;
0195             }
0196 
0197             zipDevice.reset(entry->createDevice());
0198 
0199             QImage image;
0200             bool success = image.load(zipDevice.data(), "");
0201 
0202             if (success) {
0203                 return KIO::ThumbnailResult::pass(image);
0204             }
0205         }
0206         return KIO::ThumbnailResult::fail();
0207     }
0208 
0209     // Decode percent encoded URL
0210     QByteArray encoded = coverHref.toUtf8();
0211     coverHref = QUrl::fromPercentEncoding(encoded);
0212 
0213     // Make coverHref relative to OPF location
0214     const int lastOpfSlash = opfPath.lastIndexOf(QLatin1Char('/'));
0215     if (lastOpfSlash > -1) {
0216         QString basePath = opfPath.left(lastOpfSlash + 1);
0217         coverHref.prepend(basePath);
0218     }
0219 
0220     // Finally, just load the cover image file
0221     entry = zip.directory()->file(coverHref);
0222     if (entry) {
0223         zipDevice.reset(entry->createDevice());
0224         QImage image;
0225         image.load(zipDevice.data(), "");
0226 
0227         return KIO::ThumbnailResult::pass(image);
0228     }
0229 
0230     return KIO::ThumbnailResult::fail();
0231 }
0232 
0233 KIO::ThumbnailResult EbookCreator::createFb2(QIODevice *device)
0234 {
0235     QString coverId;
0236 
0237     QXmlStreamReader xml(device);
0238 
0239     bool inFictionBook = false;
0240     bool inDescription = false;
0241     bool inTitleInfo = false;
0242     bool inCoverPage = false;
0243 
0244     while (!xml.atEnd() && !xml.hasError()) {
0245         xml.readNext();
0246 
0247         if (xml.name() == QLatin1String("FictionBook")) {
0248             if (xml.isStartElement()) {
0249                 inFictionBook = true;
0250             } else if (xml.isEndElement()) {
0251                 break;
0252             }
0253         } else if (xml.name() == QLatin1String("description")) {
0254             if (xml.isStartElement()) {
0255                 inDescription = true;
0256             } else if (xml.isEndElement()) {
0257                 inDescription = false;
0258             }
0259         } else if (xml.name() == QLatin1String("title-info")) {
0260             if (xml.isStartElement()) {
0261                 inTitleInfo = true;
0262             } else if (xml.isEndElement()) {
0263                 inTitleInfo = false;
0264             }
0265         } else if (xml.name() == QLatin1String("coverpage")) {
0266             if (xml.isStartElement()) {
0267                 inCoverPage = true;
0268             } else if (xml.isEndElement()) {
0269                 inCoverPage = false;
0270             }
0271         }
0272 
0273         if (!inFictionBook) {
0274             continue;
0275         }
0276 
0277         if (inDescription) {
0278             if (inTitleInfo && inCoverPage) {
0279                 if (xml.isStartElement() && xml.name() == QLatin1String("image")) {
0280                     const auto attributes = xml.attributes();
0281 
0282                     // value() wants a namespace but we don't care, so iterate until we find any "href"
0283                     for (const auto &attribute : attributes) {
0284                         if (attribute.name() == QLatin1String("href")) {
0285                             coverId = attribute.value().toString();
0286                             if (coverId.startsWith(QLatin1Char('#'))) {
0287                                 coverId = coverId.mid(1);
0288                             }
0289                         }
0290                     }
0291                 }
0292             }
0293         } else {
0294             if (!coverId.isEmpty() && xml.isStartElement() && xml.name() == QLatin1String("binary")) {
0295                 if (xml.attributes().value(QStringLiteral("id")) == coverId) {
0296                     QImage image;
0297                     image.loadFromData(QByteArray::fromBase64(xml.readElementText().toLatin1()));
0298                     return KIO::ThumbnailResult::pass(image);
0299                 }
0300             }
0301         }
0302     }
0303 
0304     return KIO::ThumbnailResult::fail();
0305 }
0306 
0307 QStringList EbookCreator::getEntryList(const KArchiveDirectory *dir, const QString &path)
0308 {
0309     QStringList list;
0310 
0311     const QStringList entries = dir->entries();
0312     for (const QString &name : entries) {
0313         const KArchiveEntry *entry = dir->entry(name);
0314 
0315         QString fullPath = name;
0316 
0317         if (!path.isEmpty()) {
0318             fullPath.prepend(QLatin1Char('/'));
0319             fullPath.prepend(path);
0320         }
0321 
0322         if (entry->isFile()) {
0323             list << fullPath;
0324         } else {
0325             list << getEntryList(static_cast<const KArchiveDirectory *>(entry), fullPath);
0326         }
0327     }
0328 
0329     return list;
0330 }
0331 
0332 #include "ebookcreator.moc"
0333 #include "moc_ebookcreator.cpp"