Coverage Report

Created: 2026-04-29 07:00

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
25.1k
    : KIO::ThumbnailCreator(parent, args)
23
25.1k
{
24
25.1k
}
25
26
25.1k
EbookCreator::~EbookCreator() = default;
27
28
KIO::ThumbnailResult EbookCreator::create(const KIO::ThumbnailRequest &request)
29
25.1k
{
30
25.1k
    const QString path = request.url().toLocalFile();
31
32
25.1k
    if (request.mimeType() == QLatin1String("application/epub+zip")) {
33
3.35k
        return createEpub(path);
34
35
21.8k
    } else if (request.mimeType() == QLatin1String("application/x-fictionbook+xml")) {
36
4.24k
        QFile file(path);
37
4.24k
        if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
38
0
            return KIO::ThumbnailResult::fail();
39
0
        }
40
41
4.24k
        return createFb2(&file);
42
43
17.5k
    } else if (request.mimeType() == QLatin1String("application/x-zip-compressed-fb2")) {
44
2.64k
        KZip zip(path);
45
2.64k
        if (!zip.open(QIODevice::ReadOnly)) {
46
2.30k
            return KIO::ThumbnailResult::fail();
47
2.30k
        }
48
49
340
        QScopedPointer<QIODevice> zipDevice;
50
51
340
        const auto entries = zip.directory()->entries();
52
1.43k
        for (const QString &entryPath : entries) {
53
1.43k
            if (entries.count() > 1 && !entryPath.endsWith(QLatin1String(".fb2"))) { // can this done a bit more cleverly?
54
381
                continue;
55
381
            }
56
57
1.05k
            const auto *entry = zip.directory()->file(entryPath);
58
1.05k
            if (!entry) {
59
11
                return KIO::ThumbnailResult::fail();
60
11
            }
61
62
1.04k
            zipDevice.reset(entry->createDevice());
63
1.04k
        }
64
65
329
        return createFb2(zipDevice.data());
66
340
    }
67
68
14.9k
    return KIO::ThumbnailResult::fail();
69
25.1k
}
70
71
KIO::ThumbnailResult EbookCreator::createEpub(const QString &path)
72
3.35k
{
73
3.35k
    KZip zip(path);
74
3.35k
    if (!zip.open(QIODevice::ReadOnly)) {
75
663
        return KIO::ThumbnailResult::fail();
76
663
    }
77
78
2.68k
    QScopedPointer<QIODevice> zipDevice;
79
2.68k
    QString opfPath;
80
2.68k
    QString coverHref;
81
82
    // First figure out where the OPF file with metadata is
83
2.68k
    const auto *entry = zip.directory()->file(QStringLiteral("META-INF/container.xml"));
84
85
2.68k
    if (!entry) {
86
8
        return KIO::ThumbnailResult::fail();
87
8
    }
88
89
2.68k
    zipDevice.reset(entry->createDevice());
90
91
2.68k
    QXmlStreamReader xml(zipDevice.data());
92
14.5k
    while (!xml.atEnd() && !xml.hasError()) {
93
13.3k
        xml.readNext();
94
95
13.3k
        if (xml.isStartElement() && xml.name() == QLatin1String("rootfile")) {
96
1.49k
            opfPath = xml.attributes().value(QStringLiteral("full-path")).toString();
97
1.49k
            break;
98
1.49k
        }
99
13.3k
    }
100
101
2.68k
    if (opfPath.isEmpty()) {
102
1.19k
        return KIO::ThumbnailResult::fail();
103
1.19k
    }
104
105
    // Now read the OPF file and look for a <meta name="cover" content="...">
106
1.48k
    entry = zip.directory()->file(opfPath);
107
1.48k
    if (!entry) {
108
4
        return KIO::ThumbnailResult::fail();
109
4
    }
110
111
1.48k
    zipDevice.reset(entry->createDevice());
112
113
1.48k
    xml.setDevice(zipDevice.data());
114
115
1.48k
    bool inMetadata = false;
116
1.48k
    bool inManifest = false;
117
1.48k
    QString coverId;
118
1.48k
    QMap<QString, QString> itemHrefs;
119
120
7.54k
    while (!xml.atEnd() && !xml.hasError()) {
121
6.06k
        xml.readNext();
122
123
6.06k
        if (xml.name() == QLatin1String("metadata")) {
124
93
            if (xml.isStartElement()) {
125
75
                inMetadata = true;
126
75
            } else if (xml.isEndElement()) {
127
17
                inMetadata = false;
128
17
            }
129
93
            continue;
130
93
        }
131
132
5.97k
        if (xml.name() == QLatin1String("manifest")) {
133
61
            if (xml.isStartElement()) {
134
57
                inManifest = true;
135
57
            } else if (xml.isEndElement()) {
136
3
                inManifest = false;
137
3
            }
138
61
            continue;
139
61
        }
140
141
5.90k
        if (xml.isStartElement()) {
142
1.67k
            if (inMetadata && (xml.name() == QLatin1String("meta"))) {
143
489
                const auto attributes = xml.attributes();
144
489
                if (attributes.value(QStringLiteral("name")) == QLatin1String("cover")) {
145
0
                    coverId = attributes.value(QStringLiteral("content")).toString();
146
0
                }
147
1.18k
            } else if (inManifest && (xml.name() == QLatin1String("item"))) {
148
141
                const auto attributes = xml.attributes();
149
141
                const QString href = attributes.value(QStringLiteral("href")).toString();
150
141
                const QString id = attributes.value(QStringLiteral("id")).toString();
151
141
                if (!id.isEmpty() && !href.isEmpty()) {
152
                    // EPUB 3 has the "cover-image" property set
153
67
                    const auto properties = attributes.value(QStringLiteral("properties")).toString();
154
67
                    const auto propertyList = properties.split(QChar(' '), Qt::SkipEmptyParts);
155
67
                    if (propertyList.contains(QLatin1String("cover-image"))) {
156
0
                        coverHref = href;
157
0
                        break;
158
0
                    }
159
67
                    itemHrefs[id] = href;
160
67
                }
161
1.04k
            } else {
162
1.04k
                continue;
163
1.04k
            }
164
165
630
            if (!coverId.isEmpty() && itemHrefs.contains(coverId)) {
166
0
                coverHref = itemHrefs[coverId];
167
0
                break;
168
0
            }
169
630
        }
170
5.90k
    }
171
172
1.48k
    if (coverHref.isEmpty()) {
173
        // Maybe we're lucky and the archive contains an iTunesArtwork file from iBooks
174
1.48k
        entry = zip.directory()->file(QStringLiteral("iTunesArtwork"));
175
1.48k
        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
1.48k
        const QStringList entries = getEntryList(zip.directory(), QString());
186
187
25.6k
        for (const QString &name : entries) {
188
25.6k
            if (!name.contains(QLatin1String("cover"), Qt::CaseInsensitive)) {
189
5.50k
                continue;
190
5.50k
            }
191
192
20.1k
            entry = zip.directory()->file(name);
193
20.1k
            if (!entry) {
194
20
                continue;
195
20
            }
196
197
20.1k
            zipDevice.reset(entry->createDevice());
198
199
20.1k
            QImage image;
200
20.1k
            bool success = image.load(zipDevice.data(), "");
201
202
20.1k
            if (success) {
203
65
                return KIO::ThumbnailResult::pass(image);
204
65
            }
205
20.1k
        }
206
1.41k
        return KIO::ThumbnailResult::fail();
207
1.48k
    }
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.56k
{
235
4.56k
    QString coverId;
236
237
4.56k
    QXmlStreamReader xml(device);
238
239
4.56k
    bool inFictionBook = false;
240
4.56k
    bool inDescription = false;
241
4.56k
    bool inTitleInfo = false;
242
4.56k
    bool inCoverPage = false;
243
244
774k
    while (!xml.atEnd() && !xml.hasError()) {
245
769k
        xml.readNext();
246
247
769k
        if (xml.name() == QLatin1String("FictionBook")) {
248
238k
            if (xml.isStartElement()) {
249
237k
                inFictionBook = true;
250
237k
            } else if (xml.isEndElement()) {
251
1
                break;
252
1
            }
253
531k
        } else if (xml.name() == QLatin1String("description")) {
254
2.94k
            if (xml.isStartElement()) {
255
2.51k
                inDescription = true;
256
2.51k
            } else if (xml.isEndElement()) {
257
432
                inDescription = false;
258
432
            }
259
528k
        } else if (xml.name() == QLatin1String("title-info")) {
260
556
            if (xml.isStartElement()) {
261
284
                inTitleInfo = true;
262
284
            } else if (xml.isEndElement()) {
263
268
                inTitleInfo = false;
264
268
            }
265
527k
        } 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
769k
        if (!inFictionBook) {
274
27.4k
            continue;
275
27.4k
        }
276
277
742k
        if (inDescription) {
278
3.34k
            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
738k
        } else {
294
738k
            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
738k
        }
302
742k
    }
303
304
4.56k
    return KIO::ThumbnailResult::fail();
305
4.56k
}
306
307
QStringList EbookCreator::getEntryList(const KArchiveDirectory *dir, const QString &path)
308
657k
{
309
657k
    QStringList list;
310
311
657k
    const QStringList entries = dir->entries();
312
682k
    for (const QString &name : entries) {
313
682k
        const KArchiveEntry *entry = dir->entry(name);
314
315
682k
        QString fullPath = name;
316
317
682k
        if (!path.isEmpty()) {
318
673k
            fullPath.prepend(QLatin1Char('/'));
319
673k
            fullPath.prepend(path);
320
673k
        }
321
322
682k
        if (entry->isFile()) {
323
25.6k
            list << fullPath;
324
656k
        } else {
325
656k
            list << getEntryList(static_cast<const KArchiveDirectory *>(entry), fullPath);
326
656k
        }
327
682k
    }
328
329
657k
    return list;
330
657k
}
331
332
#include "ebookcreator.moc"
333
#include "moc_ebookcreator.cpp"