diff --git a/README.md b/README.md index ef59b417..a1c15159 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`. - **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches. -- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, in their own window. +- **Space previews** pictures, video, audio, PDFs, fonts, text and Markdown at full size, and the names inside archives, in their own window. - **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP. - **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten. - **Split** the window in two with Ctrl+Alt+N to move things between folders. diff --git a/src/Format.js b/src/Format.js index a359f60e..fa9858c0 100644 --- a/src/Format.js +++ b/src/Format.js @@ -1,7 +1,7 @@ .pragma library .import QtQml as QtQml -// Sizes, dates, counts and thumbnail URLs as the views show them, in one +// Sizes, dates, lengths, counts and thumbnail URLs as the views show them, in one // copy that every tile and row shares. A library can't see context // properties, so a binding that shows a date reads translator.language // itself to follow a language switch; whatever goes through qsTr here is @@ -33,6 +33,18 @@ function when(date) { return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat) } +// A length as a player's clock (1:05, or 1:02:05 past the hour); nothing when unknown. +function length(seconds) { + const perMinute = 60, perHour = 3600 + if (!(seconds > 0)) + return "" + const total = Math.floor(seconds) + const hours = Math.floor(total / perHour) + const minutes = Math.floor(total % perHour / perMinute) + const rest = String(total % perMinute).padStart(2, "0") + return hours > 0 ? hours + ":" + String(minutes).padStart(2, "0") + ":" + rest : minutes + ":" + rest +} + // A folder's contents; nothing until they've been counted. function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..797f7907 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Folio import "Format.js" as Format // A larger look at the selected item and the facts about it, with the one @@ -43,6 +44,11 @@ Rectangle { // A picture's size or a text's opening lines once they've been read off // the UI thread. readonly property var info: backend.details + // Text files show their opening lines in a frame, archives bsdtar can list the names inside. + readonly property bool archive: hasItem && backend.listsArchive(item.mime) + readonly property bool listed: hasItem && (item.kind === "text" || archive) + // Fonts show the alphabet and a pangram in the font itself. + readonly property bool sampled: fontSample.status !== "" // Described once the folder's loaded has put the cursor back on its // item: a re-read leaves another item on the cursor's row for a moment, // and describing that would drop what's been read of this one. @@ -68,14 +74,14 @@ Rectangle { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit - source: pane.hasItem && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" + source: pane.hasItem && !pane.item.isDir && !pane.listed && !pane.sampled && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" visible: status === Image.Ready } - // Text files show their opening lines instead of a picture. + // Text files show their opening lines instead of a picture, archives the names inside. Rectangle { anchors.fill: parent - visible: pane.hasItem && pane.item.kind === "text" + visible: pane.listed color: theme.background border.width: 1 border.color: Qt.alpha(theme.foreground, 0.12) @@ -90,11 +96,63 @@ Rectangle { textFormat: Text.PlainText elide: Text.ElideRight } + Sentence { + visible: pane.archive && !pane.info.text + margin: 14 + text: pane.info.sentence || qsTr("Reading the archive…") + color: theme.secondaryForeground + font.pixelSize: 12 + } + } + + FontSample { + id: fontSample + path: pane.hasItem && !pane.item.isDir ? pane.item.path : "" + modified: pane.hasItem ? pane.item.modified : undefined + } + Rectangle { + anchors.fill: parent + visible: pane.sampled + color: theme.background + border.width: 1 + border.color: Qt.alpha(theme.foreground, 0.12) + clip: true + Column { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + visible: fontSample.status === "ready" + // The Latin alphabet and the digits on purpose: they're what the font was checked for. + FontText { + width: parent.width + source: fontSample + text: fontSample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789" + color: theme.foreground + pixelSize: 18 + anywhere: true + } + FontText { + width: parent.width + visible: !fontSample.glyphs + source: fontSample + // Not translated: the font was checked for these letters and the space, no others. + text: "The quick brown fox jumps over the lazy dog" + color: theme.foreground + pixelSize: 13 + } + } + Sentence { + visible: fontSample.status === "error" + margin: 14 + text: qsTr("Couldn't read this font.") + color: theme.secondaryForeground + font.pixelSize: 12 + } } Glyph { anchors.centerIn: parent - visible: !big.visible && pane.hasItem && pane.item.kind !== "text" + visible: !big.visible && pane.hasItem && !pane.listed && !pane.sampled name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file" size: 72 stroke: 1.2 @@ -123,7 +181,7 @@ Rectangle { horizontalAlignment: Text.AlignLeft text: { translator.language // The type's name comes from the system, in the language of the moment. - return Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + return Format.summary(pane.item, backend.typeName(pane.item.mime || "", pane.item.path || "")) } color: theme.secondaryForeground font.pixelSize: 12 @@ -220,6 +278,45 @@ Rectangle { color: theme.foreground font.pixelSize: 12 } + Text { visible: !!pane.info.seconds; text: qsTr("Length"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.seconds + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: Format.length(pane.info.seconds) + color: theme.foreground + font.pixelSize: 12 + } + Text { visible: !!pane.info.video; text: qsTr("Video"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.video + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.info.video || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideRight + } + Text { visible: !!pane.info.audio; text: qsTr("Audio"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.audio + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.info.audio || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideRight + } + // Without ffprobe, the package that brings it. + Text { + visible: (pane.item.kind === "video" || pane.item.kind === "audio") && !!pane.info.sentence + Layout.columnSpan: 2 + Layout.fillWidth: true + text: pane.info.sentence || "" + color: theme.secondaryForeground + font.pixelSize: 12 + wrapMode: Text.Wrap + } // Plugins' rows, label and value in turn, after Folio's own: a // plugin's item's details, or what plugins say about a file. Repeater { diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e3..f57dd235 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Controls.Material import QtQuick.Layouts +import Folio import "Format.js" as Format // The large look at one file (Space in Folio, or `folio preview FILE`): @@ -143,8 +144,9 @@ ApplicationWindow { anchors.fill: parent active: !win.external sourceComponent: win.viewer === "image" || win.viewer === "animated" ? imageView - : win.viewer === "text" || win.viewer === "markdown" ? textView + : win.viewer === "text" || win.viewer === "markdown" || win.viewer === "archive" ? textView : win.viewer === "folder" ? folderView + : win.viewer === "font" ? fontView : otherView } @@ -167,6 +169,13 @@ ApplicationWindow { : qsTr("Playing video and audio needs Qt Multimedia, from the qt6-multimedia package.") color: theme.foreground } + + // An archive's names are read as bsdtar prints them. + Sentence { + visible: win.viewer === "archive" && !previewer.text + text: previewer.sentence || qsTr("Reading the archive…") + color: previewer.sentence ? theme.foreground : theme.secondaryForeground + } } } @@ -270,7 +279,7 @@ ApplicationWindow { } } - // Text to read, Markdown rendered; selectable, never editable. + // Text to read, Markdown rendered, an archive's names; selectable, never editable. Component { id: textView @@ -284,11 +293,7 @@ ApplicationWindow { function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) } function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) } - Connections { - target: previewer - function onChanged() { flick.contentY = 0 } - } - + // Never sent to the top when the text changes: an archive's names keep arriving, and another file empties the text first, which brings the view back by itself, even one still gliding. TextEdit { id: body width: Math.min(flick.width - 96, win.viewer === "markdown" ? 820 : 1200) @@ -310,6 +315,102 @@ ApplicationWindow { } } + // A font as a sample sheet in the font itself, laid out and scrolled like the text view. + Component { + id: fontView + + Flickable { + id: flick + clip: true + boundsBehavior: Flickable.StopAtBounds + contentWidth: width + contentHeight: Math.max(height, sheet.implicitHeight + 72) + + function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) } + function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) } + + Connections { + target: previewer + function onChanged() { flick.contentY = 0 } + } + + FontSample { + id: sample + path: win.item.path || "" + modified: win.item.modified + } + + Column { + id: sheet + width: Math.min(flick.width - 96, 1200) + x: (flick.width - width) / 2 + y: 36 + spacing: 24 + visible: sample.status === "ready" + + Column { + width: parent.width + spacing: 2 + Text { + width: parent.width + text: sample.family + color: theme.brightForeground + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: sample.faces > 1 ? sample.style + " · " + qsTr("%Ln faces", "", sample.faces) : sample.style + color: theme.secondaryForeground + font.pixelSize: 12 + elide: Text.ElideRight + } + } + + // The Latin alphabet and the digits on purpose: they're what the font was checked for. + FontText { + width: parent.width + source: sample + text: sample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789" + color: theme.foreground + pixelSize: 36 + anywhere: true + } + + // From the text view's size up to a display size. + Repeater { + model: [13, 18, 24, 36, 48] + Column { + required property int modelData + width: sheet.width + spacing: 4 + Text { + text: qsTr("%1 px").arg(modelData) + color: theme.secondaryForeground + font.pixelSize: 12 + } + FontText { + width: parent.width + source: sample + // Not translated: the font was checked for these letters and the space, no others. + text: sample.glyphs || "The quick brown fox jumps over the lazy dog" + color: theme.foreground + pixelSize: modelData + } + } + } + } + + Sentence { + visible: sample.status === "error" + text: qsTr("Couldn't read this font.") + } + + ScrollBar.vertical: FolioScrollBar { } + } + } + // A folder: what's inside, at a glance. Component { id: folderView @@ -351,7 +452,7 @@ ApplicationWindow { } // Anything without a viewer of its own: the largest thumbnail the - // installed thumbnailers can make (HEIC, office files, fonts…), or the + // installed thumbnailers can make (HEIC, office files…), or the // file's type and a pointer to its app. Component { id: otherView diff --git a/src/Sentence.qml b/src/Sentence.qml new file mode 100644 index 00000000..74328f23 --- /dev/null +++ b/src/Sentence.qml @@ -0,0 +1,13 @@ +import QtQuick + +// A sentence in the middle of an empty view: what it waits for, that it's empty, or why not. +Text { + // Kept clear on either side. + property int margin: 48 + + anchors.centerIn: parent + width: parent.width - 2 * margin + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: theme.foreground +} diff --git a/src/backend.cpp b/src/backend.cpp index 6224ee0c..ed5d6c30 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -1,5 +1,6 @@ #include "backend.h" #include "fileops.h" +#include "fontsample.h" #include "previewer.h" #include @@ -8,14 +9,22 @@ #include #include #include +#include #include +#include +#include +#include #include #include #include #include #include +#include #include +// ffprobe's answer for a file with many streams and tags is a few kilobytes. +static constexpr qint64 probeOutputLimit = 1024 * 1024; + Backend::Backend(const QString &home, QObject *parent) : QObject(parent), m_home(home.isEmpty() ? QDir::homePath() : home) { m_noticeTimer.setSingleShot(true); @@ -363,6 +372,8 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { const QString kind = item.value("kind").toString(); const QString mime = item.value("mime").toString(); const quint64 generation = ++*m_describing; + // Whatever a helper program was reading for the last item goes unread. + m_helper.stop(); // The same item again (its folder re-read) keeps what it shows until // the new reading is in, rather than flickering. QVariantMap details; @@ -376,19 +387,55 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { // a plugin's own items. m_pluginItem = path.startsWith('/') && !inTrash ? item : QVariantMap(); showPluginRows(askPlugins()); - if (kind != "image" && kind != "text" && !inTrash) + // What a helper program reads comes on top of the rest. + const auto help = [this, path, kind, mime](const QVariantMap &details) { + // A helper program reads a file on the disk: a plugin's own item isn't one. + if (!path.startsWith('/')) + return; + // An archive's names, shown as bsdtar prints them. + if (Previewer::listsArchive(mime)) { + Previewer::listArchive(m_helper, path, [this, details](const QString &text, const QString &sentence) { + m_details = details; + m_details.insert("text", text); + m_details.insert("sentence", sentence); + emit detailsChanged(); + }); + } else if (kind == "video" || kind == "audio") { + // A video's or a song's length and codecs, once ffprobe is done. + auto json = std::make_shared(); + // "file:" keeps a name with a colon from being taken for a protocol. + m_helper.run("ffprobe", {"-v", "error", "-print_format", "json", "-show_format", "-show_streams", + "file:" + QFileInfo(path).absoluteFilePath()}, + probeOutputLimit, [json](const QByteArray &output) { *json += output; }, + [this, details, json](int code, const QByteArray &) { + QVariantMap facts; + if (code == 0) + facts = mediaFacts(*json); + else if (code == Helper::missing) + facts.insert("sentence", tr("Lengths and codecs need ffprobe, from the ffmpeg package.")); + // Shown even when there's nothing to add: a file written over that ffprobe can't read loses the old one's length. + m_details = details; + m_details.insert(facts); + emit detailsChanged(); + }); + } + }; + if (kind != "image" && kind != "text" && !inTrash) { + help(details); return; + } // Reading the file waits on its disk, which for a slow mount would hold // up moving through files, so it happens off the UI thread. auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, details]() mutable { + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, details, help]() mutable { watcher->deleteLater(); if (generation != *m_describing) return; // The selection moved on while this was read. details.insert(watcher->result()); m_details = details; emit detailsChanged(); + help(details); }); // Stepping through files faster than they're read queues readings; the // ones overtaken before they start are skipped rather than left to tie @@ -456,8 +503,58 @@ bool Backend::askPlugins() { }); } -QString Backend::typeName(const QString &mime) { - return QMimeDatabase().mimeTypeForName(mime).comment(); +QString Backend::typeName(const QString &mime, const QString &path) { + return QMimeDatabase().mimeTypeForName(FontSample::typeOf(mime, path)).comment(); +} + +// ffprobe's names for common codecs as people write them; any other is upper-cased. +static QString codecName(const QString &name) { + static const QHash names{ + {"h264", "H.264"}, {"hevc", "H.265"}, {"vp8", "VP8"}, {"vp9", "VP9"}, {"av1", "AV1"}, + {"mpeg4", "MPEG-4"}, {"mpeg2video", "MPEG-2"}, {"mpeg1video", "MPEG-1"}, {"aac", "AAC"}, + {"mp3", "MP3"}, {"opus", "Opus"}, {"vorbis", "Vorbis"}, {"flac", "FLAC"}, {"ac3", "AC-3"}, + {"eac3", "E-AC-3"}, {"dts", "DTS"}, {"alac", "ALAC"}}; + if (name.startsWith("pcm_")) + return "PCM"; + return names.value(name, name.toUpper()); +} + +// Sample: {"streams": [{"codec_name": "h264", "codec_type": "video", "width": 1920, "height": 1080, "side_data_list": [{"rotation": -90}]}, {"codec_name": "aac", "codec_type": "audio"}], "format": {"duration": "125.400000"}} +QVariantMap Backend::mediaFacts(const QByteArray &json) { + const QJsonObject root = QJsonDocument::fromJson(json).object(); + QVariantMap facts; + for (const QJsonValue &value : root.value("streams").toArray()) { + const QJsonObject stream = value.toObject(); + const QString type = stream.value("codec_type").toString(); + const QString codec = codecName(stream.value("codec_name").toString()); + // A song's cover art is a video stream to ffprobe, not to a person. + const bool cover = stream.value("disposition").toObject().value("attached_pic").toInt() != 0; + if (type == "video" && !cover && !facts.contains("video")) { + facts.insert("video", codec); + int width = stream.value("width").toInt(), height = stream.value("height").toInt(); + // A phone's sideways recording is turned when it plays, so its size is given as it shows. + for (const QJsonValue &side : stream.value("side_data_list").toArray()) + if (qAbs(qRound(side.toObject().value("rotation").toDouble())) % 180 == 90) + std::swap(width, height); + // Numbers, as a picture's are: the pane writes them in the language's digits. + if (width > 0 && height > 0) { + facts.insert("width", width); + facts.insert("height", height); + } + } else if (type == "audio" && !facts.contains("audio")) { + facts.insert("audio", codec); + } + } + // A stream ffprobe couldn't name gives no codec row. + for (const QString &key : {QStringLiteral("video"), QStringLiteral("audio")}) + if (facts.value(key).toString().isEmpty()) + facts.remove(key); + // A live recording's dump has no duration at all: no length, rather than 0:00. + bool ok = false; + const double seconds = root.value("format").toObject().value("duration").toString().toDouble(&ok); + if (ok && std::isfinite(seconds) && seconds > 0) + facts.insert("seconds", seconds); + return facts; } Backend::~Backend() { diff --git a/src/backend.h b/src/backend.h index 7d0f582d..a1e38927 100644 --- a/src/backend.h +++ b/src/backend.h @@ -9,6 +9,7 @@ #include "foldermodel.h" #include "mounter.h" #include "plugins.h" +#include "previewer.h" // Where the window is and how it got there: the current folder, its // breadcrumbs, back/forward history, and the actions that leave the folder @@ -29,9 +30,10 @@ class Backend : public QObject { Q_PROPERTY(bool previewing READ previewing NOTIFY previewingChanged) // What the details pane shows beyond the listing, for the item last // given to describe(), once they've been read in the background: a - // picture's dimensions, a text's opening lines, or a trashed item's - // origin and deletion time; and under `plugins`, plugins' rows about a - // file or folder, [[label, value]...]. + // picture's dimensions, a text's opening lines, an archive's names (or + // a sentence in their place), a video's or a song's length and codecs, + // or a trashed item's origin and deletion time; and under `plugins`, + // plugins' rows about a file or folder, [[label, value]...]. Q_PROPERTY(QVariantMap details READ details NOTIFY detailsChanged) public: @@ -75,7 +77,10 @@ class Backend : public QObject { Q_INVOKABLE void describe(const QVariantMap &item, bool inTrash = false); // The system's own name for a type ("JPEG image"), in the language of // the moment. - Q_INVOKABLE static QString typeName(const QString &mime); + // `path` lets a font be named as one where its extension alone means another type. + Q_INVOKABLE static QString typeName(const QString &mime, const QString &path = {}); + // Whether the details list an archive of this type's names. + Q_INVOKABLE static bool listsArchive(const QString &mime) { return Previewer::listsArchive(mime); } // The item to select once the folder has loaded (where we came from, or // what "Show in folder" asked for); empty means the first item. Q_INVOKABLE QString takeSelection(); @@ -110,6 +115,8 @@ class Backend : public QObject { // A server address (sftp://, smb://, ftp://, dav://, davs://) as GIO // mounts it, or empty when the text isn't one. static QString serverAddress(const QString &text); + // ffprobe's JSON as details: seconds, width and height, video and audio codecs, only those it gave. + static QVariantMap mediaFacts(const QByteArray &json); signals: void pathChanged(); @@ -185,4 +192,6 @@ class Backend : public QObject { // The pane the working directory follows: the one used last. static inline Backend *s_working = nullptr; void moveWorkingDirectory(); + // Reads what only a helper program can, for the item described last. + Helper m_helper{this}; }; diff --git a/src/fontsample.cpp b/src/fontsample.cpp new file mode 100644 index 00000000..91c9710a --- /dev/null +++ b/src/fontsample.cpp @@ -0,0 +1,336 @@ +#include "fontsample.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Past the largest CJK collections; a bigger file isn't read. +constexpr qint64 fontBytes = 128 * 1024 * 1024; +// As many characters as the alphabet in both cases and the digits. +constexpr int glyphCount = 62; +// QRawFont needs a size, though it's only asked which characters it has. +constexpr qreal probePixels = 12; +// After the space: controls and the space itself show nothing. +constexpr char32_t firstShown = 0x21; +constexpr char32_t lastCodePoint = 0x10FFFF; +// A collection's header: "ttcf", its version, how many faces, then a 4-byte offset per face. +constexpr int faceCountAt = 8; +constexpr int collectionHeader = 12; +constexpr int faceOffsetBytes = 4; + +struct Read { + QByteArray data; + QString family; + QString style; + int faces = 0; + QString glyphs; +}; + +bool showsAlone(char32_t c) { + switch (QChar::category(c)) { + case QChar::Mark_NonSpacing: + case QChar::Mark_SpacingCombining: + case QChar::Mark_Enclosing: + case QChar::Separator_Space: + case QChar::Separator_Line: + case QChar::Separator_Paragraph: + case QChar::Other_Control: + case QChar::Other_Format: + case QChar::Other_Surrogate: + case QChar::Other_NotAssigned: + return false; + default: + return true; + } +} + +// The first characters by code point the font has that `take` accepts, for as long as they're `wanted`. +QString glyphsIn(const QRawFont &font, bool (*take)(char32_t), const std::function &wanted) { + QString glyphs; + int found = 0; + for (char32_t c = firstShown; c <= lastCodePoint && found < glyphCount && wanted(); ++c) { + if (take(c) && font.supportsCharacter(c)) { + glyphs += QString::fromUcs4(&c, 1); + ++found; + } + } + return glyphs; +} + +// Runs off the UI thread: the file is read and checked here. +Read readFont(const QString &path, quint64 generation, const std::shared_ptr> &shown) { + Read read; + // Asked before the disk is, before the font is searched and at each character of that search: a font stepped past costs little. + const auto current = [&] { return shown->load() == generation; }; + if (!current()) + return read; + // Opened without waiting and read only as far as its size: a pipe or a device named like a font gives nothing and holds no thread. + const int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + QFile file; + if (fd < 0 || !file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + if (fd >= 0) + ::close(fd); + return read; + } + struct stat info; + if (::fstat(fd, &info) != 0 || info.st_size > fontBytes) + return read; + const QByteArray data = file.read(info.st_size); + if (!current()) + return read; + const QRawFont font(data, probePixels); + if (!font.isValid()) + return read; + read.data = data; + read.family = font.familyName(); + read.style = font.styleName(); + read.faces = FontSample::faceCount(data); + // The sheet's own characters: the alphabet in both cases and the digits. + bool sheet = true; + for (char32_t c = 'A'; c <= 'Z' && sheet; ++c) + sheet = font.supportsCharacter(c) && font.supportsCharacter(c - 'A' + 'a'); + for (char32_t c = '0'; c <= '9' && sheet; ++c) + sheet = font.supportsCharacter(c); + // The line of text has spaces between its words. + sheet = sheet && font.supportsCharacter(' '); + // Without all of them: letters it has (Greek, kana, an alphabet with no digits), else any it draws, never a fallback font's. + if (!sheet) { + read.glyphs = glyphsIn(font, [](char32_t c) { return QChar::isLetter(c); }, current); + if (read.glyphs.isEmpty()) + read.glyphs = glyphsIn(font, showsAlone, current); + // Nothing it draws alone (only marks, or nothing at all): no sample, as for a file that isn't a font. + if (read.glyphs.isEmpty()) + return {}; + } + return read; +} + +} + +FontSample::FontSample(QObject *parent) : QObject(parent) {} + +FontSample::~FontSample() { + unload(); + // A reading under way stops at its next look. + ++*m_shown; +} + +bool FontSample::isFont(const QString &mime) { + static const QStringList fonts{"font/ttf", "font/otf", "font/collection", "font/woff", "font/woff2", + "application/x-font-type1"}; + // Resolves the older names (application/x-font-ttf) to the current ones. + return fonts.contains(QMimeDatabase().mimeTypeForName(mime).name()); +} + +bool FontSample::isFontFile(const QString &path) { + return isFont(typeOf({}, path)); +} + +QString FontSample::typeOf(const QString &mime, const QString &path) { + if (isFont(mime)) + return mime; + for (const QMimeType &type : QMimeDatabase().mimeTypesForFileName(path)) + if (isFont(type.name())) + return type.name(); + return mime; +} + +// Sample: 74 74 63 66 | 00 01 00 00 | 00 00 00 02 | an offset per face is "ttcf", version 1.0, two faces. +int FontSample::faceCount(const QByteArray &data) { + if (!data.startsWith("ttcf") || data.size() < collectionHeader) + return 1; + const quint32 faces = qFromBigEndian(data.constData() + faceCountAt); + return faces > 0 && faces <= quint32(data.size() - collectionHeader) / faceOffsetBytes ? int(faces) : 1; +} + +void FontSample::setPath(const QString &path) { + if (path == m_path) + return; + m_path = path; + emit pathChanged(); + show(); +} + +void FontSample::setModified(const QVariant &modified) { + if (modified == m_modified) + return; + m_modified = modified; + emit modifiedChanged(); + // Only a font being shown is read again: the file was written over in place. + if (!m_status.isEmpty()) + show(); +} + +void FontSample::show() { + unload(); + const quint64 generation = ++*m_shown; + m_status = !m_path.isEmpty() && isFontFile(m_path) ? QStringLiteral("loading") : QString(); + emit changed(); + if (m_status.isEmpty()) + return; + // Read once the loop turns: a selection that moves sets the path and the time one after the other, and one reading serves both. + QMetaObject::invokeMethod(this, [this, generation] { + if (generation == *m_shown) + load(generation); + }, Qt::QueuedConnection); +} + +void FontSample::load(quint64 generation) { + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + if (generation != *m_shown) + return; // Stepped on to another file while this one was read. + const Read read = watcher->result(); + if (read.data.isEmpty()) { + m_status = "error"; + emit changed(); + return; + } + // Kept, not added to Folio's fonts: Qt has one list of those, where another window's file of the same name would take this one's place. + m_data = read.data; + m_family = read.family; + m_style = read.style; + m_faces = read.faces; + m_glyphs = read.glyphs; + m_status = "ready"; + emit changed(); + }); + watcher->setFuture(QtConcurrent::run(readFont, m_path, generation, m_shown)); +} + +QRawFont FontSample::font(qreal pixelSize) const { + return m_data.isEmpty() ? QRawFont() : QRawFont(m_data, pixelSize); +} + +void FontSample::unload() { + m_data.clear(); + m_status.clear(); + m_family.clear(); + m_style.clear(); + m_faces = 0; + m_glyphs.clear(); +} + +FontText::FontText(QQuickItem *parent) : QQuickPaintedItem(parent) { + connect(this, &QQuickItem::widthChanged, this, &FontText::lay); +} + +void FontText::setSource(FontSample *source) { + if (source == m_source) + return; + if (m_source) + disconnect(m_source, &FontSample::changed, this, &FontText::load); + m_source = source; + if (m_source) + connect(m_source, &FontSample::changed, this, &FontText::load); + emit sourceChanged(); + load(); +} + +void FontText::setText(const QString &text) { + if (text == m_text) + return; + m_text = text; + emit textChanged(); + lay(); +} + +void FontText::setPixelSize(qreal pixelSize) { + if (pixelSize == m_pixelSize) + return; + m_pixelSize = pixelSize; + emit pixelSizeChanged(); + load(); +} + +void FontText::setColor(const QColor &color) { + if (color == m_color) + return; + m_color = color; + emit colorChanged(); + draw(); +} + +void FontText::setAnywhere(bool anywhere) { + if (anywhere == m_anywhere) + return; + m_anywhere = anywhere; + emit anywhereChanged(); + lay(); +} + +void FontText::load() { + m_font = m_source && m_pixelSize > 0 ? m_source->font(m_pixelSize) : QRawFont(); + lay(); +} + +void FontText::lay() { + m_runs.clear(); + qreal height = 0; + if (m_font.isValid() && width() > 0 && !m_text.isEmpty()) { + // One paragraph to the layout: a new line in the text is a line separator in it. + QTextLayout layout(QString(m_text).replace(QChar('\n'), QChar(QChar::LineSeparator))); + // Shaped with this font alone, so a character it lacks is its own missing glyph, never another font's. + layout.setRawFont(m_font); + QTextOption option; + option.setWrapMode(m_anywhere ? QTextOption::WrapAnywhere : QTextOption::WrapAtWordBoundaryOrAnywhere); + layout.setTextOption(option); + layout.beginLayout(); + for (QTextLine line = layout.createLine(); line.isValid(); line = layout.createLine()) { + line.setLineWidth(width()); + line.setPosition({0, height}); + height += line.height(); + } + layout.endLayout(); + m_runs = layout.glyphRuns(); + } + setImplicitHeight(std::ceil(height)); + draw(); +} + +void FontText::draw() { + // A QRawFont and the glyph runs made with it belong to this thread, so they're drawn here and never in paint(). + const qreal ratio = window() ? window()->effectiveDevicePixelRatio() : qGuiApp->devicePixelRatio(); + const QSize size = (QSizeF(width(), implicitHeight()) * ratio).toSize(); + m_picture = QImage(); + if (!m_runs.isEmpty() && !size.isEmpty()) { + m_picture = QImage(size, QImage::Format_ARGB32_Premultiplied); + m_picture.setDevicePixelRatio(ratio); + m_picture.fill(Qt::transparent); + QPainter painter(&m_picture); + painter.setPen(m_color); + painter.setRenderHint(QPainter::TextAntialiasing); + for (const QGlyphRun &run : std::as_const(m_runs)) + painter.drawGlyphRun({}, run); + } + update(); +} + +void FontText::itemChange(ItemChange change, const ItemChangeData &value) { + // A window of another scale needs a picture of its own. + if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) + draw(); + QQuickPaintedItem::itemChange(change, value); +} + +void FontText::paint(QPainter *painter) { + painter->drawImage(QPointF(0, 0), m_picture); +} diff --git a/src/fontsample.h b/src/fontsample.h new file mode 100644 index 00000000..959ee63b --- /dev/null +++ b/src/fontsample.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A font file as a sample sheet: read off the UI thread and drawn from its own bytes, never added to Folio's fonts. +class FontSample : public QObject { + Q_OBJECT + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + // The file's modified time, or anything that tells one writing of it from the next: a change reads it again. + Q_PROPERTY(QVariant modified READ modified WRITE setModified NOTIFY modifiedChanged) + // Empty for a file that isn't a font, then loading, ready or error. + Q_PROPERTY(QString status READ status NOTIFY changed) + // The family's name and its first face's style, as the file gives them. + Q_PROPERTY(QString family READ family NOTIFY changed) + Q_PROPERTY(QString style READ style NOTIFY changed) + Q_PROPERTY(int faces READ faces NOTIFY changed) + // Characters it has, drawn in place of the alphabet, digits and pangram; empty when it has all of those. + Q_PROPERTY(QString glyphs READ glyphs NOTIFY changed) + +public: + explicit FontSample(QObject *parent = nullptr); + ~FontSample() override; + + QString path() const { return m_path; } + void setPath(const QString &path); + QVariant modified() const { return m_modified; } + void setModified(const QVariant &modified); + QString status() const { return m_status; } + QString family() const { return m_family; } + QString style() const { return m_style; } + int faces() const { return m_faces; } + QString glyphs() const { return m_glyphs; } + // The file's first face at a size; not valid unless ready. + QRawFont font(qreal pixelSize) const; + + // TrueType, OpenType, collections, WOFF and Type 1, by their current or older names. + static bool isFont(const QString &mime); + // Whether a name could be a font's; by extension alone ".otf" is an OpenDocument template. + static bool isFontFile(const QString &path); + // The font type a file's name could mean, or `mime` when it's one already or the name means none. + static QString typeOf(const QString &mime, const QString &path); + // The faces in a font collection; 1 for a single font. + static int faceCount(const QByteArray &data); + +signals: + void pathChanged(); + void modifiedChanged(); + void changed(); + +private: + void show(); + void load(quint64 generation); + void unload(); + + QString m_path; + QVariant m_modified; + QString m_status; + QString m_family; + QString m_style; + int m_faces = 0; + QString m_glyphs; + // The file's bytes while it's shown. + QByteArray m_data; + // Counts what was asked for; a reading goes on, and is kept, only while it's still the newest. Shared with the readings under way. + std::shared_ptr> m_shown = std::make_shared>(0); +}; + +// Text drawn in a sample's own font: two files of one family name each draw as themselves. +class FontText : public QQuickPaintedItem { + Q_OBJECT + Q_PROPERTY(FontSample *source READ source WRITE setSource NOTIFY sourceChanged) + Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged) + Q_PROPERTY(qreal pixelSize READ pixelSize WRITE setPixelSize NOTIFY pixelSizeChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + // Breaks a line at any character, for a sheet of characters; otherwise between words where it can. + Q_PROPERTY(bool anywhere READ anywhere WRITE setAnywhere NOTIFY anywhereChanged) + +public: + explicit FontText(QQuickItem *parent = nullptr); + + FontSample *source() const { return m_source; } + void setSource(FontSample *source); + QString text() const { return m_text; } + void setText(const QString &text); + qreal pixelSize() const { return m_pixelSize; } + void setPixelSize(qreal pixelSize); + QColor color() const { return m_color; } + void setColor(const QColor &color); + bool anywhere() const { return m_anywhere; } + void setAnywhere(bool anywhere); + + // What's drawn: the glyphs of the source's own font, line by line. + QList runs() const { return m_runs; } + // Those glyphs as a picture, made on the thread that owns the font: paint() runs on the render thread and only copies it. + QImage picture() const { return m_picture; } + void paint(QPainter *painter) override; + +signals: + void sourceChanged(); + void textChanged(); + void pixelSizeChanged(); + void colorChanged(); + void anywhereChanged(); + +protected: + void itemChange(ItemChange change, const ItemChangeData &value) override; + +private: + void load(); + void lay(); + void draw(); + + QPointer m_source; + QString m_text; + qreal m_pixelSize = 13; + QColor m_color; + bool m_anywhere = false; + // Loaded once for a source and a size: a window being resized only lays the lines again. + QRawFont m_font; + QList m_runs; + QImage m_picture; +}; diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..8563813d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -8,6 +8,7 @@ "%1 GB": "%1 GB", "%1 KB": "%1 KB", "%1 MB": "%1 MB", + "%1 px": "%1 px", "%1 TB": "%1 TB", "%1 already exists here.": "%1 already exists here.", "%1 can't be burned onto that drive.": "%1 can't be burned onto that drive.", @@ -32,6 +33,10 @@ "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", + "%Ln faces": { + "one": "%Ln face", + "other": "%Ln faces" + }, "%Ln items": { "one": "%Ln item", "other": "%Ln items" @@ -40,11 +45,24 @@ "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." }, + "%Ln more": { + "one": "%Ln more", + "other": "%Ln more" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", "Alt+click": "Alt+click", "Archive": "Archive", + "At least %Ln items": { + "one": "At least %Ln item", + "other": "At least %Ln items" + }, + "At least %Ln more": { + "one": "At least %Ln more", + "other": "At least %Ln more" + }, + "Audio": "Audio", "Back / ahead 5 seconds": "Back / ahead 5 seconds", "Back / forward (or the mouse's thumb buttons)": "Back / forward (or the mouse's thumb buttons)", "Bigger, smaller or normal tiles (or Ctrl+scroll)": "Bigger, smaller or normal tiles (or Ctrl+scroll)", @@ -118,6 +136,8 @@ "Couldn't open this PDF: %1": "Couldn't open this PDF: %1", "Couldn't play this file: %1": "Couldn't play this file: %1", "Couldn't read %1.": "Couldn't read %1.", + "Couldn't read this archive.": "Couldn't read this archive.", + "Couldn't read this font.": "Couldn't read this font.", "Couldn't read this picture.": "Couldn't read this picture.", "Couldn't recreate %1.": "Couldn't recreate %1.", "Couldn't remove %1.": "Couldn't remove %1.", @@ -159,6 +179,7 @@ "one": "Emptied the trash: %Ln item deleted.", "other": "Emptied the trash: %Ln items deleted." }, + "Empty": "Empty", "Empty Trash": "Empty Trash", "Empty Trash…": "Empty Trash…", "Empty the trash?": "Empty the trash?", @@ -214,6 +235,10 @@ "Keep Both": "Keep Both", "Keyboard shortcuts": "Keyboard shortcuts", "Kind": "Kind", + "Length": "Length", + "Lengths and codecs need ffprobe, from the ffmpeg package.": "Lengths and codecs need ffprobe, from the ffmpeg package.", + "Listing archives needs bsdtar, from the libarchive package.": "Listing archives needs bsdtar, from the libarchive package.", + "Listing this archive took too long.": "Listing this archive took too long.", "Making a preview…": "Making a preview…", "Merge": "Merge", "Merging adds what's in this folder to the one there. What has the same name inside is replaced, and the replaced items go to the trash.": "Merging adds what's in this folder to the one there. What has the same name inside is replaced, and the replaced items go to the trash.", @@ -286,6 +311,7 @@ "Previewing PDFs needs Qt PDF, from the qt6-webengine package.": "Previewing PDFs needs Qt PDF, from the qt6-webengine package.", "Previous (%1)": "Previous (%1)", "Previous / next file": "Previous / next file", + "Reading the archive…": "Reading the archive…", "Recents": "Recents", "Redid compressing into %1.": "Redid compressing into %1.", "Redid copying %Ln items.": { @@ -386,6 +412,7 @@ "The drive is gone.": "The drive is gone.", "The drive stopped taking data (%1).": "The drive stopped taking data (%1).", "The drive was changed or removed.": "The drive was changed or removed.", + "The names in this archive are encrypted.": "The names in this archive are encrypted.", "The preview closed unexpectedly on %1.": "The preview closed unexpectedly on %1.", "There's nothing in %1 to extract.": "There's nothing in %1 to extract.", "There's nothing to redo.": "There's nothing to redo.", @@ -438,6 +465,7 @@ "Untitled Folder": "Untitled Folder", "Untitled Folder %1": "Untitled Folder %1", "User": "User", + "Video": "Video", "Views": "Views", "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.": "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.", "Works with Mac, Windows and Linux, and holds big files.": "Works with Mac, Windows and Linux, and holds big files.", diff --git a/src/main.cpp b/src/main.cpp index 9228dca5..2d5dda4a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include "filemanager1.h" +#include "fontsample.h" #include "foldermodel.h" #include "places.h" #include "plugins.h" @@ -104,6 +105,8 @@ static int preview(QGuiApplication &app) { QObject::connect(&translator, &Translator::changed, &engine, &QQmlEngine::retranslate); QObject::connect(&translator, &Translator::changed, &previewer, &Previewer::relist); engine.addImageProvider("thumb", new ThumbnailProvider(&thumbnails)); + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); @@ -144,6 +147,9 @@ int main(int argc, char *argv[]) { engine.addImageProvider("plugin-icon", new PluginIconProvider); // Creatable too: the column view lists each folder on the way here. qmlRegisterType("Folio", 1, 0, "FolderModel"); + // The details pane's sample of a font, and the text drawn in it. + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("places", &places); diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0e..5499162f 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -1,4 +1,5 @@ #include "previewer.h" +#include "fontsample.h" #include "fileops.h" #include @@ -8,7 +9,11 @@ #include #include #include +#include +#include +#include #include +#include #include Previewer::Previewer(QObject *parent) : QObject(parent) {} @@ -18,6 +23,8 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "folder"; if (mime == "application/pdf") return "pdf"; + if (FontSample::isFont(mime)) + return "font"; if (mime.startsWith("video/")) return "video"; if (mime.startsWith("audio/")) @@ -34,6 +41,8 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "image"; return "thumbnail"; } + if (listsArchive(mime)) + return "archive"; if (mime == "text/markdown") return "markdown"; const QMimeType type = QMimeDatabase().mimeTypeForName(mime); @@ -42,6 +51,20 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "other"; } +bool Previewer::listsArchive(const QString &mime) { + static const QStringList types{"application/zip", "application/x-tar", "application/x-compressed-tar", + "application/x-xz-compressed-tar", "application/x-bzip2-compressed-tar", + "application/x-zstd-compressed-tar", "application/x-lz4-compressed-tar", + "application/x-7z-compressed", "application/vnd.rar", "application/x-iso9660-image", + "application/x-cpio"}; + // By the names the system's database gives, older or newer (an ISO is application/vnd.efi.iso in newer ones). + const QMimeDatabase mimes; + const QString name = mimes.mimeTypeForName(mime).name(); + return !name.isEmpty() && std::any_of(types.begin(), types.end(), [&](const QString &type) { + return mimes.mimeTypeForName(type).name() == name; + }); +} + QString Previewer::readText(const QString &path, qint64 limit) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) @@ -182,7 +205,8 @@ QString Previewer::url() const { QString Previewer::type() const { if (m_index < 0 || m_index >= m_entries.size()) return {}; - return QMimeDatabase().mimeTypeForName(m_entries.at(m_index).mime).comment(); + const FolderModel::Entry &e = m_entries.at(m_index); + return QMimeDatabase().mimeTypeForName(FontSample::typeOf(e.mime, e.path)).comment(); } void Previewer::next() { @@ -207,7 +231,19 @@ void Previewer::setIndex(int index, bool report) { m_index = index; const FolderModel::Entry e = m_entries.at(index); m_viewer = viewerFor(e.mime, e.isDir); + // By its extension alone a ".otf" font is taken for an OpenDocument template. + if (m_viewer == "other" && FontSample::isFontFile(e.path)) + m_viewer = "font"; m_text.clear(); + m_sentence.clear(); + m_helper.stop(); + if (m_viewer == "archive") { + listArchive(m_helper, e.path, [this](const QString &text, const QString &sentence) { + m_text = text; + m_sentence = sentence; + emit changed(); + }); + } // A folder's count and first pictures, and a text's opening part, are // read off the UI thread: a big folder, or any file on a slow mount, @@ -271,3 +307,188 @@ void Previewer::connectToFolio() { } }); } + +// An archive's listing is cut there; bsdtar prints each name on a line. +static constexpr qint64 archiveOutputLimit = 32 * 1024 * 1024; + +Helper::Helper(QObject *parent, int milliseconds) : QObject(parent) { + m_limit.setSingleShot(true); + m_limit.setInterval(milliseconds); + connect(&m_limit, &QTimer::timeout, this, [this] { end(stopped, {}); }); +} + +Helper::~Helper() { + stop(); +} + +void Helper::run(const QString &program, const QStringList &args, qint64 outputLimit, + const std::function &output, + const std::function &finished) { + stop(); + m_read = 0; + m_outputLimit = outputLimit; + m_errors.clear(); + m_finished = finished; + auto *process = new QProcess(this); + m_process = process; + // UTF-8 names print as they are, whatever Folio's language. + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert("LC_ALL", "C.UTF-8"); + process->setProcessEnvironment(environment); + // No terminal or input to ask for a passphrase on. + process->setStandardInputFile(QProcess::nullDevice()); + process->setUnixProcessParameters(QProcess::UnixProcessFlag::CreateNewSession); + // It goes with Folio, even when Folio crashes. + process->setChildProcessModifier([] { ::prctl(PR_SET_PDEATHSIG, SIGKILL); }); + const auto take = [this, process, output] { + const QByteArray part = process->readAllStandardOutput(); + m_read += part.size(); + if (m_read > m_outputLimit) + return false; + if (!part.isEmpty()) + output(part); + return true; + }; + connect(process, &QProcess::readyReadStandardOutput, this, [this, take] { + if (!take()) + end(stopped, {}); + }); + // Read as it arrives, so a program that prints errors without end fills no memory. + const auto keep = [this, process] { + const QByteArray part = process->readAllStandardError(); + m_errors += part.left(errorsKept - m_errors.size()); + }; + connect(process, &QProcess::readyReadStandardError, this, keep); + connect(process, &QProcess::finished, this, [this, take, keep](int code, QProcess::ExitStatus status) { + if (!take()) { + end(stopped, {}); + return; + } + keep(); + end(status == QProcess::NormalExit ? code : crashed, std::exchange(m_errors, {})); + }); + // A program that isn't installed never finishes, so this is the only word from it. + connect(process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + end(missing, {}); + }); + m_limit.start(); + process->start(program, args); +} + +void Helper::stop() { + m_limit.stop(); + m_finished = nullptr; + QProcess *process = std::exchange(m_process, nullptr); + if (!process) + return; + process->disconnect(this); + if (process->state() == QProcess::NotRunning) { + process->deleteLater(); + return; + } + // Killed without waiting on a hung disk; the object goes once the process has exited, or has failed to start. + process->setParent(nullptr); + connect(process, &QProcess::finished, process, &QObject::deleteLater); + connect(process, &QProcess::errorOccurred, process, [process](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + process->deleteLater(); + }); + // It leads a session of its own, so what it started (a program bsdtar unpacks with) goes with it. + if (const qint64 pid = process->processId(); pid > 0) + ::killpg(pid_t(pid), SIGKILL); + process->kill(); +} + +void Helper::end(int code, const QByteArray &errors) { + const auto finished = m_finished; + stop(); + if (finished) + finished(code, errors); +} + +bool ArchiveNames::add(const QByteArray &output) { + // What was left had no line end in it: the look for one starts at the new part. + qsizetype from = m_pending.size(); + m_pending += output; + const qsizetype before = m_names.size(); + qsizetype start = 0; + qsizetype newline; + while ((newline = m_pending.indexOf('\n', from)) >= 0) { + const QByteArrayView line(m_pending.constData() + start, newline - start); + start = from = newline + 1; + // The "./" a tar made with `-C folder .` starts with, or an ISO's ".", is the archive itself. + if (line.isEmpty() || line == "./" || line == ".") + continue; + ++m_count; + // Names are shown up to a count and up to a size together: a few very long ones would hold the window as a huge text does. + m_full = m_full || m_names.size() == shown || m_bytes + line.size() > shownBytes; + if (m_full) + continue; + m_bytes += line.size(); + m_names << nameFrom(line); + } + m_pending.remove(0, start); + return m_names.size() > before; +} + +QString ArchiveNames::text(bool whole) const { + const int count = int(qMin(m_count, INT_MAX)); + const int more = count - int(m_names.size()); + QStringList lines{whole ? Previewer::tr("%Ln items", nullptr, count) : Previewer::tr("At least %Ln items", nullptr, count), + QString()}; + lines += m_names; + if (more > 0) + lines << (whole ? Previewer::tr("%Ln more", nullptr, more) : Previewer::tr("At least %Ln more", nullptr, more)); + return lines.join('\n'); +} + +// Sample: `./sub dir/caf\303\251 \\ two\nlines.txt` reads `sub dir/café \ two\nlines.txt` ("\n" stays). +QString ArchiveNames::nameFrom(QByteArrayView line) { + if (line.startsWith("./")) + line = line.sliced(2); + const auto octal = [&](qsizetype at) { return at < line.size() && line[at] >= '0' && line[at] <= '7'; }; + QByteArray name; + name.reserve(line.size()); + for (qsizetype i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && i + 1 < line.size() && line[i + 1] == '\\') { + name += '\\'; + ++i; + } else if (line[i] == '\\' && octal(i + 1) && octal(i + 2) && octal(i + 3)) { + name += char((line[i + 1] - '0') * 64 + (line[i + 2] - '0') * 8 + (line[i + 3] - '0')); + i += 3; + } else { + name += line[i]; + } + } + return QString::fromUtf8(name); +} + +void Previewer::listArchive(Helper &helper, const QString &path, + const std::function &show) { + auto names = std::make_shared(); + // An absolute path is never read as an option, or as "-" for stdin. + helper.run("bsdtar", {"-tf", QFileInfo(path).absoluteFilePath()}, archiveOutputLimit, + [names, show](const QByteArray &output) { + if (names->add(output)) + show(names->text(false), {}); + }, + [names, show](int code, const QByteArray &errors) { + if (code == 0 && names->count() > 0) + show(names->text(true), {}); + else if (code == 0) + show({}, tr("Empty")); + else if (code == Helper::stopped && names->count() > 0) + show(names->text(false), {}); + // Stopped before its first name: a large archive on a slow drive, not a damaged one. + else if (code == Helper::stopped) + show({}, tr("Listing this archive took too long.")); + else if (code == Helper::missing) + show({}, tr("Listing archives needs bsdtar, from the libarchive package.")); + // A failed run shows no names: bsdtar reads a renamed text file as a list of names. + else if (errors.contains("ncrypt")) + show({}, tr("The names in this archive are encrypted.")); + else + show({}, tr("Couldn't read this archive.")); + }); +} diff --git a/src/previewer.h b/src/previewer.h index 9aee60e9..62bf3825 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -1,12 +1,66 @@ #pragma once #include +#include #include +#include #include #include +#include #include "foldermodel.h" +// A helper program (bsdtar, ffprobe) reading the shown item, stopped unread when the view moves on or goes. +class Helper : public QObject { +public: + // How a run ended when it didn't exit by itself. + static constexpr int crashed = -1, stopped = -2, missing = -3; + // As much of stderr as a run keeps for its caller; the rest is read and dropped. + static constexpr qint64 errorsKept = 64 * 1024; + + explicit Helper(QObject *parent = nullptr, int milliseconds = 5000); + ~Helper() override; + + // Stops the last run; `output` gets stdout as it arrives, `finished` the exit code and stderr. + void run(const QString &program, const QStringList &args, qint64 outputLimit, + const std::function &output, + const std::function &finished); + void stop(); + +private: + void end(int code, const QByteArray &errors); + + QProcess *m_process = nullptr; + QTimer m_limit; + qint64 m_read = 0; + qint64 m_outputLimit = 0; + QByteArray m_errors; + std::function m_finished; +}; + +// An archive's names from `bsdtar -tf`, read as they arrive: the first kept, the rest counted. +class ArchiveNames { +public: + static constexpr int shown = 500; + // As much as a text file shows of itself. + static constexpr qint64 shownBytes = 128 * 1024; + + // Takes the next part of the output; true when it adds a name to those shown. + bool add(const QByteArray &output); + qint64 count() const { return m_count; } + // The count, a blank line, the names and how many more; "at least" until it's whole. + QString text(bool whole) const; + // One line of `bsdtar -tf` back to the name it escapes. + static QString nameFrom(QByteArrayView line); + +private: + QByteArray m_pending; + QStringList m_names; + qint64 m_count = 0; + qint64 m_bytes = 0; + bool m_full = false; +}; + // The state behind `folio preview`: the file being looked at, its siblings // to step through, and which viewer shows it. It runs in its own process, as // Sushi does for Nautilus, so a video or PDF that crashes a decoder takes the @@ -27,6 +81,8 @@ class Previewer : public QObject { Q_PROPERTY(QString url READ url NOTIFY changed) Q_PROPERTY(QString text READ text NOTIFY changed) Q_PROPERTY(QString type READ type NOTIFY changed) + // Said in place of an archive's names: it's empty, can't be read, or needs bsdtar. + Q_PROPERTY(QString sentence READ sentence NOTIFY changed) public: explicit Previewer(QObject *parent = nullptr); @@ -49,14 +105,17 @@ class Previewer : public QObject { QString url() const; QString text() const { return m_text; } QString type() const; + QString sentence() const { return m_sentence; } Q_INVOKABLE void next(); Q_INVOKABLE void previous(); Q_INVOKABLE void open(); // Which view shows a type: image, animated, thumbnail, video, audio, - // pdf, markdown, text, folder or other. + // pdf, font, markdown, text, archive, folder or other. static QString viewerFor(const QString &mime, bool isDir); + // Whether `bsdtar -tf` lists an archive of this type; a lone .gz, .xz or .zst isn't one. + static bool listsArchive(const QString &mime); // The opening part of a text file, ending at a line when there's more, // or nothing when it looks binary. Laying out text takes the UI thread // about a third of a millisecond per KB, so the preview reads 128 KB. @@ -65,6 +124,9 @@ class Previewer : public QObject { // `cells` cells: laid out, each costs the UI thread about as much as a // whole line of prose, and a long table froze the preview for seconds. static QString markdownOpening(const QString &text, int cells = 600); + // Lists an archive with `helper`: `show` gets the names so far, then all, or a sentence instead. + static void listArchive(Helper &helper, const QString &path, + const std::function &show); signals: void changed(); @@ -77,6 +139,8 @@ class Previewer : public QObject { int m_index = -1; QString m_viewer; QString m_text; + QString m_sentence; + Helper m_helper{this}; bool m_showHidden = false; QString m_sort = "name"; quint64 m_generation = 0; diff --git a/src/resources.qrc b/src/resources.qrc index a4118dfc..0272632c 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -22,6 +22,7 @@ FolioScrollBar.qml PlainButton.qml PlainField.qml + Sentence.qml SortMenu.qml FileMenu.qml FolioDialog.qml diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..fe992d37 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,6 +1,8 @@ #include +#include #include #include +#include #include #include #include @@ -22,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -29,8 +32,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -39,6 +44,7 @@ #include "disks.h" #include "fileops.h" #include "foldermodel.h" +#include "fontsample.h" #include "mounter.h" #include "places.h" #include "plugins.h" @@ -3355,6 +3361,98 @@ private slots: QCOMPARE(backend.title(), QString("Trash")); } + void backendReadsMediaFacts() + { + // A phone's clip: turned a quarter, with sound, past the hour. + const QVariantMap clip = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "hevc", "codec_type": "video", "width": 1920, "height": 1080, + "side_data_list": [{"side_data_type": "Display Matrix", "rotation": -90}]}, + {"codec_name": "aac", "codec_type": "audio"}], + "format": {"duration": "3725.900000"}})"); + QCOMPARE(clip, (QVariantMap{{"seconds", 3725.9}, {"width", 1080}, {"height", 1920}, {"video", "H.265"}, {"audio", "AAC"}})); + + // A song with its cover art: the cover gives no picture size or video codec. + const QVariantMap song = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "pcm_s16le", "codec_type": "audio"}, + {"codec_name": "mjpeg", "codec_type": "video", "width": 600, "height": 600, + "disposition": {"attached_pic": 1}}], + "format": {"duration": "59.990000"}})"); + QCOMPARE(song, (QVariantMap{{"seconds", 59.99}, {"audio", "PCM"}})); + + // A dump with no duration has no length; a codec without a name of + // its own keeps ffprobe's, and one without any gives no row. + const QVariantMap dump = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "prores", "codec_type": "video", "width": 3840, "height": 2160}, + {"codec_type": "audio"}], "format": {}})"); + QCOMPARE(dump, (QVariantMap{{"width", 3840}, {"height", 2160}, {"video", "PRORES"}})); + QVERIFY(Backend::mediaFacts("not json").isEmpty()); + } + + void backendAsksFfprobeAboutMedia() + { + // A stand-in ffprobe that answers for clip.mp4 unless a file named broken is beside it, fails on anything else, and notes what it was asked. + QTemporaryDir dir, empty; + const QString asked = dir.filePath("asked"); + write(dir.filePath("answer.json"), R"({"streams": [{"codec_name": "h264", "codec_type": "video", + "width": 1280, "height": 720}, {"codec_name": "opus", "codec_type": "audio"}], "format": {"duration": "65.4"}})"); + write(dir.filePath("ffprobe"), "#!/bin/sh\nPATH=/usr/bin:/bin\nfor a in \"$@\"; do echo \"$a\"; done > '" + asked.toUtf8() + + "'\ncase \"$*\" in *'/-a: clip.mp4') [ -e '" + dir.filePath("broken").toUtf8() + "' ] && exit 1; cat '" + + dir.filePath("answer.json").toUtf8() + + "' ;; *) exit 1 ;; esac\n"); + QVERIFY(QFile::setPermissions(dir.filePath("ffprobe"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const QString clip = dir.filePath("-a: clip.mp4"); + write(clip, "x"); + + Backend backend(dir.path()); + { + const auto restore = pathAt(dir.path()); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QVERIFY(!backend.details().contains("seconds")); + QTRY_COMPARE(backend.details().value("seconds").toDouble(), 65.4); + QCOMPARE(backend.details().value("width").toInt(), 1280); + QCOMPARE(backend.details().value("height").toInt(), 720); + QCOMPARE(backend.details().value("video").toString(), QString("H.264")); + QCOMPARE(backend.details().value("audio").toString(), QString("Opus")); + QFile file(asked); + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(file.readAll()).split('\n').value(6), "file:" + clip); + + // The clip written over with something ffprobe can't read loses the length and codecs it showed. + write(dir.filePath("broken"), "x"); + QVERIFY(QFile::remove(asked)); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QTRY_VERIFY(QFile::exists(asked)); + QTRY_VERIFY(!backend.details().contains("seconds")); + QVERIFY(!backend.details().contains("video")); + QVERIFY(QFile::remove(dir.filePath("broken"))); + + // ffprobe failing says nothing. + QVERIFY(QFile::remove(asked)); + backend.describe({{"path", dir.filePath("song.mp3")}, {"kind", "audio"}, {"mime", "audio/mpeg"}}); + QTRY_VERIFY(QFile::exists(asked)); + QTest::qWait(200); + QVERIFY(backend.details().isEmpty()); + } + // Without ffprobe, the package that has it is named. + const auto restore = pathAt(empty.path()); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Lengths and codecs need ffprobe, from the ffmpeg package.")); + QVERIFY(!backend.details().contains("seconds")); + } + + void formatShowsLengthsAsAClock() + { + QQmlEngine engine; + QQmlComponent lengths(&engine); + lengths.setData("import QtQml\nimport \"Format.js\" as Format\n" + "QtObject { property var shown: [0, 0.4, 59.99, 65, 3599, 3725.9, 36000].map(Format.length) }", + QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); + std::unique_ptr object(lengths.create()); + QVERIFY2(object, qPrintable(lengths.errorString())); + QCOMPARE(object->property("shown").toStringList(), + QStringList({"", "0:00", "0:59", "1:05", "59:59", "1:02:05", "10:00:00"})); + } + void placesListEveryFolderInHome() { QTemporaryDir home; @@ -5552,7 +5650,15 @@ private slots: QCOMPARE(Previewer::viewerFor("text/markdown", false), QString("markdown")); QCOMPARE(Previewer::viewerFor("text/x-c++src", false), QString("text")); QCOMPARE(Previewer::viewerFor("application/x-shellscript", false), QString("text")); - QCOMPARE(Previewer::viewerFor("application/zip", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/zip", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-compressed-tar", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-xz-compressed-tar", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-cpio", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-iso9660-image", false), QString("archive")); + // bsdtar can't list a lone compressed file, so it stays as 0.2.0 showed it. + QCOMPARE(Previewer::viewerFor("application/gzip", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/zstd", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/x-sharedlib", false), QString("other")); QCOMPARE(Previewer::viewerFor("inode/directory", true), QString("folder")); } @@ -5793,6 +5899,553 @@ private slots: } } + void archiveNamesAreReadAsTheyArrive() + { + // bsdtar's output comes in parts that can end inside a line; a name + // is taken once its line is whole, decoded from bsdtar's escapes. + ArchiveNames names; + QVERIFY(names.add(".\n./\n./sub dir/\n./sub dir/caf\\303\\251 \\\\ two\\nlines.txt\n./ze")); + QVERIFY(!names.add("b")); + QVERIFY(names.add("ra.txt\n")); + QCOMPARE(names.text(false), QString::fromUtf8("At least 3 items\n\nsub dir/\nsub dir/café \\ two\\nlines.txt\nzebra.txt")); + QCOMPARE(names.text(true), QString::fromUtf8("3 items\n\nsub dir/\nsub dir/café \\ two\\nlines.txt\nzebra.txt")); + + // Past the names shown, the rest are only counted. + ArchiveNames many; + QByteArray output; + for (int i = 0; i < ArchiveNames::shown + 3; ++i) + output += QByteArray::number(i) + ".txt\n"; + QVERIFY(many.add(output)); + QVERIFY(!many.add("more.txt\n")); + const QStringList lines = many.text(true).split('\n'); + QCOMPARE(lines.size(), ArchiveNames::shown + 3); + QCOMPARE(lines.first(), QString("%1 items").arg(ArchiveNames::shown + 4)); + QCOMPARE(lines.last(), QString("4 more")); + QCOMPARE(many.text(false).section('\n', -1), QString("At least 4 more")); + + // Past the size shown too, however few the names are; a short one after a long one isn't shown out of its place. + ArchiveNames longOnes; + const QByteArray longName(int(ArchiveNames::shownBytes) * 2 / 5, 'n'); + QVERIFY(longOnes.add(longName + "1\n" + longName + "2\n")); + QVERIFY(!longOnes.add(longName + "3\nshort.txt\n")); + const QStringList few = longOnes.text(true).split('\n'); + QCOMPARE(few.size(), 5); + QCOMPARE(few.first(), QString("4 items")); + QCOMPARE(few.at(3), QString::fromLatin1(longName + "2")); + QCOMPARE(few.last(), QString("2 more")); + + // A name that comes in many parts is one name, whatever part its line ends in. + ArchiveNames parts; + QVERIFY(!parts.add("one")); + QVERIFY(!parts.add(" long")); + QVERIFY(parts.add(" name\ntwo")); + QVERIFY(parts.add("\n")); + QCOMPARE(parts.text(true), QString("2 items\n\none long name\ntwo")); + } + + void archivesListTheirNamesOffTheUiThread() + { + // The details pane and the preview window list an archive with + // bsdtar, and say so when there's nothing to list. + QTemporaryDir dir; + QDir(dir.path()).mkpath("files/sub dir"); + write(dir.filePath("files/sub dir/a b.txt"), "x"); + write(dir.filePath("files/-n.txt"), "x"); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("-files.tar"), "-C", dir.filePath("files"), "--", "sub dir", "-n.txt"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("empty.tar"), "-T", "/dev/null"}), 0); + write(dir.filePath("notes.zip"), "hello\n"); + const QString listing = "3 items\n\nsub dir/\nsub dir/a b.txt\n-n.txt"; + + Backend backend(dir.path()); + auto describe = [&](const QString &name) { + backend.describe({{"path", dir.filePath(name)}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + }; + describe("-files.tar"); + QVERIFY(!backend.details().contains("text")); + QTRY_COMPARE(backend.details().value("text").toString(), listing); + describe("empty.tar"); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Empty")); + QVERIFY(backend.details().value("text").toString().isEmpty()); + // bsdtar takes a text file for a list of names, then fails. + describe("notes.zip"); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Couldn't read this archive.")); + QVERIFY(backend.details().value("text").toString().isEmpty()); + + Previewer previewer; + previewer.start(dir.filePath("-files.tar"), false, "name"); + QCOMPARE(previewer.viewer(), QString("archive")); + QTRY_COMPARE(previewer.text(), listing); + QVERIFY(previewer.sentence().isEmpty()); + // The commonest tarball, named by its extension as the folder lists it. + QCOMPARE(QProcess::execute("bsdtar", {"-J", "-cf", dir.filePath("-files.tar.xz"), "-C", dir.filePath("files"), "--", "sub dir", "-n.txt"}), 0); + previewer.show(dir.filePath("-files.tar.xz")); + QCOMPARE(previewer.viewer(), QString("archive")); + QTRY_COMPARE(previewer.text(), listing); + + { + const auto restore = pathAt(dir.filePath("files")); + describe("-files.tar"); + QTRY_COMPARE(backend.details().value("sentence").toString(), + QString("Listing archives needs bsdtar, from the libarchive package.")); + } + } + + void archivePreviewKeepsItsPlaceWhileNamesArrive() + { + // A stand-in bsdtar that lists half of a.tar's names, and the rest once `go` is there. + QTemporaryDir tools, dir, home; + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("a.tar"), "-T", "/dev/null"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("b.tar"), "-T", "/dev/null"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("c.tar"), "-T", "/dev/null"}), 0); + const QByteArray go = QFile::encodeName(tools.filePath("go")); + write(tools.filePath("bsdtar"), "#!/bin/sh\nPATH=/usr/bin:/bin\ncase \"$2\" in\n" + "*/a.tar) seq -f name-%g 1 200; while [ ! -e '" + go + "' ]; do sleep 0.05; done; seq -f name-%g 201 400 ;;\n" + "*) seq -f name-%g 1 400 ;;\n" + "esac\n"); + QVERIFY(QFile::setPermissions(tools.filePath("bsdtar"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto restore = pathAt(tools.path()); + + Theme theme(home.filePath("theme")); + Previewer previewer; + previewer.start(dir.filePath("a.tar"), false, "name"); + QQmlEngine engine; + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("previewer", &previewer); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/PreviewWindow.qml")); + std::unique_ptr window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + const auto view = [&] { return window->property("view").value(); }; + const auto place = [&] { return view()->property("contentY").toReal(); }; + + // Scrolled down while the rest is still to come: the place is kept when it comes. + QTRY_VERIFY(previewer.text().contains("name-200")); + QTRY_VERIFY(view() && view()->property("contentHeight").toReal() > view()->property("height").toReal() + 300); + QVERIFY(view()->setProperty("contentY", 300)); + write(tools.filePath("go"), "x"); + QTRY_VERIFY(previewer.text().contains("name-400")); + QCOMPARE(place(), 300.0); + + // Another file starts at its top. + previewer.next(); + QCOMPARE(previewer.item().value("name").toString(), QString("b.tar")); + QTRY_VERIFY(previewer.text().contains("name-400")); + QCOMPARE(place(), 0.0); + + // And does so while the last one still glides. + const qreal glide = 3000; + QVERIFY(view()->setProperty("contentY", 300)); + QVERIFY(QMetaObject::invokeMethod(view(), "flick", Q_ARG(qreal, 0), Q_ARG(qreal, -glide))); + QTRY_VERIFY(view()->property("moving").toBool()); + previewer.next(); + QCOMPARE(previewer.item().value("name").toString(), QString("c.tar")); + QTRY_VERIFY(previewer.text().contains("name-400")); + QTRY_VERIFY(!view()->property("moving").toBool()); + QCOMPARE(place(), 0.0); + } + + void helperKeepsOnlyTheStartOfWhatItsProgramComplainsOf() + { + // A program that fails with far more on stderr than a run keeps. + const qint64 printed = 64 * Helper::errorsKept; + Helper helper; + int ended = 0; + QByteArray errors; + helper.run("sh", {"-c", QString("echo first-line >&2; head -c %1 /dev/zero >&2; exit 3").arg(printed)}, 1024, + [](const QByteArray &) {}, [&](int code, const QByteArray &said) { ended = code; errors = said; }); + QTRY_COMPARE(ended, 3); + QCOMPARE(qint64(errors.size()), Helper::errorsKept); + QVERIFY(errors.startsWith("first-line\n")); + } + + void helperStopsReadingForWhatsNoLongerShown() + { + // A stand-in bsdtar that never finishes on stuck.tar, says the + // names are encrypted on locked.7z, and lists one name otherwise. + QTemporaryDir dir; + const QString pid = dir.filePath("pid"); + const QString kid = dir.filePath("kid"); + write(dir.filePath("bsdtar"), "#!/bin/sh\nPATH=/usr/bin:/bin\ncase \"$2\" in\n" + "*/stuck.tar) echo $$ > '" + pid.toUtf8() + "'; exec sleep 30 ;;\n" + "*/family.tar) sleep 30 & echo $! > '" + kid.toUtf8() + "'; echo $$ > '" + pid.toUtf8() + "'; wait ;;\n" + "*/locked.7z) echo 'Encrypted file is unsupported' >&2; exit 1 ;;\n" + "esac\necho one.txt\n"); + QVERIFY(QFile::setPermissions(dir.filePath("bsdtar"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto restore = pathAt(dir.path()); + auto stuckPid = [&] { + QFile file(pid); + return file.open(QIODevice::ReadOnly) ? pid_t(file.readAll().trimmed().toInt()) : pid_t(0); + }; + + // Moving on stops it at once, and what it said is never shown. + Backend backend(dir.path()); + backend.describe({{"path", dir.filePath("stuck.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY(stuckPid() > 0); + const pid_t stuck = stuckPid(); + backend.describe({{"path", dir.filePath("other.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuck, 0) != 0, 2000); + QTRY_COMPARE(backend.details().value("text").toString(), QString("1 items\n\none.txt")); + backend.describe({{"path", dir.filePath("locked.7z")}, {"kind", "archive"}, {"mime", "application/x-7z-compressed"}}); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("The names in this archive are encrypted.")); + + // What the program started (as bsdtar starts a program to unpack with) is stopped with it. + QFile::remove(pid); + backend.describe({{"path", dir.filePath("family.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY(stuckPid() > 0); + QFile started(kid); + QVERIFY(started.open(QIODevice::ReadOnly)); + const pid_t child = pid_t(started.readAll().trimmed().toInt()); + QVERIFY(child > 0 && !ended(child)); + backend.describe({{"path", dir.filePath("other.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY_WITH_TIMEOUT(ended(child), 2000); + + // A plugin's own item is no file on the disk: no program is asked about it. + QFile::remove(pid); + backend.describe({{"path", "notes:/stuck.tar"}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTest::qWait(200); + QCOMPARE(stuckPid(), pid_t(0)); + QVERIFY(!backend.details().contains("sentence")); + + // An archive that gives no name in its time took too long: it isn't called damaged. + { + Helper slow(nullptr, 300); + QString sentence; + Previewer::listArchive(slow, dir.filePath("stuck.tar"), [&](const QString &, const QString &said) { sentence = said; }); + QTRY_COMPARE(sentence, QString("Listing this archive took too long.")); + } + + // Past its time it's stopped too, and so is one whose view goes. + QFile::remove(pid); + Helper helper(nullptr, 300); + int ended = 0; + helper.run("bsdtar", {"-tf", dir.filePath("stuck.tar")}, 1024, [](const QByteArray &) {}, + [&](int code, const QByteArray &) { ended = code; }); + QTRY_VERIFY(stuckPid() > 0); + QTRY_COMPARE(ended, Helper::stopped); + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuckPid(), 0) != 0, 2000); + QFile::remove(pid); + { + Helper going; + going.run("bsdtar", {"-tf", dir.filePath("stuck.tar")}, 1024, [](const QByteArray &) {}, + [](int, const QByteArray &) {}); + QTRY_VERIFY(stuckPid() > 0); + } + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuckPid(), 0) != 0, 2000); + } + + void previewerShowsFontsAsASampleSheet() + { + for (const char *mime : {"font/ttf", "font/otf", "font/collection", "font/woff", "font/woff2", + "application/x-font-ttf", "application/x-font-otf", "application/x-font-type1"}) + QCOMPARE(Previewer::viewerFor(mime, false), QString("font")); + // A bitmap font has no outlines to draw a sheet in. + QCOMPARE(Previewer::viewerFor("application/x-font-bdf", false), QString("other")); + + // By its extension alone a ".otf" is an OpenDocument template; it's still shown as a font. + QTemporaryDir dir; + write(dir.filePath("Sample.otf"), tinyFont("Folio Sample", "ABC")); + Previewer previewer; + previewer.start(dir.filePath("Sample.otf"), false, "name"); + QCOMPARE(previewer.viewer(), QString("font")); + + // And named as one, in the preview window and in the details pane. + const QMimeDatabase mimes; + const QString byName = mimes.mimeTypeForFile(dir.filePath("Sample.otf"), QMimeDatabase::MatchExtension).name(); + const QString font = mimes.mimeTypeForName("font/otf").comment(); + QVERIFY(!font.isEmpty() && mimes.mimeTypeForName(byName).comment() != font); + QCOMPARE(previewer.type(), font); + QCOMPARE(Backend::typeName(byName, dir.filePath("Sample.otf")), font); + QCOMPARE(Backend::typeName("text/plain", dir.filePath("notes.txt")), mimes.mimeTypeForName("text/plain").comment()); + } + + void fontSampleKeepsItsFontToItself() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + write(dir.filePath("One.ttf"), tinyFont("Folio One", latin)); + write(dir.filePath("Two.ttf"), tinyFont("Folio Two", latin)); + write(dir.filePath("notes.txt"), "x"); + const QStringList before = QFontDatabase::families(); + + auto sample = std::make_unique(); + sample->setPath(dir.filePath("One.ttf")); + QCOMPARE(sample->status(), QString("loading")); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->family(), QString("Folio One")); + QCOMPARE(sample->style(), QString("Regular")); + QCOMPARE(sample->faces(), 1); + QVERIFY(sample->glyphs().isEmpty()); + // The sample has the font; Folio's own list of fonts never does. + QCOMPARE(sample->font(12).familyName(), QString("Folio One")); + QCOMPARE(QFontDatabase::families(), before); + + // Stepping to another font swaps one for the other, and to a file that isn't one leaves neither. + sample->setPath(dir.filePath("Two.ttf")); + QVERIFY(!sample->font(12).isValid()); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->font(12).familyName(), QString("Folio Two")); + QCOMPARE(QFontDatabase::families(), before); + sample->setPath(dir.filePath("notes.txt")); + QVERIFY(sample->status().isEmpty()); + QVERIFY(!sample->font(12).isValid()); + + // A font stepped past while it's read is never the one kept. + sample->setPath(dir.filePath("One.ttf")); + sample->setPath(dir.filePath("Two.ttf")); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->family(), QString("Folio Two")); + QCOMPARE(sample->font(12).familyName(), QString("Folio Two")); + QCOMPARE(QFontDatabase::families(), before); + } + + void fontSampleDrawsOnlyWhatTheFontHas() + { + QTemporaryDir dir; + // Greek letters and a star but no Latin: the letters are drawn, never + // the alphabet in boxes or another font. Without letters, the star. + write(dir.filePath("Greek.ttf"), tinyFont("Folio Greek", QString::fromUtf8("★αγβ"))); + write(dir.filePath("Stars.ttf"), tinyFont("Folio Stars", QString::fromUtf8("★☆"))); + // The alphabet without the digits: its letters are drawn, never digits from another font. + const QString letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + write(dir.filePath("Letters.ttf"), tinyFont("Folio Letters", letters)); + write(dir.filePath("Pair.ttc"), tinyCollection({"Folio Left", "Folio Right"})); + write(dir.filePath("Broken.ttf"), "not a font"); + write(dir.filePath("Empty.woff2"), ""); + const QStringList before = QFontDatabase::families(); + FontSample sample; + sample.setPath(dir.filePath("Greek.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), QString::fromUtf8("αβγ")); + sample.setPath(dir.filePath("Stars.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), QString::fromUtf8("★☆")); + sample.setPath(dir.filePath("Letters.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), letters); + // The alphabet and the digits without the space: its letters again, never a line of words with boxes between them. + write(dir.filePath("Tight.ttf"), tinyFont("Folio Tight", letters + "0123456789")); + sample.setPath(dir.filePath("Tight.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), letters); + + // A collection shows its first face and counts them all. + sample.setPath(dir.filePath("Pair.ttc")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio Left")); + QCOMPARE(sample.faces(), 2); + + // A file that can't be read as a font adds nothing. + sample.setPath(dir.filePath("Broken.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + QVERIFY(sample.family().isEmpty()); + sample.setPath(dir.filePath("Empty.woff2")); + QTRY_COMPARE(sample.status(), QString("error")); + + // Only a mark that sits on another character: nothing to draw alone, so no sheet from another font. + write(dir.filePath("Marks.ttf"), tinyFont("Folio Marks", QString(QChar(0x0301)))); + sample.setPath(dir.filePath("Marks.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + + // A pipe named like a font, with nobody writing to it, is not waited on. + const QByteArray pipe = QFile::encodeName(dir.filePath("Pipe.ttf")); + QCOMPARE(::mkfifo(pipe.constData(), 0600), 0); + // Lets go of a reader that did wait, so a failure here ends the suite. + const auto release = qScopeGuard([&] { ::close(::open(pipe.constData(), O_WRONLY | O_NONBLOCK)); }); + sample.setPath(dir.filePath("Pipe.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + QCOMPARE(QFontDatabase::families(), before); + } + + void fontSamplesOfOneNameEachDrawTheirOwnFile() + { + // Two files of one family and style, shown at once as two windows show them, and a font of that name among Folio's own. + QTemporaryDir dir; + const int there = QFontDatabase::addApplicationFontFromData(tinyFont("Folio Twin", "C")); + QVERIFY(there >= 0); + const auto remove = qScopeGuard([&] { QFontDatabase::removeApplicationFont(there); }); + write(dir.filePath("First.ttf"), tinyFont("Folio Twin", "A")); + write(dir.filePath("Second.ttf"), tinyFont("Folio Twin", "B")); + FontSample first, second; + FontText one, two; + // The glyph drawn for each character of "AB": 0 is the font's own missing glyph. + const auto drawn = [](const FontText &text) { + QList glyphs; + for (const QGlyphRun &run : text.runs()) + glyphs += run.glyphIndexes(); + return glyphs; + }; + for (FontText *text : {&one, &two}) { + text->setWidth(400); + text->setPixelSize(18); + text->setText("AB"); + } + one.setSource(&first); + two.setSource(&second); + QVERIFY(drawn(one).isEmpty()); + first.setPath(dir.filePath("First.ttf")); + QTRY_COMPARE(first.status(), QString("ready")); + second.setPath(dir.filePath("Second.ttf")); + QTRY_COMPARE(second.status(), QString("ready")); + QCOMPARE(first.family(), second.family()); + QCOMPARE(first.style(), second.style()); + QCOMPARE(drawn(one), QList({1, 0})); + QCOMPARE(drawn(two), QList({0, 1})); + QVERIFY(one.implicitHeight() > 0); + // Folio's own font of that name is still the one its name draws. + const QRawFont named = QRawFont::fromFont(QFont("Folio Twin")); + QVERIFY(named.supportsCharacter(QChar('C')) && !named.supportsCharacter(QChar('A')) && !named.supportsCharacter(QChar('B'))); + + // Lines break at the width, a new line in the text starts one, and nothing is drawn once the font goes. + two.setAnywhere(true); + two.setText("BBBB\nB"); + const qreal twoLines = two.implicitHeight(); + QVERIFY(twoLines > one.implicitHeight()); + two.setWidth(two.pixelSize()); + QVERIFY(two.implicitHeight() > twoLines); + QCOMPARE(drawn(two).size(), 5); + second.setPath({}); + QVERIFY(drawn(two).isEmpty()); + QCOMPARE(two.implicitHeight(), 0.0); + QCOMPARE(drawn(one), QList({1, 0})); + } + + void fontTextPaintsAPictureMadeWithItsFont() + { + // paint() runs on the render thread, where a font made on this one must not be used: it only copies a picture. + QTemporaryDir dir; + write(dir.filePath("Inked.ttf"), tinyFont("Folio Inked", "A", 0, true)); + FontSample sample; + FontText text; + text.setWidth(200); + text.setPixelSize(40); + text.setColor(Qt::red); + text.setText("A"); + text.setSource(&sample); + QVERIFY(text.picture().isNull()); + sample.setPath(dir.filePath("Inked.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + // How many pixels are wholly ink of one colour: the square's inside. + const auto inked = [](const QImage &picture, const QColor &colour) { + int pixels = 0; + for (int y = 0; y < picture.height(); ++y) + for (int x = 0; x < picture.width(); ++x) + pixels += picture.pixelColor(x, y) == colour; + return pixels; + }; + const QImage red = text.picture(); + QCOMPARE(red.size(), (QSizeF(200, text.implicitHeight()) * red.devicePixelRatio()).toSize()); + QVERIFY(inked(red, Qt::red) > 100); + QCOMPARE(inked(red, Qt::blue), 0); + // What paint() puts down is that picture. + QImage painted(red.size(), red.format()); + painted.setDevicePixelRatio(red.devicePixelRatio()); + painted.fill(Qt::transparent); + { + QPainter painter(&painted); + text.paint(&painter); + } + QCOMPARE(painted, red); + // A new colour makes a new picture, and no font leaves none. + text.setColor(Qt::blue); + QVERIFY(inked(text.picture(), Qt::blue) > 100); + QCOMPARE(inked(text.picture(), Qt::red), 0); + sample.setPath({}); + QVERIFY(text.picture().isNull()); + } + + void fontSampleReadsAFontAgainWhenItIsWrittenOver() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const QString path = dir.filePath("One.ttf"); + const QDateTime written = QDateTime::currentDateTime(); + write(path, tinyFont("Folio Before", latin)); + FontSample sample; + sample.setPath(path); + sample.setModified(written); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio Before")); + + // Written over in place: the path is the same, the time the folder reports is not. + write(path, tinyFont("Folio After", latin)); + sample.setModified(written.addSecs(1)); + QCOMPARE(sample.status(), QString("loading")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio After")); + QCOMPARE(sample.font(12).familyName(), QString("Folio After")); + + // The same time again reads nothing, and neither does a time for a file that isn't a font. + sample.setModified(written.addSecs(1)); + QCOMPARE(sample.status(), QString("ready")); + write(dir.filePath("notes.txt"), "x"); + sample.setPath(dir.filePath("notes.txt")); + sample.setModified(written.addSecs(2)); + QVERIFY(sample.status().isEmpty()); + } + + void fontSampleReadsOnlyWhatIsStillShown() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + write(dir.filePath("One.ttf"), tinyFont("Folio One", latin)); + write(dir.filePath("Two.ttf"), tinyFont("Folio Two", latin)); + // Counts how often each font is opened from here on. + const int watch = ::inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + QVERIFY(watch >= 0); + const auto closed = qScopeGuard([watch] { ::close(watch); }); + const int one = ::inotify_add_watch(watch, QFile::encodeName(dir.filePath("One.ttf")).constData(), IN_OPEN); + const int two = ::inotify_add_watch(watch, QFile::encodeName(dir.filePath("Two.ttf")).constData(), IN_OPEN); + QVERIFY(one >= 0 && two >= 0); + QHash opened; + const auto count = [&] { + char events[4096]; + for (ssize_t got; (got = ::read(watch, events, sizeof events)) > 0;) { + for (ssize_t at = 0; at < got;) { + inotify_event event; + memcpy(&event, events + at, sizeof event); + ++opened[event.wd]; + at += ssize_t(sizeof event) + event.len; + } + } + }; + + // A selection that moves sets the path, then the time: the font is opened once for both. + static constexpr int aReadingStartsMs = 100; + FontSample sample; + sample.setPath(dir.filePath("One.ttf")); + QThread::msleep(aReadingStartsMs); + // Counted here too: two opens that nobody read between come as one event. + count(); + sample.setModified(QDateTime::currentDateTime()); + QTRY_COMPARE(sample.status(), QString("ready")); + count(); + QCOMPARE(opened.value(one), 1); + + // With every worker busy, a font stepped past before its turn comes is never opened. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore busy; + pool->setMaxThreadCount(1); + const auto freed = qScopeGuard([&] { + busy.release(); + pool->setMaxThreadCount(workers); + }); + QFuture holder = QtConcurrent::run([&busy] { busy.acquire(); }); + sample.setPath(dir.filePath("Two.ttf")); + QCoreApplication::processEvents(); + sample.setPath(dir.filePath("One.ttf")); + QCoreApplication::processEvents(); + busy.release(); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio One")); + holder.waitForFinished(); + count(); + QCOMPARE(opened.value(two), 0); + QCOMPARE(opened.value(one), 2); + } + void themeFallsBack() { QTemporaryDir dir; @@ -6080,6 +6733,108 @@ private slots: return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } + // A TrueType font for `chars`, its glyphs empty unless `inked`, starting `at` bytes into its file: enough for FreeType to load. + static QByteArray tinyFont(const QString &family, const QString &chars, quint32 at = 0, bool inked = false) + { + QList codes; + for (const QChar c : chars) + codes << c.unicode(); + std::sort(codes.begin(), codes.end()); + const quint16 glyphs = quint16(codes.size() + 1); + const quint16 segments = quint16(codes.size() + 1); + auto table = [](const std::function &fill) { + QByteArray bytes; + QDataStream s(&bytes, QIODevice::WriteOnly); + fill(s); + return bytes; + }; + auto zeros = [](QDataStream &s, int n) { for (int i = 0; i < n; ++i) s << quint16(0); }; + // By tag, in the sorted order the table directory wants. + QMap tables; + tables["cmap"] = table([&](QDataStream &s) { + s << quint16(0) << quint16(1) << quint16(3) << quint16(1) << quint32(12); + s << quint16(4) << quint16(16 + 8 * segments) << quint16(0) << quint16(2 * segments); + zeros(s, 3); + for (const quint16 c : codes) s << c; + s << quint16(0xFFFF) << quint16(0); + for (const quint16 c : codes) s << c; + s << quint16(0xFFFF); + for (int i = 0; i < codes.size(); ++i) s << quint16(i + 1 - codes[i]); + s << quint16(1); + zeros(s, segments); + }); + // When `inked`, the first character is a square: one contour of 4 points on the curve, each a 16-bit step. + tables["glyf"] = inked ? table([](QDataStream &s) { + s << qint16(1) << qint16(100) << qint16(0) << qint16(500) << qint16(700) << quint16(3) << quint16(0); + for (int i = 0; i < 4; ++i) s << quint8(1); + s << qint16(100) << qint16(400) << qint16(0) << qint16(-400) << qint16(0) << qint16(0) << qint16(700) << qint16(0); + }) : QByteArray(4, '\0'); + tables["head"] = table([](QDataStream &s) { + s << quint32(0x10000) << quint32(0x10000) << quint32(0) << quint32(0x5F0F3CF5) << quint16(0xB) + << quint16(1000) << quint64(0) << quint64(0) << qint16(0) << qint16(-200) << qint16(600) + << qint16(800) << quint16(0) << quint16(8) << qint16(2) << qint16(0) << qint16(0); + }); + tables["hhea"] = table([&](QDataStream &s) { + s << quint32(0x10000) << qint16(800) << qint16(-200) << qint16(0) << quint16(600); + zeros(s, 3); + s << qint16(1); + zeros(s, 7); + s << glyphs; + }); + tables["hmtx"] = table([&](QDataStream &s) { for (int i = 0; i < glyphs; ++i) s << quint16(600) << qint16(0); }); + // In 2-byte units: the square's 34 bytes are glyph 1, and every other glyph is empty. + tables["loca"] = table([&](QDataStream &s) { + zeros(s, 2); + for (int i = 2; i <= glyphs; ++i) s << quint16(inked ? 17 : 0); + }); + tables["maxp"] = table([&](QDataStream &s) { + s << quint32(0x10000) << glyphs << quint16(inked ? 4 : 0) << quint16(inked ? 1 : 0); + zeros(s, 2); + s << quint16(2); + zeros(s, 8); + }); + const QStringList names{family, "Regular", family + " Regular", QString(family).remove(' ')}; + tables["name"] = table([&](QDataStream &s) { + s << quint16(0) << quint16(names.size()) << quint16(6 + 12 * names.size()); + const QList ids{1, 2, 4, 6}; + int offset = 0; + for (int i = 0; i < names.size(); ++i) { + s << quint16(3) << quint16(1) << quint16(0x409) << ids[i] << quint16(2 * names[i].size()) << quint16(offset); + offset += 2 * names[i].size(); + } + for (const QString &name : names) + for (const QChar c : name) s << quint16(c.unicode()); + }); + + QByteArray font = table([&](QDataStream &s) { + s << quint32(0x10000) << quint16(tables.size()); + zeros(s, 3); + }); + QByteArray body; + const quint32 start = at + 12 + 16 * tables.size(); + for (auto it = tables.cbegin(); it != tables.cend(); ++it) { + font += it.key(); + font += table([&](QDataStream &s) { s << quint32(0) << quint32(start + body.size()) << quint32(it.value().size()); }); + body += it.value() + QByteArray((4 - it.value().size() % 4) % 4, '\0'); + } + return font + body; + } + + // A collection of one tiny font per family, each face after the last. + static QByteArray tinyCollection(const QStringList &families) + { + const quint32 header = 12 + 4 * families.size(); + QByteArray faces; + QByteArray collection("ttcf"); + QDataStream s(&collection, QIODevice::Append); + s << quint32(0x10000) << quint32(families.size()); + for (const QString &family : families) { + s << quint32(header + faces.size()); + faces += tinyFont(family, "AB", header + faces.size()); + } + return collection + faces; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path);