From 28d70c9a8a2d868969b10976551e4b19de76d3c9 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 17:15:53 -0400 Subject: [PATCH 1/2] Thumbnail a file from the picture inside it An e-book, a comic book archive, an OpenDocument or Office file, a 3MF or FreeCAD model and a Krita or OpenRaster drawing keep a cover or a preview picture in their zip. With no thumbnailer installed for the type, Folio now reads that picture itself, by offset and within named limits, on the road RAW photos already take. --- src/embedded.cpp | 271 ++++++++++++++++++++++++++++++++++++++++++ src/embedded.h | 14 +++ src/rawembed.cpp | 3 +- src/rawembed.h | 3 + src/thumbnails.cpp | 12 +- src/thumbnails.h | 5 +- tests/folio_tests.cpp | 253 ++++++++++++++++++++++++++++++++++++++- 7 files changed, 550 insertions(+), 11 deletions(-) create mode 100644 src/embedded.cpp create mode 100644 src/embedded.h diff --git a/src/embedded.cpp b/src/embedded.cpp new file mode 100644 index 00000000..292f70ff --- /dev/null +++ b/src/embedded.cpp @@ -0,0 +1,271 @@ +#include "embedded.h" +#include "rawembed.h" + +#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"}; + +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(); +} + +} // namespace + +bool Embedded::handles(const QString &mime, const QString &path) { + return mime == kEpub || mime == kComic || !fixedNames(mime, path).isEmpty(); +} + +QImage Embedded::thumbnail(const QString &path, const QString &mime, int pixels) { + QFile file; + QMap members; + if (!openRegular(file, path) || !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..02ada618 --- /dev/null +++ b/src/embedded.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +// Thumbnails from the picture a file keeps inside its own zip: EPUB, comic book, OpenDocument, Office Open XML, 3MF, FreeCAD, Krita and OpenRaster. +namespace Embedded { + +bool handles(const QString &mime, const QString &path); + +// The kept picture, upright and at most `pixels` on its longer side; null when the file holds none Folio can read. +QImage thumbnail(const QString &path, const QString &mime, int pixels); + +} // namespace Embedded 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..65d44655 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, or a file that keeps its picture in a zip, with no installed thumbnailer, the desktop's own answer, shows the picture it embedded. + 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..6a4dff4c 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -36,9 +37,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 +6137,214 @@ 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 thumbnailsNameThePackageThatWouldDrawAType() { QTemporaryDir dir; @@ -6141,8 +6352,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 +6924,43 @@ 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); + } // 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) From d5d1ed7c014f4b2ee14cc1454144c050d593439e Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 17:15:53 -0400 Subject: [PATCH 2/2] Thumbnail a font by drawing it A font file showed only its icon unless a font thumbnailer was installed. Folio now draws "Aa" in the font itself, read from the file's bytes and never added to the application's fonts, or the first letters the font has when it lacks those. --- src/embedded.cpp | 87 +++++++++++++++++++++++++++- src/embedded.h | 4 +- src/foldermodel.cpp | 2 + src/previewer.cpp | 3 + src/thumbnails.cpp | 2 +- tests/folio_tests.cpp | 132 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/embedded.cpp b/src/embedded.cpp index 292f70ff..ac373ad9 100644 --- a/src/embedded.cpp +++ b/src/embedded.cpp @@ -6,10 +6,14 @@ #include #include #include +#include +#include +#include #include #include #include #include +#include #include #include #include @@ -42,6 +46,19 @@ const QString kOpenRaster = QStringLiteral("image/openraster"), kFreeCad = QStri // 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); } @@ -231,16 +248,82 @@ QImage picture(QByteArray bytes, int pixels) { 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 || !fixedNames(mime, path).isEmpty(); + 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 (!openRegular(file, path) || !directory(file, members)) + if (!directory(file, members)) return {}; const auto part = [&](const QByteArray &name) { const auto it = members.constFind(name); diff --git a/src/embedded.h b/src/embedded.h index 02ada618..31560a28 100644 --- a/src/embedded.h +++ b/src/embedded.h @@ -3,12 +3,12 @@ #include #include -// Thumbnails from the picture a file keeps inside its own zip: EPUB, comic book, OpenDocument, Office Open XML, 3MF, FreeCAD, Krita and OpenRaster. +// 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; null when the file holds none Folio can read. +// 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/thumbnails.cpp b/src/thumbnails.cpp index 65d44655..78b64efb 100644 --- a/src/thumbnails.cpp +++ b/src/thumbnails.cpp @@ -404,7 +404,7 @@ 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, or a file that keeps its picture in a zip, with no installed thumbnailer, the desktop's own answer, shows the picture it embedded. + // 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; const QImage image = RawEmbed::handles(mime) ? RawEmbed::thumbnail(path, pixels) : Embedded::thumbnail(path, mime, pixels); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 6a4dff4c..783c1c86 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -6345,6 +6347,46 @@ private slots: 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; @@ -6961,6 +7003,96 @@ private slots: 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)