/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 | 18.3k | : KIO::ThumbnailCreator(parent, args) |
23 | 18.3k | { |
24 | 18.3k | } |
25 | | |
26 | 18.3k | EbookCreator::~EbookCreator() = default; |
27 | | |
28 | | KIO::ThumbnailResult EbookCreator::create(const KIO::ThumbnailRequest &request) |
29 | 18.3k | { |
30 | 18.3k | const QString path = request.url().toLocalFile(); |
31 | | |
32 | 18.3k | if (request.mimeType() == QLatin1String("application/epub+zip")) { |
33 | 1.62k | return createEpub(path); |
34 | | |
35 | 16.6k | } else if (request.mimeType() == QLatin1String("application/x-fictionbook+xml")) { |
36 | 3.82k | QFile file(path); |
37 | 3.82k | if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { |
38 | 0 | return KIO::ThumbnailResult::fail(); |
39 | 0 | } |
40 | | |
41 | 3.82k | return createFb2(&file); |
42 | | |
43 | 12.8k | } else if (request.mimeType() == QLatin1String("application/x-zip-compressed-fb2")) { |
44 | 1.58k | KZip zip(path); |
45 | 1.58k | if (!zip.open(QIODevice::ReadOnly)) { |
46 | 1.43k | return KIO::ThumbnailResult::fail(); |
47 | 1.43k | } |
48 | | |
49 | 149 | QScopedPointer<QIODevice> zipDevice; |
50 | | |
51 | 149 | const auto entries = zip.directory()->entries(); |
52 | 496 | for (const QString &entryPath : entries) { |
53 | 496 | if (entries.count() > 1 && !entryPath.endsWith(QLatin1String(".fb2"))) { // can this done a bit more cleverly? |
54 | 157 | continue; |
55 | 157 | } |
56 | | |
57 | 339 | const auto *entry = zip.directory()->file(entryPath); |
58 | 339 | if (!entry) { |
59 | 14 | return KIO::ThumbnailResult::fail(); |
60 | 14 | } |
61 | | |
62 | 325 | zipDevice.reset(entry->createDevice()); |
63 | 325 | } |
64 | | |
65 | 135 | return createFb2(zipDevice.data()); |
66 | 149 | } |
67 | | |
68 | 11.2k | return KIO::ThumbnailResult::fail(); |
69 | 18.3k | } |
70 | | |
71 | | KIO::ThumbnailResult EbookCreator::createEpub(const QString &path) |
72 | 1.62k | { |
73 | 1.62k | KZip zip(path); |
74 | 1.62k | if (!zip.open(QIODevice::ReadOnly)) { |
75 | 1.08k | return KIO::ThumbnailResult::fail(); |
76 | 1.08k | } |
77 | | |
78 | 538 | QScopedPointer<QIODevice> zipDevice; |
79 | 538 | QString opfPath; |
80 | 538 | QString coverHref; |
81 | | |
82 | | // First figure out where the OPF file with metadata is |
83 | 538 | const auto *entry = zip.directory()->file(QStringLiteral("META-INF/container.xml")); |
84 | | |
85 | 538 | if (!entry) { |
86 | 8 | return KIO::ThumbnailResult::fail(); |
87 | 8 | } |
88 | | |
89 | 530 | zipDevice.reset(entry->createDevice()); |
90 | | |
91 | 530 | QXmlStreamReader xml(zipDevice.data()); |
92 | 1.76k | while (!xml.atEnd() && !xml.hasError()) { |
93 | 1.35k | xml.readNext(); |
94 | | |
95 | 1.35k | if (xml.isStartElement() && xml.name() == QLatin1String("rootfile")) { |
96 | 124 | opfPath = xml.attributes().value(QStringLiteral("full-path")).toString(); |
97 | 124 | break; |
98 | 124 | } |
99 | 1.35k | } |
100 | | |
101 | 530 | if (opfPath.isEmpty()) { |
102 | 408 | return KIO::ThumbnailResult::fail(); |
103 | 408 | } |
104 | | |
105 | | // Now read the OPF file and look for a <meta name="cover" content="..."> |
106 | 122 | entry = zip.directory()->file(opfPath); |
107 | 122 | if (!entry) { |
108 | 1 | return KIO::ThumbnailResult::fail(); |
109 | 1 | } |
110 | | |
111 | 121 | zipDevice.reset(entry->createDevice()); |
112 | | |
113 | 121 | xml.setDevice(zipDevice.data()); |
114 | | |
115 | 121 | bool inMetadata = false; |
116 | 121 | bool inManifest = false; |
117 | 121 | QString coverId; |
118 | 121 | QMap<QString, QString> itemHrefs; |
119 | | |
120 | 3.53k | while (!xml.atEnd() && !xml.hasError()) { |
121 | 3.41k | xml.readNext(); |
122 | | |
123 | 3.41k | if (xml.name() == QLatin1String("metadata")) { |
124 | 98 | if (xml.isStartElement()) { |
125 | 60 | inMetadata = true; |
126 | 60 | } else if (xml.isEndElement()) { |
127 | 33 | inMetadata = false; |
128 | 33 | } |
129 | 98 | continue; |
130 | 98 | } |
131 | | |
132 | 3.31k | if (xml.name() == QLatin1String("manifest")) { |
133 | 61 | if (xml.isStartElement()) { |
134 | 50 | inManifest = true; |
135 | 50 | } else if (xml.isEndElement()) { |
136 | 10 | inManifest = false; |
137 | 10 | } |
138 | 61 | continue; |
139 | 61 | } |
140 | | |
141 | 3.25k | if (xml.isStartElement()) { |
142 | 924 | if (inMetadata && (xml.name() == QLatin1String("meta"))) { |
143 | 373 | const auto attributes = xml.attributes(); |
144 | 373 | if (attributes.value(QStringLiteral("name")) == QLatin1String("cover")) { |
145 | 0 | coverId = attributes.value(QStringLiteral("content")).toString(); |
146 | 0 | } |
147 | 551 | } else if (inManifest && (xml.name() == QLatin1String("item"))) { |
148 | 148 | const auto attributes = xml.attributes(); |
149 | 148 | const QString href = attributes.value(QStringLiteral("href")).toString(); |
150 | 148 | const QString id = attributes.value(QStringLiteral("id")).toString(); |
151 | 148 | if (!id.isEmpty() && !href.isEmpty()) { |
152 | | // EPUB 3 has the "cover-image" property set |
153 | 105 | const auto properties = attributes.value(QStringLiteral("properties")).toString(); |
154 | 105 | const auto propertyList = properties.split(QChar(' '), Qt::SkipEmptyParts); |
155 | 105 | if (propertyList.contains(QLatin1String("cover-image"))) { |
156 | 0 | coverHref = href; |
157 | 0 | break; |
158 | 0 | } |
159 | 105 | itemHrefs[id] = href; |
160 | 105 | } |
161 | 403 | } else { |
162 | 403 | continue; |
163 | 403 | } |
164 | | |
165 | 521 | if (!coverId.isEmpty() && itemHrefs.contains(coverId)) { |
166 | 0 | coverHref = itemHrefs[coverId]; |
167 | 0 | break; |
168 | 0 | } |
169 | 521 | } |
170 | 3.25k | } |
171 | | |
172 | 121 | if (coverHref.isEmpty()) { |
173 | | // Maybe we're lucky and the archive contains an iTunesArtwork file from iBooks |
174 | 121 | entry = zip.directory()->file(QStringLiteral("iTunesArtwork")); |
175 | 121 | 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 | 121 | const QStringList entries = getEntryList(zip.directory(), QString()); |
186 | | |
187 | 1.09k | for (const QString &name : entries) { |
188 | 1.09k | if (!name.contains(QLatin1String("cover"), Qt::CaseInsensitive)) { |
189 | 1.09k | continue; |
190 | 1.09k | } |
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 | 121 | return KIO::ThumbnailResult::fail(); |
207 | 121 | } |
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 | 3.95k | { |
235 | 3.95k | QString coverId; |
236 | | |
237 | 3.95k | QXmlStreamReader xml(device); |
238 | | |
239 | 3.95k | bool inFictionBook = false; |
240 | 3.95k | bool inDescription = false; |
241 | 3.95k | bool inTitleInfo = false; |
242 | 3.95k | bool inCoverPage = false; |
243 | | |
244 | 2.14M | while (!xml.atEnd() && !xml.hasError()) { |
245 | 2.14M | xml.readNext(); |
246 | | |
247 | 2.14M | if (xml.name() == QLatin1String("FictionBook")) { |
248 | 142k | if (xml.isStartElement()) { |
249 | 142k | inFictionBook = true; |
250 | 142k | } else if (xml.isEndElement()) { |
251 | 3 | break; |
252 | 3 | } |
253 | 1.99M | } else if (xml.name() == QLatin1String("description")) { |
254 | 1.31k | if (xml.isStartElement()) { |
255 | 1.19k | inDescription = true; |
256 | 1.19k | } else if (xml.isEndElement()) { |
257 | 117 | inDescription = false; |
258 | 117 | } |
259 | 1.99M | } else if (xml.name() == QLatin1String("title-info")) { |
260 | 1.11k | if (xml.isStartElement()) { |
261 | 694 | inTitleInfo = true; |
262 | 694 | } else if (xml.isEndElement()) { |
263 | 407 | inTitleInfo = false; |
264 | 407 | } |
265 | 1.99M | } 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 | 2.14M | if (!inFictionBook) { |
274 | 56.0k | continue; |
275 | 56.0k | } |
276 | | |
277 | 2.08M | if (inDescription) { |
278 | 9.12k | 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 | 2.07M | } else { |
294 | 2.07M | 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 | 2.07M | } |
302 | 2.08M | } |
303 | | |
304 | 3.95k | return KIO::ThumbnailResult::fail(); |
305 | 3.95k | } |
306 | | |
307 | | QStringList EbookCreator::getEntryList(const KArchiveDirectory *dir, const QString &path) |
308 | 1.21k | { |
309 | 1.21k | QStringList list; |
310 | | |
311 | 1.21k | const QStringList entries = dir->entries(); |
312 | 2.18k | for (const QString &name : entries) { |
313 | 2.18k | const KArchiveEntry *entry = dir->entry(name); |
314 | | |
315 | 2.18k | QString fullPath = name; |
316 | | |
317 | 2.18k | if (!path.isEmpty()) { |
318 | 1.72k | fullPath.prepend(QLatin1Char('/')); |
319 | 1.72k | fullPath.prepend(path); |
320 | 1.72k | } |
321 | | |
322 | 2.18k | if (entry->isFile()) { |
323 | 1.09k | list << fullPath; |
324 | 1.09k | } else { |
325 | 1.09k | list << getEntryList(static_cast<const KArchiveDirectory *>(entry), fullPath); |
326 | 1.09k | } |
327 | 2.18k | } |
328 | | |
329 | 1.21k | return list; |
330 | 1.21k | } |
331 | | |
332 | | #include "ebookcreator.moc" |
333 | | #include "moc_ebookcreator.cpp" |