Coverage Report

Created: 2026-03-12 07:14

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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"