Coverage Report

Created: 2026-10-28 07:09

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
27.6k
    : KIO::ThumbnailCreator(parent, args)
23
27.6k
{
24
27.6k
}
25
26
27.6k
EbookCreator::~EbookCreator() = default;
27
28
KIO::ThumbnailResult EbookCreator::create(const KIO::ThumbnailRequest &request)
29
27.6k
{
30
27.6k
    const QString path = request.url().toLocalFile();
31
32
27.6k
    if (request.mimeType() == QLatin1String("application/epub+zip")) {
33
3.74k
        return createEpub(path);
34
35
23.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
19.4k
    } else if (request.mimeType() == QLatin1String("application/x-zip-compressed-fb2")) {
44
3.18k
        KZip zip(path);
45
3.18k
        if (!zip.open(QIODevice::ReadOnly)) {
46
2.73k
            return KIO::ThumbnailResult::fail();
47
2.73k
        }
48
49
451
        QScopedPointer<QIODevice> zipDevice;
50
51
451
        const auto entries = zip.directory()->entries();
52
2.36k
        for (const QString &entryPath : entries) {
53
2.36k
            if (entries.count() > 1 && !entryPath.endsWith(QLatin1String(".fb2"))) { // can this done a bit more cleverly?
54
728
                continue;
55
728
            }
56
57
1.64k
            const auto *entry = zip.directory()->file(entryPath);
58
1.64k
            if (!entry) {
59
17
                return KIO::ThumbnailResult::fail();
60
17
            }
61
62
1.62k
            zipDevice.reset(entry->createDevice());
63
1.62k
        }
64
65
434
        return createFb2(zipDevice.data());
66
451
    }
67
68
16.2k
    return KIO::ThumbnailResult::fail();
69
27.6k
}
70
71
KIO::ThumbnailResult EbookCreator::createEpub(const QString &path)
72
3.74k
{
73
3.74k
    KZip zip(path);
74
3.74k
    if (!zip.open(QIODevice::ReadOnly)) {
75
557
        return KIO::ThumbnailResult::fail();
76
557
    }
77
78
3.18k
    QScopedPointer<QIODevice> zipDevice;
79
3.18k
    QString opfPath;
80
3.18k
    QString coverHref;
81
82
    // First figure out where the OPF file with metadata is
83
3.18k
    const auto *entry = zip.directory()->file(QStringLiteral("META-INF/container.xml"));
84
85
3.18k
    if (!entry) {
86
9
        return KIO::ThumbnailResult::fail();
87
9
    }
88
89
3.17k
    zipDevice.reset(entry->createDevice());
90
91
3.17k
    QXmlStreamReader xml(zipDevice.data());
92
17.2k
    while (!xml.atEnd() && !xml.hasError()) {
93
16.0k
        xml.readNext();
94
95
16.0k
        if (xml.isStartElement() && xml.name() == QLatin1String("rootfile")) {
96
1.88k
            opfPath = xml.attributes().value(QStringLiteral("full-path")).toString();
97
1.88k
            break;
98
1.88k
        }
99
16.0k
    }
100
101
3.17k
    if (opfPath.isEmpty()) {
102
1.29k
        return KIO::ThumbnailResult::fail();
103
1.29k
    }
104
105
    // Now read the OPF file and look for a <meta name="cover" content="...">
106
1.87k
    entry = zip.directory()->file(opfPath);
107
1.87k
    if (!entry) {
108
6
        return KIO::ThumbnailResult::fail();
109
6
    }
110
111
1.87k
    zipDevice.reset(entry->createDevice());
112
113
1.87k
    xml.setDevice(zipDevice.data());
114
115
1.87k
    bool inMetadata = false;
116
1.87k
    bool inManifest = false;
117
1.87k
    QString coverId;
118
1.87k
    QMap<QString, QString> itemHrefs;
119
120
15.8k
    while (!xml.atEnd() && !xml.hasError()) {
121
13.9k
        xml.readNext();
122
123
13.9k
        if (xml.name() == QLatin1String("metadata")) {
124
298
            if (xml.isStartElement()) {
125
269
                inMetadata = true;
126
269
            } else if (xml.isEndElement()) {
127
25
                inMetadata = false;
128
25
            }
129
298
            continue;
130
298
        }
131
132
13.6k
        if (xml.name() == QLatin1String("manifest")) {
133
186
            if (xml.isStartElement()) {
134
184
                inManifest = true;
135
184
            } else if (xml.isEndElement()) {
136
1
                inManifest = false;
137
1
            }
138
186
            continue;
139
186
        }
140
141
13.4k
        if (xml.isStartElement()) {
142
4.79k
            if (inMetadata && (xml.name() == QLatin1String("meta"))) {
143
1.31k
                const auto attributes = xml.attributes();
144
1.31k
                if (attributes.value(QStringLiteral("name")) == QLatin1String("cover")) {
145
0
                    coverId = attributes.value(QStringLiteral("content")).toString();
146
0
                }
147
3.47k
            } else if (inManifest && (xml.name() == QLatin1String("item"))) {
148
541
                const auto attributes = xml.attributes();
149
541
                const QString href = attributes.value(QStringLiteral("href")).toString();
150
541
                const QString id = attributes.value(QStringLiteral("id")).toString();
151
541
                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
2.93k
            } else {
162
2.93k
                continue;
163
2.93k
            }
164
165
1.85k
            if (!coverId.isEmpty() && itemHrefs.contains(coverId)) {
166
0
                coverHref = itemHrefs[coverId];
167
0
                break;
168
0
            }
169
1.85k
        }
170
13.4k
    }
171
172
1.87k
    if (coverHref.isEmpty()) {
173
        // Maybe we're lucky and the archive contains an iTunesArtwork file from iBooks
174
1.87k
        entry = zip.directory()->file(QStringLiteral("iTunesArtwork"));
175
1.87k
        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.87k
        const QStringList entries = getEntryList(zip.directory(), QString());
186
187
38.1k
        for (const QString &name : entries) {
188
38.1k
            if (!name.contains(QLatin1String("cover"), Qt::CaseInsensitive)) {
189
5.25k
                continue;
190
5.25k
            }
191
192
32.8k
            entry = zip.directory()->file(name);
193
32.8k
            if (!entry) {
194
18
                continue;
195
18
            }
196
197
32.8k
            zipDevice.reset(entry->createDevice());
198
199
32.8k
            QImage image;
200
32.8k
            bool success = image.load(zipDevice.data(), "");
201
202
32.8k
            if (success) {
203
86
                return KIO::ThumbnailResult::pass(image);
204
86
            }
205
32.8k
        }
206
1.78k
        return KIO::ThumbnailResult::fail();
207
1.87k
    }
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.86k
{
235
4.86k
    QString coverId;
236
237
4.86k
    QXmlStreamReader xml(device);
238
239
4.86k
    bool inFictionBook = false;
240
4.86k
    bool inDescription = false;
241
4.86k
    bool inTitleInfo = false;
242
4.86k
    bool inCoverPage = false;
243
244
677k
    while (!xml.atEnd() && !xml.hasError()) {
245
672k
        xml.readNext();
246
247
672k
        if (xml.name() == QLatin1String("FictionBook")) {
248
101k
            if (xml.isStartElement()) {
249
101k
                inFictionBook = true;
250
101k
            } else if (xml.isEndElement()) {
251
1
                break;
252
1
            }
253
570k
        } else if (xml.name() == QLatin1String("description")) {
254
950
            if (xml.isStartElement()) {
255
607
                inDescription = true;
256
607
            } else if (xml.isEndElement()) {
257
340
                inDescription = false;
258
340
            }
259
569k
        } else if (xml.name() == QLatin1String("title-info")) {
260
593
            if (xml.isStartElement()) {
261
304
                inTitleInfo = true;
262
304
            } else if (xml.isEndElement()) {
263
284
                inTitleInfo = false;
264
284
            }
265
569k
        } 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
672k
        if (!inFictionBook) {
274
22.2k
            continue;
275
22.2k
        }
276
277
650k
        if (inDescription) {
278
1.07k
            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
649k
        } else {
294
649k
            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
649k
        }
302
650k
    }
303
304
4.86k
    return KIO::ThumbnailResult::fail();
305
4.86k
}
306
307
QStringList EbookCreator::getEntryList(const KArchiveDirectory *dir, const QString &path)
308
978k
{
309
978k
    QStringList list;
310
311
978k
    const QStringList entries = dir->entries();
312
1.01M
    for (const QString &name : entries) {
313
1.01M
        const KArchiveEntry *entry = dir->entry(name);
314
315
1.01M
        QString fullPath = name;
316
317
1.01M
        if (!path.isEmpty()) {
318
1.00M
            fullPath.prepend(QLatin1Char('/'));
319
1.00M
            fullPath.prepend(path);
320
1.00M
        }
321
322
1.01M
        if (entry->isFile()) {
323
38.2k
            list << fullPath;
324
976k
        } else {
325
976k
            list << getEntryList(static_cast<const KArchiveDirectory *>(entry), fullPath);
326
976k
        }
327
1.01M
    }
328
329
978k
    return list;
330
978k
}
331
332
#include "ebookcreator.moc"
333
#include "moc_ebookcreator.cpp"