Coverage Report

Created: 2026-01-25 07:18

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
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"