/src/kio-extras/thumbnail/ebookcreator.cpp
Line | Count | Source |
1 | | /* |
2 | | * SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@broulik.de> |
3 | | * |
4 | | * SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL |
5 | | */ |
6 | | |
7 | | #include "ebookcreator.h" |
8 | | |
9 | | #include <QFile> |
10 | | #include <QImage> |
11 | | #include <QMap> |
12 | | #include <QMimeDatabase> |
13 | | #include <QUrl> |
14 | | #include <QXmlStreamReader> |
15 | | |
16 | | #include <KPluginFactory> |
17 | | #include <KZip> |
18 | | |
19 | 0 | K_PLUGIN_CLASS_WITH_JSON(EbookCreator, "ebookthumbnail.json") Unexecuted instantiation: ebookthumbnail_factory::tr(char const*, char const*, int) Unexecuted instantiation: ebookthumbnail_factory::~ebookthumbnail_factory() |
20 | 0 |
|
21 | 0 | EbookCreator::EbookCreator(QObject *parent, const QVariantList &args) |
22 | 23.3k | : KIO::ThumbnailCreator(parent, args) |
23 | 23.3k | { |
24 | 23.3k | } |
25 | | |
26 | 23.3k | EbookCreator::~EbookCreator() = default; |
27 | | |
28 | | KIO::ThumbnailResult EbookCreator::create(const KIO::ThumbnailRequest &request) |
29 | 23.3k | { |
30 | 23.3k | const QString path = request.url().toLocalFile(); |
31 | | |
32 | 23.3k | if (request.mimeType() == QLatin1String("application/epub+zip")) { |
33 | 2.31k | return createEpub(path); |
34 | | |
35 | 20.9k | } else if (request.mimeType() == QLatin1String("application/x-fictionbook+xml")) { |
36 | 4.43k | QFile file(path); |
37 | 4.43k | if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { |
38 | 0 | return KIO::ThumbnailResult::fail(); |
39 | 0 | } |
40 | | |
41 | 4.43k | return createFb2(&file); |
42 | | |
43 | 16.5k | } else if (request.mimeType() == QLatin1String("application/x-zip-compressed-fb2")) { |
44 | 2.44k | KZip zip(path); |
45 | 2.44k | if (!zip.open(QIODevice::ReadOnly)) { |
46 | 2.19k | return KIO::ThumbnailResult::fail(); |
47 | 2.19k | } |
48 | | |
49 | 254 | QScopedPointer<QIODevice> zipDevice; |
50 | | |
51 | 254 | const auto entries = zip.directory()->entries(); |
52 | 1.51k | for (const QString &entryPath : entries) { |
53 | 1.51k | if (entries.count() > 1 && !entryPath.endsWith(QLatin1String(".fb2"))) { // can this done a bit more cleverly? |
54 | 494 | continue; |
55 | 494 | } |
56 | | |
57 | 1.01k | const auto *entry = zip.directory()->file(entryPath); |
58 | 1.01k | if (!entry) { |
59 | 18 | return KIO::ThumbnailResult::fail(); |
60 | 18 | } |
61 | | |
62 | 999 | zipDevice.reset(entry->createDevice()); |
63 | 999 | } |
64 | | |
65 | 236 | return createFb2(zipDevice.data()); |
66 | 254 | } |
67 | | |
68 | 14.1k | return KIO::ThumbnailResult::fail(); |
69 | 23.3k | } |
70 | | |
71 | | KIO::ThumbnailResult EbookCreator::createEpub(const QString &path) |
72 | 2.31k | { |
73 | 2.31k | KZip zip(path); |
74 | 2.31k | if (!zip.open(QIODevice::ReadOnly)) { |
75 | 839 | return KIO::ThumbnailResult::fail(); |
76 | 839 | } |
77 | | |
78 | 1.47k | QScopedPointer<QIODevice> zipDevice; |
79 | 1.47k | QString opfPath; |
80 | 1.47k | QString coverHref; |
81 | | |
82 | | // First figure out where the OPF file with metadata is |
83 | 1.47k | const auto *entry = zip.directory()->file(QStringLiteral("META-INF/container.xml")); |
84 | | |
85 | 1.47k | if (!entry) { |
86 | 9 | return KIO::ThumbnailResult::fail(); |
87 | 9 | } |
88 | | |
89 | 1.46k | zipDevice.reset(entry->createDevice()); |
90 | | |
91 | 1.46k | QXmlStreamReader xml(zipDevice.data()); |
92 | 25.8k | while (!xml.atEnd() && !xml.hasError()) { |
93 | 24.7k | xml.readNext(); |
94 | | |
95 | 24.7k | if (xml.isStartElement() && xml.name() == QLatin1String("rootfile")) { |
96 | 412 | opfPath = xml.attributes().value(QStringLiteral("full-path")).toString(); |
97 | 412 | break; |
98 | 412 | } |
99 | 24.7k | } |
100 | | |
101 | 1.46k | if (opfPath.isEmpty()) { |
102 | 1.05k | return KIO::ThumbnailResult::fail(); |
103 | 1.05k | } |
104 | | |
105 | | // Now read the OPF file and look for a <meta name="cover" content="..."> |
106 | 407 | entry = zip.directory()->file(opfPath); |
107 | 407 | if (!entry) { |
108 | 4 | return KIO::ThumbnailResult::fail(); |
109 | 4 | } |
110 | | |
111 | 403 | zipDevice.reset(entry->createDevice()); |
112 | | |
113 | 403 | xml.setDevice(zipDevice.data()); |
114 | | |
115 | 403 | bool inMetadata = false; |
116 | 403 | bool inManifest = false; |
117 | 403 | QString coverId; |
118 | 403 | QMap<QString, QString> itemHrefs; |
119 | | |
120 | 3.85k | while (!xml.atEnd() && !xml.hasError()) { |
121 | 3.45k | xml.readNext(); |
122 | | |
123 | 3.45k | if (xml.name() == QLatin1String("metadata")) { |
124 | 97 | if (xml.isStartElement()) { |
125 | 64 | inMetadata = true; |
126 | 64 | } else if (xml.isEndElement()) { |
127 | 25 | inMetadata = false; |
128 | 25 | } |
129 | 97 | continue; |
130 | 97 | } |
131 | | |
132 | 3.35k | if (xml.name() == QLatin1String("manifest")) { |
133 | 46 | if (xml.isStartElement()) { |
134 | 41 | inManifest = true; |
135 | 41 | } else if (xml.isEndElement()) { |
136 | 3 | inManifest = false; |
137 | 3 | } |
138 | 46 | continue; |
139 | 46 | } |
140 | | |
141 | 3.31k | if (xml.isStartElement()) { |
142 | 905 | if (inMetadata && (xml.name() == QLatin1String("meta"))) { |
143 | 331 | const auto attributes = xml.attributes(); |
144 | 331 | if (attributes.value(QStringLiteral("name")) == QLatin1String("cover")) { |
145 | 0 | coverId = attributes.value(QStringLiteral("content")).toString(); |
146 | 0 | } |
147 | 574 | } else if (inManifest && (xml.name() == QLatin1String("item"))) { |
148 | 115 | const auto attributes = xml.attributes(); |
149 | 115 | const QString href = attributes.value(QStringLiteral("href")).toString(); |
150 | 115 | const QString id = attributes.value(QStringLiteral("id")).toString(); |
151 | 115 | if (!id.isEmpty() && !href.isEmpty()) { |
152 | | // EPUB 3 has the "cover-image" property set |
153 | 78 | const auto properties = attributes.value(QStringLiteral("properties")).toString(); |
154 | 78 | const auto propertyList = properties.split(QChar(' '), Qt::SkipEmptyParts); |
155 | 78 | if (propertyList.contains(QLatin1String("cover-image"))) { |
156 | 0 | coverHref = href; |
157 | 0 | break; |
158 | 0 | } |
159 | 78 | itemHrefs[id] = href; |
160 | 78 | } |
161 | 459 | } else { |
162 | 459 | continue; |
163 | 459 | } |
164 | | |
165 | 446 | if (!coverId.isEmpty() && itemHrefs.contains(coverId)) { |
166 | 0 | coverHref = itemHrefs[coverId]; |
167 | 0 | break; |
168 | 0 | } |
169 | 446 | } |
170 | 3.31k | } |
171 | | |
172 | 403 | if (coverHref.isEmpty()) { |
173 | | // Maybe we're lucky and the archive contains an iTunesArtwork file from iBooks |
174 | 403 | entry = zip.directory()->file(QStringLiteral("iTunesArtwork")); |
175 | 403 | if (entry) { |
176 | 0 | zipDevice.reset(entry->createDevice()); |
177 | |
|
178 | 0 | QImage image; |
179 | 0 | bool okay = image.load(zipDevice.data(), ""); |
180 | |
|
181 | 0 | return okay ? KIO::ThumbnailResult::pass(image) : KIO::ThumbnailResult::fail(); |
182 | 0 | } |
183 | | |
184 | | // Maybe there's a file called "cover" somewhere |
185 | 403 | const QStringList entries = getEntryList(zip.directory(), QString()); |
186 | | |
187 | 4.35k | for (const QString &name : entries) { |
188 | 4.35k | if (!name.contains(QLatin1String("cover"), Qt::CaseInsensitive)) { |
189 | 4.35k | continue; |
190 | 4.35k | } |
191 | | |
192 | 0 | entry = zip.directory()->file(name); |
193 | 0 | if (!entry) { |
194 | 0 | continue; |
195 | 0 | } |
196 | | |
197 | 0 | zipDevice.reset(entry->createDevice()); |
198 | |
|
199 | 0 | QImage image; |
200 | 0 | bool success = image.load(zipDevice.data(), ""); |
201 | |
|
202 | 0 | if (success) { |
203 | 0 | return KIO::ThumbnailResult::pass(image); |
204 | 0 | } |
205 | 0 | } |
206 | 403 | return KIO::ThumbnailResult::fail(); |
207 | 403 | } |
208 | | |
209 | | // Decode percent encoded URL |
210 | 0 | QByteArray encoded = coverHref.toUtf8(); |
211 | 0 | coverHref = QUrl::fromPercentEncoding(encoded); |
212 | | |
213 | | // Make coverHref relative to OPF location |
214 | 0 | const int lastOpfSlash = opfPath.lastIndexOf(QLatin1Char('/')); |
215 | 0 | if (lastOpfSlash > -1) { |
216 | 0 | QString basePath = opfPath.left(lastOpfSlash + 1); |
217 | 0 | coverHref.prepend(basePath); |
218 | 0 | } |
219 | | |
220 | | // Finally, just load the cover image file |
221 | 0 | entry = zip.directory()->file(coverHref); |
222 | 0 | if (entry) { |
223 | 0 | zipDevice.reset(entry->createDevice()); |
224 | 0 | QImage image; |
225 | 0 | image.load(zipDevice.data(), ""); |
226 | |
|
227 | 0 | return KIO::ThumbnailResult::pass(image); |
228 | 0 | } |
229 | | |
230 | 0 | return KIO::ThumbnailResult::fail(); |
231 | 0 | } |
232 | | |
233 | | KIO::ThumbnailResult EbookCreator::createFb2(QIODevice *device) |
234 | 4.67k | { |
235 | 4.67k | QString coverId; |
236 | | |
237 | 4.67k | QXmlStreamReader xml(device); |
238 | | |
239 | 4.67k | bool inFictionBook = false; |
240 | 4.67k | bool inDescription = false; |
241 | 4.67k | bool inTitleInfo = false; |
242 | 4.67k | bool inCoverPage = false; |
243 | | |
244 | 829k | while (!xml.atEnd() && !xml.hasError()) { |
245 | 824k | xml.readNext(); |
246 | | |
247 | 824k | if (xml.name() == QLatin1String("FictionBook")) { |
248 | 111k | if (xml.isStartElement()) { |
249 | 111k | inFictionBook = true; |
250 | 111k | } else if (xml.isEndElement()) { |
251 | 1 | break; |
252 | 1 | } |
253 | 712k | } else if (xml.name() == QLatin1String("description")) { |
254 | 1.17k | if (xml.isStartElement()) { |
255 | 637 | inDescription = true; |
256 | 637 | } else if (xml.isEndElement()) { |
257 | 534 | inDescription = false; |
258 | 534 | } |
259 | 711k | } else if (xml.name() == QLatin1String("title-info")) { |
260 | 865 | if (xml.isStartElement()) { |
261 | 436 | inTitleInfo = true; |
262 | 436 | } else if (xml.isEndElement()) { |
263 | 423 | inTitleInfo = false; |
264 | 423 | } |
265 | 710k | } else if (xml.name() == QLatin1String("coverpage")) { |
266 | 0 | if (xml.isStartElement()) { |
267 | 0 | inCoverPage = true; |
268 | 0 | } else if (xml.isEndElement()) { |
269 | 0 | inCoverPage = false; |
270 | 0 | } |
271 | 0 | } |
272 | | |
273 | 824k | if (!inFictionBook) { |
274 | 41.4k | continue; |
275 | 41.4k | } |
276 | | |
277 | 782k | if (inDescription) { |
278 | 2.02k | if (inTitleInfo && inCoverPage) { |
279 | 0 | if (xml.isStartElement() && xml.name() == QLatin1String("image")) { |
280 | 0 | const auto attributes = xml.attributes(); |
281 | | |
282 | | // value() wants a namespace but we don't care, so iterate until we find any "href" |
283 | 0 | for (const auto &attribute : attributes) { |
284 | 0 | if (attribute.name() == QLatin1String("href")) { |
285 | 0 | coverId = attribute.value().toString(); |
286 | 0 | if (coverId.startsWith(QLatin1Char('#'))) { |
287 | 0 | coverId = coverId.mid(1); |
288 | 0 | } |
289 | 0 | } |
290 | 0 | } |
291 | 0 | } |
292 | 0 | } |
293 | 780k | } else { |
294 | 780k | if (!coverId.isEmpty() && xml.isStartElement() && xml.name() == QLatin1String("binary")) { |
295 | 0 | if (xml.attributes().value(QStringLiteral("id")) == coverId) { |
296 | 0 | QImage image; |
297 | 0 | image.loadFromData(QByteArray::fromBase64(xml.readElementText().toLatin1())); |
298 | 0 | return KIO::ThumbnailResult::pass(image); |
299 | 0 | } |
300 | 0 | } |
301 | 780k | } |
302 | 782k | } |
303 | | |
304 | 4.67k | return KIO::ThumbnailResult::fail(); |
305 | 4.67k | } |
306 | | |
307 | | QStringList EbookCreator::getEntryList(const KArchiveDirectory *dir, const QString &path) |
308 | 69.9k | { |
309 | 69.9k | QStringList list; |
310 | | |
311 | 69.9k | const QStringList entries = dir->entries(); |
312 | 73.9k | for (const QString &name : entries) { |
313 | 73.9k | const KArchiveEntry *entry = dir->entry(name); |
314 | | |
315 | 73.9k | QString fullPath = name; |
316 | | |
317 | 73.9k | if (!path.isEmpty()) { |
318 | 71.5k | fullPath.prepend(QLatin1Char('/')); |
319 | 71.5k | fullPath.prepend(path); |
320 | 71.5k | } |
321 | | |
322 | 73.9k | if (entry->isFile()) { |
323 | 4.35k | list << fullPath; |
324 | 69.5k | } else { |
325 | 69.5k | list << getEntryList(static_cast<const KArchiveDirectory *>(entry), fullPath); |
326 | 69.5k | } |
327 | 73.9k | } |
328 | | |
329 | 69.9k | return list; |
330 | 69.9k | } |
331 | | |
332 | | #include "ebookcreator.moc" |
333 | | #include "moc_ebookcreator.cpp" |