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"