diff --git a/src/embedded.cpp b/src/embedded.cpp new file mode 100644 index 00000000..ac373ad9 --- /dev/null +++ b/src/embedded.cpp @@ -0,0 +1,354 @@ +#include "embedded.h" +#include "rawembed.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// The end record is 22 bytes and a comment of at most 65535 follows it, so the record lies in the file's last 65557. +constexpr qint64 kEndRecord = 22, kLongestComment = 0xFFFF; +// More directory entries or bytes than this is no container a person made; a big comic book holds a few thousand pages. +constexpr quint32 kMostEntries = 8192, kMostDirectoryBytes = 8 * 1048576; +// A member stored or inflated past this is no thumbnail; inflation stops here whatever the header claims. +constexpr quint32 kMostMemberBytes = 64 * 1048576; +constexpr quint32 kEndSignature = 0x06054B50, kEntrySignature = 0x02014B50, kLocalSignature = 0x04034B50; +// A zip64 end locator stands 20 bytes ahead of the end record, and all-ones fields send a reader to zip64's own. +constexpr quint32 kZip64Locator = 0x07064B50, kZip64Word = 0xFFFFFFFF; +constexpr qint64 kLocatorLength = 20; +constexpr quint16 kZip64Half = 0xFFFF; +constexpr qint64 kEntryLength = 46, kLocalLength = 30; +// General-purpose bit 0 marks an encrypted member; method 0 stores it, 8 deflates it. +constexpr quint16 kEncrypted = 0x0001, kStored = 0, kDeflated = 8; +// Raw deflate, with no zlib header or trailer, as a zip holds it. +constexpr int kRawDeflate = -MAX_WBITS; + +const QString kEpub = QStringLiteral("application/epub+zip"), kComic = QStringLiteral("application/vnd.comicbook+zip"); +const QString kOpenDocument = QStringLiteral("application/vnd.oasis.opendocument."); +const QString kOfficeOpenXml = QStringLiteral("application/vnd.openxmlformats-officedocument."); +const QString k3mf = QStringLiteral("model/3mf"), kKrita = QStringLiteral("application/x-krita"); +const QString kOpenRaster = QStringLiteral("image/openraster"), kFreeCad = QStringLiteral("application/x-extension-fcstd"); +// A comic book's pages are its pictures, by the ending of their names. +const QByteArrayList kPageEndings = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}; + +// The fonts drawn from their own bytes: TrueType, OpenType, collections of either, WOFF and Type 1. +const QStringList kFonts = {"font/ttf", "font/otf", "font/collection", "font/woff", "font/woff2", "application/x-font-type1"}; +// An sfnt opens with "OTTO" for CFF outlines or 00 01 00 00 for TrueType ones. +constexpr qint64 kSignatureLength = 4; +const QByteArray kCffSignature("OTTO"), kTrueTypeSignature("\0\1\0\0", kSignatureLength); +const QString kFormulaTemplate = QStringLiteral("application/vnd.oasis.opendocument.formula-template"); +// A font file past this is held as no font; the largest collections people install are a few tens of MiB. +constexpr qint64 kMostFontBytes = 64 * 1048576; +// A font's letters fill this share of their square on their longer side. +constexpr qreal kFontFill = 0.8; +// Characters are looked for from "!", the first printable one, to the last code point there is. +constexpr char32_t kFirstCharacter = 0x21, kLastCharacter = 0x10FFFF; + +quint16 rd16(const char *p) { return qFromLittleEndian(p); } +quint32 rd32(const char *p) { return qFromLittleEndian(p); } + +// The file at `path` when it is a regular one, opened without waiting: a FIFO or device would hold the worker. +bool openRegular(QFile &file, const QString &path) { + const int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + struct stat status = {}; + if (fd >= 0 && ::fstat(fd, &status) == 0 && S_ISREG(status.st_mode) + && file.open(fd, QIODevice::ReadOnly | QIODevice::Unbuffered, QFileDevice::AutoCloseHandle)) + return true; + if (fd >= 0) + ::close(fd); + return false; +} + +// `length` bytes at `offset`, or none when the file holds fewer. +QByteArray at(QFile &file, qint64 offset, qint64 length) { + if (offset < 0 || length < 0 || offset > file.size() - length || !file.seek(offset)) + return {}; + const QByteArray out = file.read(length); + return out.size() == length ? out : QByteArray(); +} + +// Where a member lies, as its directory entry says. +struct Member { + quint16 flags = 0, method = 0; + quint32 compressed = 0, size = 0, local = 0; +}; + +// 50 4B 05 06, disk(2) directory's disk(2) entries on this disk(2) entries(2) directory bytes(4) offset(4) comment length(2). +bool endRecord(QFile &file, quint32 &count, quint32 &bytes, quint32 &offset) { + const qint64 tail = qMin(file.size(), kEndRecord + kLongestComment); + const QByteArray end = at(file, file.size() - tail, tail); + // The last signature whose comment ends with the file; a comment may hold the signature itself. + qsizetype found = end.size() - kEndRecord; + for (; found >= 0; --found) { + const char *e = end.constData() + found; + if (rd32(e) == kEndSignature && found + kEndRecord + rd16(e + 20) == end.size()) + break; + } + if (found < 0) + return false; + const char *e = end.constData() + found; + count = rd16(e + 10); + bytes = rd32(e + 12); + offset = rd32(e + 16); + // A split archive, or one whose real numbers are in zip64 records, is refused rather than misread. + const bool zip64 = (found >= kLocatorLength && rd32(e - kLocatorLength) == kZip64Locator) || count == kZip64Half + || bytes == kZip64Word || offset == kZip64Word; + return !zip64 && rd16(e + 4) == 0 && rd16(e + 6) == 0 && rd16(e + 8) == count && count <= kMostEntries + && bytes <= kMostDirectoryBytes; +} + +// 50 4B 01 02, versions(4) flags(2) method(2) time(4) crc(4) compressed(4) size(4) name(2) extra(2) comment(2) disk(2) attributes(6) local(4) name. +bool directory(QFile &file, QMap &members) { + quint32 count = 0, bytes = 0, offset = 0; + if (!endRecord(file, count, bytes, offset)) + return false; + const QByteArray entries = at(file, offset, bytes); + if (entries.size() != qsizetype(bytes)) + return false; + qsizetype p = 0; + for (quint32 i = 0; i < count; ++i) { + const char *e = entries.constData() + p; + if (p + kEntryLength > entries.size() || rd32(e) != kEntrySignature + || p + kEntryLength + rd16(e + 28) > entries.size()) + return false; + // A name is only ever compared with the names a format fixes, never made into a path. + members.insert(QByteArray(e + kEntryLength, rd16(e + 28)), {rd16(e + 8), rd16(e + 10), rd32(e + 20), rd32(e + 24), rd32(e + 42)}); + p += kEntryLength + rd16(e + 28) + rd16(e + 30) + rd16(e + 32); + } + return true; +} + +// 50 4B 03 04, version(2) flags(2) method(2) time(4) crc(4) sizes(8) name(2) extra(2), then the name, extra and data. +QByteArray read(QFile &file, const Member &member) { + // Sizes are refused before anything is allocated; zip64's all-ones fields are past the limit too. + if ((member.flags & kEncrypted) || member.compressed > kMostMemberBytes || member.size > kMostMemberBytes + || member.local == kZip64Word || (member.method != kStored && member.method != kDeflated)) + return {}; + const QByteArray head = at(file, member.local, kLocalLength); + if (head.size() != kLocalLength || rd32(head.constData()) != kLocalSignature) + return {}; + const qint64 start = qint64(member.local) + kLocalLength + rd16(head.constData() + 26) + rd16(head.constData() + 28); + const QByteArray data = at(file, start, member.compressed); + if (data.size() != qsizetype(member.compressed)) + return {}; + if (member.method == kStored) + return member.size == member.compressed ? data : QByteArray(); + // Inflated into the size the entry claims and no further: a stream that would run past it is refused. + QByteArray out(qsizetype(member.size), Qt::Uninitialized); + z_stream stream = {}; + if (inflateInit2(&stream, kRawDeflate) != Z_OK) + return {}; + stream.next_in = reinterpret_cast(const_cast(data.constData())); + stream.avail_in = uInt(data.size()); + stream.next_out = reinterpret_cast(out.data()); + stream.avail_out = uInt(out.size()); + const int status = inflate(&stream, Z_FINISH); + const bool whole = status == Z_STREAM_END && stream.total_out == member.size; + inflateEnd(&stream); + return whole ? out : QByteArray(); +} + +// The member name a reference makes from the part at `from`: "../images/cover.jpg" from "OEBPS/text/a.opf" is "OEBPS/images/cover.jpg". +QByteArray resolved(const QString &from, const QString &reference) { + const QString name = QUrl::fromPercentEncoding(reference.toUtf8()), folder = from.section('/', 0, -2); + const QString path = QDir::cleanPath(name.startsWith('/') || folder.isEmpty() ? name : folder + '/' + name); + return (path.startsWith('/') ? path.mid(1) : path).toUtf8(); +} + +// +QString packagePath(const QByteArray &container) { + QXmlStreamReader xml(container); + while (!xml.atEnd()) + if (xml.readNext() == QXmlStreamReader::StartElement && xml.name() == u"rootfile") + return xml.attributes().value("full-path").toString(); + return {}; +} + +// , or EPUB 2's naming an item. +QString coverReference(const QByteArray &package) { + QXmlStreamReader xml(package); + QHash items; + QString named; + while (!xml.atEnd()) { + if (xml.readNext() != QXmlStreamReader::StartElement) + continue; + const QXmlStreamAttributes attributes = xml.attributes(); + if (xml.name() == u"item" && attributes.value("properties").toString().split(' ').contains("cover-image")) + return attributes.value("href").toString(); + if (xml.name() == u"item") + items.insert(attributes.value("id").toString(), attributes.value("href").toString()); + else if (xml.name() == u"meta" && attributes.value("name") == u"cover") + named = attributes.value("content").toString(); + } + return items.value(named); +} + +// +QString thumbnailTarget(const QByteArray &relationships) { + QXmlStreamReader xml(relationships); + while (!xml.atEnd()) + if (xml.readNext() == QXmlStreamReader::StartElement && xml.name() == u"Relationship" + && xml.attributes().value("Type").endsWith(u"/metadata/thumbnail")) + return xml.attributes().value("Target").toString(); + return {}; +} + +// A comic book's page: a picture by its ending, outside the __MACOSX folder a Mac's archiver adds. +bool isPage(const QByteArray &name) { + const QByteArray lower = name.toLower(); + return !lower.startsWith("__macosx/") + && std::any_of(kPageEndings.cbegin(), kPageEndings.cend(), [&](const QByteArray &ending) { return lower.endsWith(ending); }); +} + +// The members a type keeps its picture in under the names its format fixes, best first. +QByteArrayList fixedNames(const QString &mime, const QString &path) { + if (mime.startsWith(kOpenDocument)) + return {"Thumbnails/thumbnail.png"}; + if (mime.startsWith(kOfficeOpenXml)) + return {"docProps/thumbnail.jpeg"}; + if (mime == k3mf) + return {"Metadata/thumbnail.png"}; + if (mime == kKrita) + return {"mergedimage.png", "preview.png"}; + if (mime == kOpenRaster) + return {"mergedimage.png", "Thumbnails/thumbnail.png"}; + // shared-mime-info has no type for FreeCAD's own files; FreeCAD installs one. + if (mime == kFreeCad || path.endsWith(".fcstd", Qt::CaseInsensitive)) + return {"thumbnails/Thumbnail.png"}; + return {}; +} + +// A member's picture, upright and fitted to `pixels`; one that claims more pixels than a RAW preview may is refused undecoded. +QImage picture(QByteArray bytes, int pixels) { + QBuffer buffer(&bytes); + if (bytes.isEmpty() || !buffer.open(QIODevice::ReadOnly)) + return {}; + QImageReader reader(&buffer); + reader.setAutoTransform(true); + const QSize full = reader.size(); + if (!full.isValid() || quint64(full.width()) * quint64(full.height()) > RawEmbed::kMaxPixels) + return {}; + if (full.width() > pixels || full.height() > pixels) + reader.setScaledSize(full.scaled(pixels, pixels, Qt::KeepAspectRatio)); + return reader.read(); +} + +// The glyph a font draws `c` with, when it maps it to one that has an outline. +std::optional glyphFor(const QRawFont &font, char32_t c) { + const QList glyphs = font.supportsCharacter(c) ? font.glyphIndexesForString(QString::fromUcs4(&c, 1)) : QList(); + if (glyphs.size() != 1 || font.pathForGlyph(glyphs.first()).isEmpty()) + return {}; + return glyphs.first(); +} + +// "Aa" when the font draws both, else its first two letters by code point, else its first two drawable characters. +QList sample(const QRawFont &font) { + const std::optional upper = glyphFor(font, 'A'), lower = glyphFor(font, 'a'); + if (upper && lower) + return {*upper, *lower}; + QList letters, drawable; + // At most a lookup in the font's character map for each letter Unicode has, about 146,000 of them: a few milliseconds, once per file. + for (char32_t c = kFirstCharacter; c <= kLastCharacter && letters.size() < 2; ++c) { + const bool letter = QChar::isLetter(c); + if ((!letter && drawable.size() == 2) || !QChar::isPrint(c) || QChar::isSpace(c)) + continue; + if (const std::optional glyph = glyphFor(font, c)) { + if (letter) + letters << *glyph; + if (drawable.size() < 2) + drawable << *glyph; + } + } + return letters.size() == 2 ? letters : drawable; +} + +// The font's sample black on a white square of `pixels`, made from the file's bytes and never added to Qt's list of fonts. +QImage fontSample(QFile &file, int pixels) { + if (file.size() > kMostFontBytes) + return {}; + const QRawFont font(at(file, 0, file.size()), pixels, QFont::PreferNoHinting); + if (!font.isValid()) + return {}; + const QList glyphs = sample(font); + const QList advances = font.advancesForGlyphIndexes(glyphs); + QPainterPath letters; + qreal x = 0; + for (qsizetype i = 0; i < glyphs.size() && i < advances.size(); ++i) { + letters.addPath(font.pathForGlyph(glyphs[i]).translated(x, 0)); + x += advances[i].x(); + } + const QRectF box = letters.boundingRect(); + if (box.isEmpty()) + return {}; + QImage page(pixels, pixels, QImage::Format_ARGB32_Premultiplied); + page.fill(Qt::white); + QPainter painter(&page); + painter.setRenderHint(QPainter::Antialiasing); + const qreal scale = kFontFill * pixels / qMax(box.width(), box.height()); + painter.translate(pixels / 2.0, pixels / 2.0); + painter.scale(scale, scale); + painter.translate(-box.center()); + painter.fillPath(letters, Qt::black); + painter.end(); + return page; +} + +} // namespace + +bool Embedded::handles(const QString &mime, const QString &path) { + return mime == kEpub || mime == kComic || kFonts.contains(mime) || !fixedNames(mime, path).isEmpty(); +} + +QImage Embedded::thumbnail(const QString &path, const QString &mime, int pixels) { + QFile file; + if (!openRegular(file, path)) + return {}; + // ".otf" names an OpenType font and an OpenDocument formula template alike, and the MIME database answers the template. + const QByteArray signature = at(file, 0, kSignatureLength); + if (kFonts.contains(mime) || (mime == kFormulaTemplate && (signature == kCffSignature || signature == kTrueTypeSignature))) + return fontSample(file, pixels); + QMap members; + if (!directory(file, members)) + return {}; + const auto part = [&](const QByteArray &name) { + const auto it = members.constFind(name); + return it == members.cend() ? QByteArray() : read(file, *it); + }; + QByteArrayList names; + if (mime == kEpub) { + // The package that container.xml names lists the cover among its items. + const QString package = packagePath(part("META-INF/container.xml")); + if (const QString cover = coverReference(part(package.toUtf8())); !package.isEmpty() && !cover.isEmpty()) + names << resolved(package, cover); + } else if (mime == kComic) { + // The first page by name order, as the members are kept. + for (auto it = members.cbegin(); it != members.cend() && names.isEmpty(); ++it) + if (isPage(it.key())) + names << it.key(); + } else { + // An Office Open XML or 3MF package may name its thumbnail as a relationship of its own, ahead of the usual name. + if (mime.startsWith(kOfficeOpenXml) || mime == k3mf) + if (const QString target = thumbnailTarget(part("_rels/.rels")); !target.isEmpty()) + names << resolved({}, target); + names += fixedNames(mime, path); + } + for (const QByteArray &name : names) + if (const QImage image = picture(part(name), pixels); !image.isNull()) + return image; + return {}; +} diff --git a/src/embedded.h b/src/embedded.h new file mode 100644 index 00000000..31560a28 --- /dev/null +++ b/src/embedded.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Thumbnails from a file's own bytes: the picture an EPUB, comic book, OpenDocument, Office Open XML, 3MF, FreeCAD, Krita or OpenRaster file keeps in its zip, or a font's own letters. +namespace Embedded { + +bool handles(const QString &mime, const QString &path); + +// The kept picture, upright and at most `pixels` on its longer side, or the font's letters on a square of `pixels`; null when there is none. +QImage thumbnail(const QString &path, const QString &mime, int pixels); + +} // namespace Embedded diff --git a/src/foldermodel.cpp b/src/foldermodel.cpp index 2cce5a3f..8ff992a4 100644 --- a/src/foldermodel.cpp +++ b/src/foldermodel.cpp @@ -212,6 +212,8 @@ QString FolderModel::kindForMime(const QString &mime) { if (mime.startsWith("image/")) return "image"; if (mime.startsWith("video/")) return "video"; if (mime.startsWith("audio/")) return "audio"; + // A Type 1 font is PostScript, and so text, to the MIME database; it is shown as the font it is. + if (mime == "application/x-font-type1") return "other"; // Anything readable as text (source code, config, JSON, Ruby, ...) is // text, whatever its own type is called. static const QMimeDatabase types; diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0e..68345dbd 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -36,6 +36,9 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { } if (mime == "text/markdown") return "markdown"; + // A Type 1 font is PostScript, and so text, to the MIME database; it shows its sample like every other font, never its source. + if (mime == "application/x-font-type1") + return "other"; const QMimeType type = QMimeDatabase().mimeTypeForName(mime); if (mime.startsWith("text/") || type.inherits("text/plain")) return "text"; diff --git a/src/rawembed.cpp b/src/rawembed.cpp index 5f192187..f78c38a6 100644 --- a/src/rawembed.cpp +++ b/src/rawembed.cpp @@ -31,8 +31,7 @@ constexpr quint64 kEntryLength = 12; constexpr quint64 kMaxValue = 65536; // SubIFD fan-out is capped, so one count cannot queue unbounded work. constexpr quint32 kMaxPointers = 64; -// No camera's preview holds more pixels than this; a frame header that claims more would cost a vast decode for nothing. -constexpr quint64 kMaxPixels = 250000000; +using RawEmbed::kMaxPixels; // The largest embedded JPEG read into memory; a real one is a few MiB. constexpr quint64 kMaxJpeg = 64 * 1048576; // SOI plus one frame header: anything shorter is no picture. diff --git a/src/rawembed.h b/src/rawembed.h index cbdec325..81ff48d3 100644 --- a/src/rawembed.h +++ b/src/rawembed.h @@ -8,6 +8,9 @@ namespace RawEmbed { bool handles(const QString &mime); +// No camera's preview holds more pixels than this; a frame header that claims more would cost a vast decode for nothing. +inline constexpr quint64 kMaxPixels = 250000000; + // The largest valid embedded JPEG, upright and at most `pixels` on its longer side; null when the file holds none. QImage thumbnail(const QString &path, int pixels); diff --git a/src/thumbnails.cpp b/src/thumbnails.cpp index 3613a6d9..78b64efb 100644 --- a/src/thumbnails.cpp +++ b/src/thumbnails.cpp @@ -1,4 +1,5 @@ #include "thumbnails.h" +#include "embedded.h" #include "rawembed.h" #include @@ -321,9 +322,9 @@ Thumbnails::Thumbnails(const QString &cacheDirectory, const QStringList &thumbna } QString Thumbnails::missingPackage(const QString &mime) const { - // Qt draws its own pictures in-process, an installed thumbnailer needs nothing more, and pdftoppm draws a PDF without one. + // Qt draws its own pictures in-process, as Folio draws a picture a file keeps, an installed thumbnailer needs nothing more, and pdftoppm draws a PDF without one. if ((mime.startsWith("image/") && QImageReader::supportedMimeTypes().contains(mime.toLatin1())) - || m_thumbnailers.contains(mime) + || Embedded::handles(mime, {}) || m_thumbnailers.contains(mime) || (mime == "application/pdf" && !QStandardPaths::findExecutable("pdftoppm").isEmpty())) return {}; return packageFor().value(mime); @@ -403,10 +404,11 @@ QImage Thumbnails::load(const QString &path, const QString &mime, int size) cons } QImage Thumbnails::generate(const QString &path, const QString &mime, int pixels, bool &attempted) const { - // A camera RAW with no installed thumbnailer, the desktop's own answer, shows the JPEG the camera embedded. - if (RawEmbed::handles(mime) && !m_thumbnailers.contains(mime)) { + // A camera RAW, a file that keeps its picture in a zip, or a font, with no installed thumbnailer, the desktop's own answer, is drawn from its own bytes. + if ((RawEmbed::handles(mime) || Embedded::handles(mime, path)) && !m_thumbnailers.contains(mime)) { attempted = true; - if (const QImage image = RawEmbed::thumbnail(path, pixels); !image.isNull()) + const QImage image = RawEmbed::handles(mime) ? RawEmbed::thumbnail(path, pixels) : Embedded::thumbnail(path, mime, pixels); + if (!image.isNull()) return image; } // Common photo formats decode fastest in-process; anything Qt can't read diff --git a/src/thumbnails.h b/src/thumbnails.h index 07435888..fdba3f04 100644 --- a/src/thumbnails.h +++ b/src/thumbnails.h @@ -34,8 +34,9 @@ class Thumbnails : public QObject { static QString cacheKey(const QString &path); // The spec's fail/-: the number is raised when Folio learns to draw more, so old failures are tried - // again. 3: names with a thumbnailer's codes in them, and PDFs whose thumbnailer couldn't start, are drawn now. - static inline const QString failures = QStringLiteral("fail/folio-3"); + // again. 3: names with a thumbnailer's codes in them, and PDFs whose thumbnailer couldn't start, are drawn now. 4: the + // picture a zip keeps inside, as an EPUB or an OpenDocument does. + static inline const QString failures = QStringLiteral("fail/folio-4"); static QString bucketFor(int size); // A text file's first lines set on a page in the given colors. Made // fresh each time rather than cached, since the colors follow the theme. diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 51803520..783c1c86 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -24,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -36,9 +39,11 @@ #include #include #include +#include #include "backend.h" #include "disks.h" +#include "embedded.h" #include "fileops.h" #include "foldermodel.h" #include "mounter.h" @@ -6134,6 +6139,254 @@ private slots: QVERIFY(thumbnails.load(dir.filePath("photo.tif"), "image/tiff", 1024).isNull()); } + void thumbnailsShowThePictureAFileKeeps() + { + QTemporaryDir dir; + const Thumbnails thumbnails(dir.filePath("cache"), {dir.filePath("none")}); + // Each file keeps a cover of 60 by 90 beside another picture of 40 by 40, so the size says which one is shown. + const QByteArray cover = jpeg(60, 90), other = jpeg(40, 40); + const auto shown = [&](const QString &name, const QString &mime, const QList &members) { + write(dir.filePath(name), zipOf(members)); + return thumbnails.load(dir.filePath(name), mime, 128).size(); + }; + const auto container = [](const QByteArray &package) -> QByteArray { + return "" + ""; + }; + // EPUB 3 marks its cover with a property, and the package container.xml names holds its items. + QCOMPARE(shown("book.epub", "application/epub+zip", + {{"mimetype", "application/epub+zip"}, {"META-INF/container.xml", container("OEBPS/content.opf"), true}, + {"OEBPS/content.opf", "" + "", true}, + {"OEBPS/art/page.jpg", other}, {"OEBPS/art/front cover.jpg", cover}}), + QSize(60, 90)); + // EPUB 2 names its cover's item in a meta, and the item names the picture from the package's own folder. + QCOMPARE(shown("old.epub", "application/epub+zip", + {{"META-INF/container.xml", container("text/book.opf")}, + {"text/book.opf", "" + "" + ""}, + {"images/page.jpg", other}, {"images/cover.jpg", cover, true}}), + QSize(60, 90)); + // A comic book shows its first page by name, past folders, other files and a Mac's resource forks. + QCOMPARE(shown("issue.cbz", "application/vnd.comicbook+zip", + {{"pages/", ""}, {"pages/002.jpg", other}, {"ComicInfo.xml", ""}, + {"__MACOSX/pages/._001.jpg", other}, {"pages/001.JPG", cover, true}}), + QSize(60, 90)); + QCOMPARE(shown("notes.odt", "application/vnd.oasis.opendocument.text", + {{"mimetype", "application/vnd.oasis.opendocument.text"}, {"content.xml", "", true}, + {"Thumbnails/thumbnail.png", cover}}), + QSize(60, 90)); + // Office Open XML and 3MF name the picture as the package's thumbnail relationship first, then by the usual name. + const auto relationships = [](const QByteArray &target) -> QByteArray { + return "" + ""; + }; + QCOMPARE(shown("letter.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + {{"_rels/.rels", relationships("/docProps/front.jpeg"), true}, {"docProps/thumbnail.jpeg", other}, + {"docProps/front.jpeg", cover}}), + QSize(60, 90)); + QCOMPARE(shown("sums.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + {{"xl/workbook.xml", "", true}, {"docProps/thumbnail.jpeg", cover}}), + QSize(60, 90)); + QCOMPARE(shown("plate.3mf", "model/3mf", + {{"_rels/.rels", relationships("/Metadata/plate_1.png")}, {"Metadata/thumbnail.png", other}, + {"Metadata/plate_1.png", cover}}), + QSize(60, 90)); + QCOMPARE(shown("bracket.3mf", "model/3mf", {{"3D/3dmodel.model", "", true}, {"Metadata/thumbnail.png", cover}}), + QSize(60, 90)); + // A FreeCAD file is known by its ending, since the MIME database has no type for it. + QCOMPARE(shown("part.FCStd", "application/octet-stream", {{"Document.xml", "", true}, {"thumbnails/Thumbnail.png", cover}}), + QSize(60, 90)); + // Krita and OpenRaster keep the whole picture merged, or a smaller preview. + QCOMPARE(shown("sketch.kra", "application/x-krita", {{"mergedimage.png", cover}, {"preview.png", other}}), QSize(60, 90)); + QCOMPARE(shown("small.kra", "application/x-krita", {{"maindoc.xml", "", true}, {"preview.png", other}}), QSize(40, 40)); + QCOMPARE(shown("layers.ora", "image/openraster", + {{"mimetype", "image/openraster"}, {"stack.xml", ""}, {"Thumbnails/thumbnail.png", cover}}), + QSize(60, 90)); + } + + void thumbnailsRememberAContainerWithoutItsPicture() + { + QTemporaryDir dir; + const Thumbnails thumbnails(dir.filePath("cache"), {dir.filePath("none")}); + const auto failed = [&](const QString &name) { + return QFile::exists(dir.filePath("cache/" + Thumbnails::failures + "/" + Thumbnails::cacheKey(dir.filePath(name)))); + }; + // A document saved without its picture, a book whose cover is not there and a file that is no zip show the plain + // icon, and are not read again until they change. + write(dir.filePath("letter.docx"), zipOf({{"word/document.xml", "", true}})); + QVERIFY(thumbnails.load(dir.filePath("letter.docx"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 128).isNull()); + QVERIFY(failed("letter.docx")); + write(dir.filePath("book.epub"), zipOf({{"META-INF/container.xml", ""}, + {"a.opf", ""}})); + QVERIFY(thumbnails.load(dir.filePath("book.epub"), "application/epub+zip", 128).isNull()); + QVERIFY(failed("book.epub")); + write(dir.filePath("broken.cbz"), "not a zip"); + QVERIFY(thumbnails.load(dir.filePath("broken.cbz"), "application/vnd.comicbook+zip", 128).isNull()); + QVERIFY(failed("broken.cbz")); + } + + void thumbnailsRefuseAHostileContainer() + { + QTemporaryDir dir; + const Thumbnails thumbnails(dir.filePath("cache"), {dir.filePath("none")}); + const QString odt = "application/vnd.oasis.opendocument.text"; + int files = 0; + // What an OpenDocument file of these bytes shows; each is a file of its own, so none is served from the cache. + const auto shown = [&](const QByteArray &zip) { + const QString path = dir.filePath(QString("doc%1.odt").arg(++files)); + write(path, zip); + return thumbnails.load(path, odt, 128).size(); + }; + const QByteArray picture = jpeg(40, 30); + const ZipMember thumbnail{"Thumbnails/thumbnail.png", picture, true}; + QCOMPARE(shown(zipOf({thumbnail})), QSize(40, 30)); + + // A FIFO named like a document is opened without waiting for a writer, and never read. + const QString fifo = dir.filePath("pipe.odt"); + QCOMPARE(mkfifo(QFile::encodeName(fifo).constData(), 0600), 0); + QVERIFY(Embedded::thumbnail(fifo, odt, 128).isNull()); + + // A directory of the most entries Folio walks gives the picture its last entry names; one entry more gives none. + const auto entries = [&](int count) { + QList members; + for (int i = 1; i < count; ++i) + members.append({QByteArray::number(i)}); + members.append(thumbnail); + return shown(zipOf(members)); + }; + QCOMPARE(entries(kMostZipEntries), QSize(40, 30)); + QVERIFY(entries(kMostZipEntries + 1).isEmpty()); + + // A member of the most bytes Folio holds is read, stored or deflated; one byte more is refused before it is held. + const QByteArray padded = picture + QByteArray(kMostMemberBytes - picture.size(), '\0'); + QCOMPARE(shown(zipOf({{thumbnail.name, padded}})), QSize(40, 30)); + QCOMPARE(shown(zipOf({{thumbnail.name, padded, true}})), QSize(40, 30)); + QVERIFY(shown(zipOf({{thumbnail.name, padded + '\0'}})).isEmpty()); + QVERIFY(shown(zipOf({{thumbnail.name, padded + '\0', true}})).isEmpty()); + + // A deflated member that inflates past the size its headers claim is stopped there, however little they claim. + const QByteArray bomb = picture + QByteArray(kMebibyte, '\0'); + QCOMPARE(shown(zipOf({{thumbnail.name, bomb, true}})), QSize(40, 30)); + QVERIFY(shown(zipOf({{thumbnail.name, bomb, true, 0, picture.size()}})).isEmpty()); + + // A zip64 locator ahead of the end record, a split archive and an encrypted member are refused, not misread. + QByteArray zip64 = zipOf({thumbnail}); + zip64.insert(zip64.size() - kEndRecordBytes, QByteArray("PK\x06\x07", 4) + QByteArray(16, '\0')); + QVERIFY(shown(zip64).isEmpty()); + QByteArray split = zipOf({thumbnail}); + split[split.size() - kEndRecordBytes + 4] = 1; + QVERIFY(shown(split).isEmpty()); + QVERIFY(shown(zipOf({{thumbnail.name, picture, true, 1}})).isEmpty()); + + // A picture whose frame header claims 20000 by 20000 pixels, more than a camera's preview may, is never decoded. + QByteArray vast = picture; + const qsizetype frame = vast.indexOf(QByteArray("\xFF\xC0", 2)); + QVERIFY(frame > 0); + vast.replace(frame + 5, 4, QByteArray("\x4E\x20\x4E\x20", 4)); + QVERIFY(shown(zipOf({{thumbnail.name, vast}})).isEmpty()); + + // A container behind a hole of 3 GiB is read at the offsets its directory gives, never as a whole. + const QString far = dir.filePath("far.odt"); + { + QFile file(far); + QVERIFY(file.open(QIODevice::WriteOnly) && file.resize(kFarHole) && file.seek(kFarHole)); + QVERIFY(file.write(zipOf({thumbnail}, kFarHole)) > 0); + } + QCOMPARE(thumbnails.load(far, odt, 128).size(), QSize(40, 30)); + + // A member name is only compared: a package named by a path on disk is no member, so the file there is never read. + write(dir.filePath("outside.opf"), ""); + write(dir.filePath("cover.jpg"), picture); + write(dir.filePath("book.epub"), zipOf({{"META-INF/container.xml", ""}, + {"cover.jpg", picture}})); + QVERIFY(thumbnails.load(dir.filePath("book.epub"), "application/epub+zip", 128).isNull()); + } + + void thumbnailsReadAContainerOnAWorkerUnlessCancelled() + { + QTemporaryDir dir; + // Slow thumbnailers fill every thread the provider runs, so the two asks after them wait their turn in its queue. + write(dir.filePath("slow.thumbnailer"), "[Thumbnailer Entry]\nExec=/bin/sh -c \"sleep 1\" %i\nMimeType=video/mp4;\n"); + const Thumbnails thumbnails(dir.filePath("cache"), {dir.path()}); + const QString odt = "application/vnd.oasis.opendocument.text"; + std::vector> responses; + // Declared after the responses, so its pool has finished every one of them before they go. + ThumbnailProvider provider(&thumbnails); + const auto ask = [&](const QString &name, const QString &mime) { + responses.emplace_back(provider.requestImageResponse( + QString::fromLatin1("128/0/" + QUrl::toPercentEncoding(mime) + "/" + QUrl::toPercentEncoding(dir.filePath(name))), {})); + return responses.back().get(); + }; + for (int i = 0; i < kMostThumbnailThreads; ++i) { + write(dir.filePath(QString("clip%1.mp4").arg(i)), "not a video"); + ask(QString("clip%1.mp4").arg(i), "video/mp4"); + } + write(dir.filePath("kept.odt"), zipOf({{"Thumbnails/thumbnail.png", jpeg(40, 30)}})); + write(dir.filePath("dropped.odt"), zipOf({{"Thumbnails/thumbnail.png", jpeg(40, 30)}})); + QQuickImageResponse *kept = ask("kept.odt", odt); + std::atomic where{nullptr}; + connect(kept, &QQuickImageResponse::finished, kept, [&] { where = QThread::currentThread(); }, Qt::DirectConnection); + QQuickImageResponse *dropped = ask("dropped.odt", odt); + dropped->cancel(); + QSignalSpy keptDone(kept, &QQuickImageResponse::finished), droppedDone(dropped, &QQuickImageResponse::finished); + QTRY_VERIFY_WITH_TIMEOUT(keptDone.count() == 1 && droppedDone.count() == 1, 20000); + // The picture is read on the provider's worker, never the thread that asked; the one cancelled before its turn is never read. + QVERIFY(where.load() && where.load() != QThread::currentThread()); + const std::unique_ptr picture(kept->textureFactory()); + QVERIFY(picture); + QCOMPARE(picture->image().size(), QSize(40, 30)); + QCOMPARE(dropped->errorString(), QString("No thumbnail")); + QVERIFY(!QFile::exists(dir.filePath("cache/normal/" + Thumbnails::cacheKey(dir.filePath("dropped.odt"))))); + QVERIFY(QFile::exists(dir.filePath("cache/normal/" + Thumbnails::cacheKey(dir.filePath("kept.odt"))))); + } + + void thumbnailsDrawAFontInItsOwnLetters() + { + QTemporaryDir dir; + const Thumbnails thumbnails(dir.filePath("cache"), {dir.filePath("none")}); + const auto drawn = [&](const QString &name, const QString &mime, const QByteArray &bytes) { + write(dir.filePath(name), bytes); + return thumbnails.load(dir.filePath(name), mime, 128); + }; + // Glyph 1 is a triangle and glyph 2 a square, so which characters were drawn shows in the picture. + const auto tables = [](const QList> &characters) { + return fontTables("Folio Test Glyphs", characters); + }; + const QList> aa = {{u'0', 2}, {u'A', 1}, {u'B', 1}, {u'a', 2}}; + QCOMPARE(QRawFont(sfnt(tables(aa)), 12).familyName(), QString("Folio Test Glyphs")); + const QImage letters = drawn("glyphs.ttf", "font/ttf", sfnt(tables(aa))); + QCOMPARE(letters.size(), QSize(128, 128)); + QImage blank(letters.size(), letters.format()); + blank.fill(Qt::white); + QVERIFY(letters != blank); + // Drawn from its bytes alone: the family never joins the fonts Qt lists for the whole of Folio. + QVERIFY(!QFontDatabase::families().contains("Folio Test Glyphs")); + // Without "Aa" a font shows its first two letters, past a digit; without two letters, its first two characters. + // An .otf is drawn whichever type the MIME database gives it: an OpenDocument formula template shares the ending. + const QString otf = QMimeDatabase().mimeTypeForFile(dir.filePath("letters.otf"), QMimeDatabase::MatchExtension).name(); + QCOMPARE(drawn("letters.otf", otf, sfnt(tables({{u'0', 2}, {u'B', 1}, {u'c', 2}}))), letters); + QCOMPARE(drawn("formula.otf", "application/vnd.oasis.opendocument.formula-template", + zipOf({{"Thumbnails/thumbnail.png", jpeg(40, 30)}})).size(), QSize(40, 30)); + const QImage digits = drawn("digits.ttf", "font/ttf", sfnt(tables({{u'0', 2}, {u'1', 1}}))); + QVERIFY(digits != blank && digits != letters); + // A collection shows its first font, and a WOFF the font it wraps. + QCOMPARE(drawn("family.ttc", "font/collection", QByteArray("ttcf\0\1\0\0\0\0\0\1\0\0\0\x10", 16) + sfnt(tables(aa), 16)), letters); + QCOMPARE(drawn("web.woff", "font/woff", woff(tables(aa))), letters); + // A font with nothing to draw, and bytes that are no font, show the plain icon. + QVERIFY(drawn("empty.ttf", "font/ttf", sfnt(tables({}))).isNull()); + QVERIFY(drawn("broken.ttf", "font/ttf", "not a font").isNull()); + // A Type 1 font is asked for as a font, never set on a page as the PostScript it is written in. + QCOMPARE(FolderModel::kindForMime("application/x-font-type1"), QString("other")); + QCOMPARE(Previewer::viewerFor("application/x-font-type1", false), QString("other")); + } + void thumbnailsNameThePackageThatWouldDrawAType() { QTemporaryDir dir; @@ -6141,8 +6394,9 @@ private slots: "[Thumbnailer Entry]\nTryExec=/bin/sh\nExec=/bin/sh -c \"exit 1\" %i %o\nMimeType=video/webm;\n"); const Thumbnails thumbnails(dir.filePath("cache"), {dir.path()}); QCOMPARE(thumbnails.missingPackage("video/mp4"), QString("ffmpegthumbnailer")); - QCOMPARE(thumbnails.missingPackage("application/vnd.oasis.opendocument.text"), QString("libgsf")); - // Installed already, drawn by Qt itself, or no package known: nothing to name. + QCOMPARE(thumbnails.missingPackage("application/msword"), QString("libgsf")); + // Installed already, drawn by Qt or by Folio itself, or no package known: nothing to name. + QVERIFY(thumbnails.missingPackage("application/vnd.oasis.opendocument.text").isEmpty()); QVERIFY(thumbnails.missingPackage("video/webm").isEmpty()); QVERIFY(thumbnails.missingPackage("image/png").isEmpty()); QVERIFY(thumbnails.missingPackage("model/step").isEmpty()); @@ -6712,6 +6966,133 @@ private slots: static constexpr int kEntryLimit = 1024, kMostEntries = 0xFFFF; // The reader holds an embedded JPEG of this many bytes in memory and none longer. static constexpr qint64 kMebibyte = 1048576, kMostJpegBytes = 64 * kMebibyte; + // The zip reader walks a directory of this many entries and none longer, and holds a member of this many bytes and none longer. + static constexpr int kMostZipEntries = 8192; + static constexpr qint64 kMostMemberBytes = 64 * kMebibyte; + // A zip with no comment ends with its end record of 22 bytes, whose disk number follows the 4-byte signature. + static constexpr int kEndRecordBytes = 22; + // The provider runs at most six thumbnails at once. + static constexpr int kMostThumbnailThreads = 6; + // A hole of 3 GiB, past what a signed 32-bit offset can name. + static constexpr quint32 kFarHole = 3u * 1024 * 1048576; + // One member of a zip a test writes: deflated when asked, with `flags` its general-purpose bits and `claimed` the inflated size its headers give. + struct ZipMember { + QByteArray name, data; + bool deflated = false; + quint16 flags = 0; + qint64 claimed = -1; + }; + // A zip of these members whose offsets count from `hole`, the bytes the file holds ahead of it. + static QByteArray zipOf(const QList &members, quint32 hole = 0) + { + const Raw le(true); + QByteArray local, central; + for (const ZipMember &m : members) { + // qCompress gives a 4-byte length and a 2-byte zlib header, then the raw deflate a zip holds and a 4-byte checksum. + const QByteArray stored = m.deflated ? qCompress(m.data).mid(6).chopped(4) : m.data; + const quint32 crc = quint32(crc32(0, reinterpret_cast(m.data.constData()), uInt(m.data.size()))); + // Flags(2) method(2) time(4) crc(4) compressed(4) size(4) name(2) extra(2), as both headers hold them. + const QByteArray common = le.word(m.flags, 2) + le.word(m.deflated ? 8 : 0, 2) + le.word(0, 4) + le.word(crc, 4) + + le.word(stored.size(), 4) + le.word(quint32(m.claimed >= 0 ? m.claimed : m.data.size()), 4) + + le.word(m.name.size(), 2) + le.word(0, 2); + // Versions(4), then comment(2) disk(2) attributes(6) and the local header's offset(4) around the common fields. + central += QByteArray("PK\x01\x02", 4) + le.word(20, 2) + le.word(20, 2) + common + QByteArray(10, '\0') + + le.word(hole + local.size(), 4) + m.name; + local += QByteArray("PK\x03\x04", 4) + le.word(20, 2) + common + m.name + stored; + } + return local + central + QByteArray("PK\x05\x06", 4) + QByteArray(4, '\0') + le.word(members.size(), 2) + + le.word(members.size(), 2) + le.word(central.size(), 4) + le.word(hole + local.size(), 4) + le.word(0, 2); + } + using FontTables = QList>; + // The tables FreeType needs of a TrueType font of one family, each character drawing glyph 1, a triangle, or glyph 2, a square. + static FontTables fontTables(const QString &family, const QList> &characters) + { + const Raw be(false); + // A simple glyph: one contour, its box, its last point, no instructions, on-curve flags, then x and y as deltas. + const auto glyph = [&](const QList &points) { + QByteArray out = be.word(1, 2) + be.word(0, 4) + be.word(1000, 2) + be.word(1000, 2) + be.word(points.size() - 1, 2) + + be.word(0, 2) + QByteArray(points.size(), '\x01'); + for (const bool vertical : {false, true}) { + int last = 0; + for (const QPoint &point : points) { + const int at = vertical ? point.y() : point.x(); + out += be.word(quint16(at - last), 2); + last = at; + } + } + return out + QByteArray(out.size() % 4 ? 4 - out.size() % 4 : 0, '\0'); + }; + const QByteArray triangle = glyph({{100, 0}, {500, 700}, {900, 0}}), square = glyph({{100, 0}, {100, 500}, {600, 500}, {600, 0}}); + // Format 4: a segment of one code per character, then the 0xFFFF one that ends every table. + QByteArray ends, deltas; + for (const auto &[code, id] : characters) { + ends += be.word(code, 2); + deltas += be.word(quint16(id - code), 2); + } + ends += be.word(0xFFFF, 2); + deltas += be.word(1, 2); + const int segments = characters.size() + 1; + int range = 1, selector = 0; + while (range * 2 <= segments) { + range *= 2; + ++selector; + } + const QByteArray format4 = be.word(4, 2) + be.word(16 + 8 * segments, 2) + be.word(0, 2) + be.word(2 * segments, 2) + + be.word(2 * range, 2) + be.word(selector, 2) + be.word(2 * segments - 2 * range, 2) + ends + be.word(0, 2) + ends + + deltas + QByteArray(2 * segments, '\0'); + QByteArray names, strings; + const QStringList texts = {family, "Regular"}; + for (int id = 1; id <= texts.size(); ++id) { + QByteArray text; + for (const QChar c : texts[id - 1]) + text += be.word(c.unicode(), 2); + names += be.word(3, 2) + be.word(1, 2) + be.word(0x409, 2) + be.word(id, 2) + be.word(text.size(), 2) + be.word(strings.size(), 2); + strings += text; + } + return {{"cmap", be.word(0, 2) + be.word(1, 2) + be.word(3, 2) + be.word(1, 2) + be.word(12, 4) + format4}, + {"glyf", triangle + square}, + // Version, revision, checksum adjustment, magic, flags, 1000 units, two dates, box, style, smallest size, + // direction, long offsets and glyph format. + {"head", be.word(0x10000, 4) + be.word(0x10000, 4) + be.word(0, 4) + be.word(0x5F0F3CF5, 4) + be.word(0, 2) + + be.word(1000, 2) + QByteArray(16, '\0') + be.word(0, 4) + be.word(1000, 2) + be.word(1000, 2) + + be.word(0, 2) + be.word(8, 2) + be.word(2, 2) + be.word(1, 2) + be.word(0, 2)}, + // Version, ascender, descender, gap, widest advance, bearings and extent, caret, reserved, format, metrics. + {"hhea", be.word(0x10000, 4) + be.word(800, 2) + be.word(quint16(-200), 2) + be.word(0, 2) + be.word(1000, 2) + + be.word(0, 4) + be.word(1000, 2) + be.word(1, 2) + QByteArray(14, '\0') + be.word(3, 2)}, + {"hmtx", (be.word(1000, 2) + be.word(100, 2)).repeated(3)}, + {"loca", be.word(0, 4) + be.word(0, 4) + be.word(triangle.size(), 4) + be.word(triangle.size() + square.size(), 4)}, + // Version 1.0: 3 glyphs of at most 4 points and 1 contour, 2 zones, nothing else. + {"maxp", be.word(0x10000, 4) + be.word(3, 2) + be.word(4, 2) + be.word(1, 2) + be.word(0, 4) + be.word(2, 2) + + QByteArray(16, '\0')}, + {"name", be.word(0, 2) + be.word(texts.size(), 2) + be.word(6 + names.size(), 2) + names + strings}}; + } + // The tables as an sfnt, sorted by tag as they come, whose offsets count from `base`. + static QByteArray sfnt(const FontTables &tables, quint32 base = 0) + { + const Raw be(false); + // Version 1.0, then the number of tables with the search hints for 8 of them. + QByteArray head = be.word(0x10000, 4) + be.word(tables.size(), 2) + be.word(128, 2) + be.word(3, 2) + be.word(0, 2), body; + const quint32 start = base + 12 + 16 * tables.size(); + for (const auto &[tag, data] : tables) { + head += tag + be.word(0, 4) + be.word(start + body.size(), 4) + be.word(data.size(), 4); + body += data + QByteArray(data.size() % 4 ? 4 - data.size() % 4 : 0, '\0'); + } + return head + body; + } + // The tables as a WOFF 1.0 that keeps each one uncompressed, as WOFF allows. + static QByteArray woff(const FontTables &tables) + { + const Raw be(false); + const quint32 start = 44 + 20 * tables.size(); + QByteArray directory, body; + for (const auto &[tag, data] : tables) { + directory += tag + be.word(start + body.size(), 4) + be.word(data.size(), 4) + be.word(data.size(), 4) + be.word(0, 4); + body += data + QByteArray(data.size() % 4 ? 4 - data.size() % 4 : 0, '\0'); + } + // Signature, flavour, length, tables, reserved, the sfnt's size, version 1.0, and no metadata or private block. + return "wOFF" + be.word(0x10000, 4) + be.word(start + body.size(), 4) + be.word(tables.size(), 2) + be.word(0, 2) + + be.word(12 + 16 * tables.size() + body.size(), 4) + be.word(1, 2) + be.word(0, 2) + QByteArray(20, '\0') + directory + body; + } // A camera RAW in either byte order; blobs and IFDs are appended, so every offset is known before it is named. struct Raw { explicit Raw(bool littleEndian)