Coverage Report

Created: 2026-02-26 07:48

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