dtkgui/tools/dci-icon-theme/main.cpp

521 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2022 - 2023 UnionTech Software Technology Co., Ltd.
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include <QGuiApplication>
#include <QImageReader>
#include <QCommandLineParser>
#include <QDirIterator>
#include <QBuffer>
#include <QDebug>
#include <QtConcurrent/QtConcurrent>
#include <DDciFile>
#include <stdexcept>
#include <atomic>
DCORE_USE_NAMESPACE
// Custom exception for DCI processing errors
class DciProcessingError : public std::runtime_error {
public:
explicit DciProcessingError(const QString &message, int code = -1)
: std::runtime_error(message.toStdString()), errorCode(code) {}
int getErrorCode() const { return errorCode; }
private:
int errorCode;
};
#define MAX_SCALE 10
#define INVALIDE_QUALITY -2
#define SCALABLE_SIZE 256
static int quality4Scaled[MAX_SCALE] = {};
static inline void initQuality() {
for (int i = 0; i < MAX_SCALE; ++i)
quality4Scaled[i] = INVALIDE_QUALITY;
}
static inline void dciChecker(bool result, std::function<const QString()> cb) {
if (!result) {
qWarning() << "Failed on writing dci file" << cb();
throw DciProcessingError("Failed on writing dci file", -6);
}
}
// TODO 应该使用xdg图标查找规范解析index.theme来查找尺寸
static int foundSize(const QFileInfo &fileInfo) {
QDir dir = fileInfo.absoluteDir();
// 解析尺寸
auto parseSize = [](const QString &dirName) -> int {
bool ok;
if (int size = dirName.toUInt(&ok); ok) {
return size;
}
if (dirName.contains('x') && dirName.split('x').size() == 2) {
if (int size = dirName.split('x').first().toUInt(&ok); ok) {
return size;
}
}
if (dirName == "scalable") {
return SCALABLE_SIZE;
}
return 0;
};
if (int size = parseSize(dir.dirName()); size > 0) {
return size;
}
// 尝试找上一级目录
if (!dir.cdUp())
return 0;
return parseSize(dir.dirName());
}
static inline QByteArray webpImageData(const QImage &image, int quality) {
QByteArray data;
QBuffer buffer(&data);
bool ok = buffer.open(QIODevice::WriteOnly);
Q_ASSERT(ok);
dciChecker(image.save(&buffer, "webp", quality), []{return "failed to save webp image";});
return data;
}
static bool writeScaledImage(DDciFile &dci, const QImage &image, const QString &targetDir, const int baseSize, int scale/* = 2*/)
{
int size = scale * baseSize;
QImage img;
if (image.width() == size) {
img = image;
} else {
img = image.scaledToWidth(size, Qt::SmoothTransformation);
}
dciChecker(dci.mkdir(targetDir + QString("/%1").arg(scale)), [&]{return dci.lastErrorString();});
int quality = quality4Scaled[scale - 1];
const QByteArray &data = webpImageData(img, quality);
dciChecker(dci.writeFile(targetDir + QString("/%1/1.webp").arg(scale), data), [&]{return dci.lastErrorString();});
return true;
}
static bool writeImage(DDciFile &dci, const QString &imageFile, const QString &targetDir)
{
QString sizeDir = targetDir.mid(1, targetDir.indexOf("/", 1) - 1);
bool ok = false;
int baseSize = sizeDir.toInt(&ok);
if (!ok)
baseSize = 256;
QImageReader reader(imageFile);
if (!reader.canRead()) {
qWarning() << "Ignore the null image file:" << imageFile;
return false;
}
for (int i = 0; i < MAX_SCALE; ++i) {
if (quality4Scaled[i] == INVALIDE_QUALITY)
continue;
int scale = i + 1;
reader.setScaledSize(QSize(baseSize * scale, baseSize * scale));
auto image = reader.read();
if (!writeScaledImage(dci, image, targetDir, baseSize, scale))
return false;
}
return true;
}
static bool recursionLink(DDciFile &dci, const QString &fromDir, const QString &targetDir)
{
for (const auto &i : dci.list(fromDir, true)) {
const QString file(fromDir + "/" + i);
const QString targetFile(targetDir + "/" + i);
if (dci.type(file) == DDciFile::Directory) {
if (!dci.mkdir(targetFile))
return false;
if (!recursionLink(dci, file, targetFile))
return false;
} else {
if (!dci.link(file, targetFile))
return false;
}
}
return true;
}
static QByteArray readNextSection(QIODevice *io) {
QByteArray section;
char ch;
bool oneLine = true;
while (io->getChar(&ch)) {
if (!oneLine && ch == '"') { // ["] end
if (io->peek(1) == ",")
io->skip(1); // skip [,]
break;
} else if (ch == ',') {
break;
} else if (ch == '"') { // ["] begin
oneLine = false;
continue;
}
section.append(ch);
if(oneLine && io->peek(1) == "\n")
break;
}
return section.trimmed();
}
QMultiHash<QString, QString> parseIconFileSymlinkMap(const QString &csvFile) {
QFile file(csvFile);
if (!file.open(QIODevice::ReadOnly)) {
qWarning() << "Failed on open symlink map file:" << csvFile;
throw DciProcessingError("Failed on open symlink map file", -7);
}
QMultiHash<QString, QString> map;
while (!file.atEnd()) {
QByteArray key = readNextSection(&file);
QByteArray value = readNextSection(&file);
for (const auto &i : value.split('\n')) {
map.insert(QString::fromLocal8Bit(key), QString::fromLocal8Bit(i));
}
char ch = 0;
while (file.getChar(&ch) && ch != '\n');
}
qInfo() << "Got symlinks:" << map.size();
return map;
}
void makeLink(const QFileInfo &file, const QDir &outputDir, const QString &dciFilePath,
const QMultiHash<QString, QString> &symlinksMap)
{
if (symlinksMap.contains(file.completeBaseName())) {
const QString symlinkKey = QFileInfo(dciFilePath).fileName();
for (const auto &symTarget : symlinksMap.values(file.completeBaseName())) {
const QString newSymlink = outputDir.absoluteFilePath(symTarget + ".dci");
qInfo() << "Create symlink from" << symlinkKey << "to" << newSymlink;
if (!QFile::link(symlinkKey, newSymlink)) {
qWarning() << "Failed on create symlink from" << symlinkKey << "to" << newSymlink;
}
}
}
}
void doFixDarkTheme(const QFileInfo &file, const QDir &outputDir, const QMultiHash<QString, QString> &symlinksMap)
{
const QString &newFile = outputDir.absoluteFilePath(file.fileName());
DDciFile dciFile(file.absoluteFilePath());
if (!dciFile.isValid()) {
qWarning() << "Skip invalid dci file:" << file.absoluteFilePath();
return;
}
for (const auto &i : dciFile.list("/")) {
if (dciFile.type(i) != DDciFile::Directory)
continue;
for (const auto &j : dciFile.list(i)) {
if (dciFile.type(j) != DDciFile::Directory || !j.endsWith(".light"))
continue;
const QString darkDir(j.left(j.size() - 5) + "dark");
Q_ASSERT(darkDir.endsWith(".dark"));
if (!dciFile.exists(darkDir)) {
dciChecker(dciFile.mkdir(darkDir), [&]{return dciFile.lastErrorString();});
dciChecker(recursionLink(dciFile, j, darkDir), [&]{return dciFile.lastErrorString();});
}
}
}
dciChecker(dciFile.writeToFile(newFile), [&]{return dciFile.lastErrorString();});
makeLink(file, outputDir, newFile, symlinksMap);
}
int main(int argc, char *argv[])
{
QCommandLineOption fileFilter({"m", "match"}, "Give wildcard rules on search icon files, "
"Each eligible icon will be packaged to a dci file, "
"If the icon have the dark mode, it needs to store "
"the dark icon file at \"dark/\" directory relative "
"to current icon file, and the file name should be "
"consistent.", "wildcard palette");
QCommandLineOption outputDirectory({"o", "output"}, "Save the *.dci files to the given directory.",
"directory");
QCommandLineOption symlinkMap({"s", "symlink"}, "Give a csv file to create symlinks for the output icon file."
"\nThe content of symlink.csv like:\n"
"\t\t sublime-text, com.sublimetext.2\n"
"\t\t deb, \"\n"
"\t\t application-vnd.debian.binary-package\n"
"\t\t application-x-deb\n"
"\t\t gnome-mime-application-x-deb\n"
"\t\t \"\n"
,
"csv file");
QCommandLineOption fixDarkTheme("fix-dark-theme", "Create symlinks from light theme for dark theme files.");
QCommandLineOption scaleQuality({"O","scale-quality"}, "Quility of dci scaled icon image\n"
"The value may like <scale size>=<quality value> e.g. 2=98:3=95\n"
"The quality factor must be in the range 0 to 100 or -1.\n"
"Specify 0 to obtain small compressed files, 100 for large uncompressed files "
"and -1 to use the image handler default settings.\n"
"The higher the quality, the larger the dci icon file size", "scale quality");
QGuiApplication a(argc, argv);
a.setApplicationName("dci-icon-theme");
a.setApplicationVersion(QString("%1.%2.%3")
.arg(DTK_VERSION_MAJOR)
.arg(DTK_VERSION_MINOR)
.arg(DTK_VERSION_PATCH));
QCommandLineParser cp;
cp.setApplicationDescription("dci-icon-theme tool is a command tool that generate dci icons from common icons.\n"
"For example, the tool is used in the following ways: \n"
"\t dci-icon-theme /usr/share/icons/hicolor/256x256/apps -o ~/Desktop/hicolor -O 3=95\n"
"\t dci-icon-theme -m *.png /usr/share/icons/hicolor/256x256/apps -o ~/Desktop/hicolor -O 3=95\n"
"\t dci-icon-theme --fix-dark-theme <input dci files directory> -o <output directory path> \n"
"\t dci-icon-theme <input file directory> -o <output directory path> -s <csv file> -O <qualities>\n"""
);
cp.addOptions({fileFilter, outputDirectory, symlinkMap, fixDarkTheme, scaleQuality});
cp.addPositionalArgument("source", "Search the given directory and it's subdirectories, "
"get the files conform to rules of --match.",
"~/dci-png-icons");
cp.addHelpOption();
cp.addVersionOption();
cp.process(a);
if (a.arguments().size() == 1)
cp.showHelp(-1);
if (cp.positionalArguments().isEmpty()) {
qWarning() << "Not give a source directory.";
cp.showHelp(-2);
}
if (!cp.isSet(outputDirectory)) {
qWarning() << "Not give -o argument";
cp.showHelp(-4);
}
if (!cp.isSet(scaleQuality) && !cp.isSet(fixDarkTheme)) {
qWarning() << "Not give -O argument"; scaleQuality.flags();
cp.showHelp(-5);
}
initQuality();
QString surfix;
if (cp.isSet(scaleQuality)) {
#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
auto behavior = Qt::SkipEmptyParts;
#else
auto behavior = QString::SkipEmptyParts;
#endif
QStringList qualityList = cp.value(scaleQuality).split(":", behavior);
for (const QString &kv : qualityList) {
auto sq = kv.split("=");
if (sq.size() != 2) {
qWarning() << "Invalid quality value:" << kv;
continue;
}
int scaleSize = sq.value(0).toInt();
if (scaleSize < 1 || scaleSize > MAX_SCALE) {
qWarning() << "Invalid scale size:" << kv;
continue;
}
int validQuality = qMax(qMin(sq.value(1).toInt(), 100), -1); // -1, 0~100
quality4Scaled[scaleSize - 1] = validQuality;
}
}
QDir outputDir(cp.value(outputDirectory));
if (!outputDir.exists()) {
if (!QDir::current().mkpath(outputDir.absolutePath())) {
qWarning() << "Can't create the" << outputDir.absolutePath() << "directory";
cp.showHelp(-5);
}
} else {
qErrnoWarning("The output directory have been exists.");
#ifndef QT_DEBUG
return -1;
#endif
}
QMultiHash<QString, QString> symlinksMap;
if (cp.isSet(symlinkMap)) {
try {
symlinksMap = parseIconFileSymlinkMap(cp.value(symlinkMap));
} catch (const DciProcessingError &e) {
qWarning() << "Error parsing symlink map:" << e.what();
return e.getErrorCode();
}
}
const QStringList nameFilter = cp.isSet(fileFilter) ? cp.values(fileFilter) : QStringList();
const auto sourceDirectory = cp.positionalArguments();
for (const auto &sd : sourceDirectory) {
QDir sourceDir(sd);
if (!sourceDir.exists()) {
qWarning() << "Ignore the non-exists directory:" << sourceDir;
continue;
}
// read all links first
{
QDirIterator di(sourceDir.absolutePath(), nameFilter,
QDir::NoDotAndDotDot | QDir::Files,
QDirIterator::Subdirectories);
while (di.hasNext()) {
di.next();
QFileInfo file = di.fileInfo();
if (!file.isSymLink())
continue;
#if QT_VERSION >= QT_VERSION_CHECK(5, 13, 0)
auto link = file.symLinkTarget();
#else
auto link = file.readLink();
#endif
const QString &linkTarget = QFileInfo(link).completeBaseName();
if (!symlinksMap.values(linkTarget).contains(file.completeBaseName())) {
symlinksMap.insert(linkTarget, file.completeBaseName());
qInfo() << "Add link" << file.completeBaseName() << "->" << linkTarget;
} else {
// qDebug() << "** link already existed in symlinksMap **";
}
}
}
// Collect all files first, grouped by icon name to avoid concurrent access to same DCI file
QMap<QString, QList<QFileInfo>> iconGroups;
QDirIterator di(sourceDir.absolutePath(), nameFilter,
QDir::NoDotAndDotDot | QDir::Files,
QDirIterator::Subdirectories);
while (di.hasNext()) {
di.next();
QFileInfo file = di.fileInfo();
if (file.isSymLink())
continue;
if (cp.isSet(fixDarkTheme)) {
try {
doFixDarkTheme(file, outputDir, symlinksMap);
} catch (const DciProcessingError &e) {
qWarning() << "Error fixing dark theme for file" << file.absoluteFilePath() << ":" << e.what();
return e.getErrorCode();
}
continue;
}
if (file.path().endsWith(QStringLiteral("/dark"))) {
qInfo() << "Ignore the dark icon file:" << file;
continue;
}
iconGroups[file.completeBaseName()].append(file);
}
// Process with proper exception handling
std::atomic<bool> hasError{false};
int errorCode = 0;
// Process icon groups concurrently (each group shares same DCI file)
QList<QString> iconNames = iconGroups.keys();
QtConcurrent::blockingMap(iconNames, [&](const QString &iconName) {
if (hasError.load()) return; // Skip if already has error
try {
const QList<QFileInfo> &files = iconGroups[iconName];
const QString dciFilePath(outputDir.absoluteFilePath(iconName) + surfix + ".dci");
QScopedPointer<DDciFile> dciFile;
for (const QFileInfo &file : files) {
QString dirName = file.absoluteDir().dirName();
uint iconSize = foundSize(file);
dirName = iconSize > 0 ? QString("/%1").arg(iconSize) : dirName.prepend("/");
// Initialize DCI file once per icon group
if (dciFile.isNull()) {
if (QFileInfo::exists(dciFilePath)) {
dciFile.reset(new DDciFile(dciFilePath));
}
if (dciFile.isNull() || !dciFile->isValid()) {
dciFile.reset(new DDciFile);
}
}
if (dciFile->exists(dirName)) {
qWarning() << "Skip exists dci file:" << dciFilePath << dirName << dciFile->list(dirName);
continue;
}
qInfo() << "Writing to dci file:" << file.absoluteFilePath() << "==>" << dciFilePath;
QString sizeDir = iconSize > 0 ? dirName : "/256"; // "/256" as default
QString normalLight = sizeDir + "/normal.light"; // "/256/normal.light"
QString normalDark = sizeDir + "/normal.dark"; // "/256/normal.dark"
if (dciFile->exists(sizeDir)) {
qWarning() << "Skip exists dci file:" << dciFilePath << sizeDir << dciFile->list(sizeDir);
continue;
}
dciChecker(dciFile->mkdir(sizeDir), [&]{return dciFile->lastErrorString();});
dciChecker(dciFile->mkdir(normalLight), [&]{return dciFile->lastErrorString();});
if (!writeImage(*dciFile, file.filePath(), normalLight))
continue;
dciChecker(dciFile->mkdir(normalDark), [&]{return dciFile->lastErrorString();});
QFileInfo darkIcon(file.dir().absoluteFilePath("dark/" + file.fileName()));
if (darkIcon.exists()) {
writeImage(*dciFile, darkIcon.filePath(), normalDark);
} else {
dciChecker(recursionLink(*dciFile, normalLight, normalDark), [&]{return dciFile->lastErrorString();});
}
}
// Write DCI file once per icon group
if (!dciFile.isNull()) {
dciChecker(dciFile->writeToFile(dciFilePath), [&]{return dciFile->lastErrorString();});
// Create symlinks for all files in this group
for (const QFileInfo &file : files) {
makeLink(file, outputDir, dciFilePath, symlinksMap);
}
}
} catch (const DciProcessingError &e) {
qWarning() << "Error processing icon group" << iconName << ":" << e.what();
hasError.store(true);
errorCode = e.getErrorCode();
}
});
if (hasError.load()) {
qWarning() << "Encountered errors during DCI file writing" << errorCode;
continue;
}
}
return 0;
}