File indexing completed on 2025-01-12 03:40:55

0001 /* SPDX-FileCopyrightText: 2023 Noah Davis <noahadvs@gmail.com>
0002  * SPDX-License-Identifier: LGPL-2.0-or-later
0003  */
0004 
0005 #include <KAboutData>
0006 #include <KCompressionDevice>
0007 #include <KSvg/Svg>
0008 #include <QCommandLineOption>
0009 #include <QCommandLineParser>
0010 #include <QCoreApplication>
0011 #include <QDebug>
0012 #include <QDir>
0013 #include <QFile>
0014 #include <QRegularExpression>
0015 #include <QSvgRenderer>
0016 #include <QXmlStreamReader>
0017 #include <QXmlStreamWriter>
0018 
0019 using namespace Qt::Literals::StringLiterals; // for ""_L1
0020 
0021 static KSvg::Svg s_ksvg;
0022 static QSvgRenderer s_renderer;
0023 
0024 // https://developer.mozilla.org/en-US/docs/Web/SVG/Element#renderable_elements
0025 static const QStringList s_renderableElements = {
0026     "a"_L1, "circle"_L1, "ellipse"_L1, "foreignObject"_L1, "g"_L1, "image"_L1,
0027     "line"_L1, "path"_L1, "polygon"_L1, "polyline"_L1, "rect"_L1, // excluding <svg>
0028     "switch"_L1, "symbol"_L1, "text"_L1, "textPath"_L1, "tspan"_L1, "use"_L1
0029 };
0030 
0031 QString joinedStrings(const QStringList &strings)
0032 {
0033     return strings.join("\", \""_L1).prepend("\""_L1).append("\""_L1);
0034 }
0035 
0036 // Translate the current element to (0,0) if possible.
0037 // FIXME: Does not necessarily translate to (0,0) in one go.
0038 void writeElementTranslation(QXmlStreamReader &reader, QXmlStreamWriter &writer, qreal dx, qreal dy)
0039 {
0040     if ((qIsFinite(dx) && dx != 0) || (qIsFinite(dy) && dy != 0)) {
0041         writer.writeStartElement(reader.qualifiedName()); // The thing reader has currently read.
0042         auto attributes = reader.attributes();
0043         bool wasTranslated = false;
0044         QString svgTranslate = "translate(%1,%2)"_L1.arg(QString::number(dx), QString::number(dy));
0045         for (int i = 0; i < attributes.size(); ++i) {
0046             if (attributes[i].qualifiedName() == "transform"_L1) {
0047                 auto svgTransform = attributes[i].value().toString();
0048                 if (!svgTransform.isEmpty()) {
0049                     svgTransform += " "_L1;
0050                 }
0051                 attributes[i] = {"transform"_L1, svgTransform + svgTranslate};
0052                 wasTranslated = true;
0053             }
0054             writer.writeAttribute(attributes[i]);
0055         }
0056         if (!wasTranslated) {
0057             writer.writeAttribute("transform"_L1, svgTranslate);
0058         }
0059     } else {
0060         writer.writeCurrentToken(reader); // The thing reader has currently read.
0061     }
0062 }
0063 
0064 QMap<QString, QByteArray> splitSvg(const QString &inputArg, const QByteArray &inputContents)
0065 {
0066     s_renderer.load(inputContents);
0067     QMap<QString, QByteArray> outputMap; // filename, contents
0068     QXmlStreamReader reader(inputContents);
0069     reader.setNamespaceProcessing(false);
0070 
0071     QString stylesheet;
0072 
0073     while (!reader.atEnd() && !reader.hasError()) {
0074         reader.readNextStartElement();
0075         if (reader.hasError()) {
0076             break;
0077         }
0078 
0079         const auto qualifiedName = reader.qualifiedName();
0080         const auto attributes = reader.attributes();
0081         QString id = attributes.value("id"_L1).toString();
0082 
0083         // Skip elements without IDs since they aren't icons.
0084         // Make sure you don't miss children when you make the output contents though.
0085         // Also skip hints and groups with the layer1 ID
0086         if (id.isEmpty() || id.startsWith("hint-"_L1) || (qualifiedName == "g"_L1 && id == "layer1"_L1)) {
0087             continue;
0088         }
0089 
0090         // Some SVGs have multiple stylesheets.
0091         // They really shouldn't, but that's just how it is sometimes.
0092         // The last stylesheet with the correct ID is the one we will use.
0093         static const auto s_stylesheetId = "current-color-scheme"_L1;
0094         if (qualifiedName == "style"_L1 && id == s_stylesheetId) {
0095             reader.readNext();
0096             auto text = reader.text();
0097             if (!text.isEmpty()) {
0098                 stylesheet = text.toString();
0099             }
0100             continue;
0101         }
0102 
0103         // ignore non-renderable elements
0104         if (!s_renderableElements.contains(qualifiedName)) {
0105             continue;
0106         }
0107 
0108         // NOTE: Does not include its own transform.
0109         QTransform transform = s_renderer.transformForElement(id);
0110         QRectF mappedRect = transform.mapRect(s_renderer.boundsOnElement(id));
0111 
0112         // Skip invisible renderable elements.
0113         if (mappedRect.isEmpty()) {
0114             continue;
0115         }
0116 
0117         QString outputFilename = id + ".svg"_L1;
0118         QByteArray outputContents;
0119         QXmlStreamWriter writer(&outputContents);
0120         // Start writing document
0121         writer.setAutoFormatting(true);
0122         writer.writeStartDocument();
0123 
0124         // <svg>
0125         writer.writeStartElement("svg"_L1);
0126         writer.writeDefaultNamespace("http://www.w3.org/2000/svg"_L1);
0127         writer.writeNamespace("http://www.w3.org/1999/xlink"_L1, "xlink"_L1);
0128         writer.writeNamespace("http://creativecommons.org/ns#"_L1, "cc"_L1);
0129         writer.writeNamespace("http://purl.org/dc/elements/1.1/"_L1, "dc"_L1);
0130         writer.writeNamespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#"_L1, "rdf"_L1);
0131         writer.writeNamespace("http://www.inkscape.org/namespaces/inkscape"_L1, "inkscape"_L1);
0132         writer.writeNamespace("http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"_L1, "sodipodi"_L1);
0133         writer.writeAttribute("width"_L1, QString::number(mappedRect.width()));
0134         writer.writeAttribute("height"_L1, QString::number(mappedRect.height()));
0135 
0136         // <style>
0137         writer.writeStartElement("style"_L1);
0138         writer.writeAttribute("type"_L1, "text/css"_L1);
0139         writer.writeAttribute("id"_L1, s_stylesheetId);
0140         // CSS
0141         writer.writeCharacters(stylesheet);
0142         writer.writeEndElement();
0143         // </style>
0144 
0145         // Translation via parent
0146         auto dx = -mappedRect.x();
0147         auto dy = -mappedRect.y();
0148         writeElementTranslation(reader, writer, dx, dy);
0149 
0150         // Write contents until we're no longer writing the current element or any of its children.
0151         int depth = 0;
0152         while (depth >= 0 && !reader.atEnd() && !reader.hasError()) {
0153             reader.readNext();
0154             if (reader.isStartElement()) {
0155                 ++depth;
0156             }
0157             if (reader.isEndElement()) {
0158                 --depth;
0159             }
0160             writer.writeCurrentToken(reader);
0161         }
0162 
0163         if (reader.hasError()) {
0164             qWarning() << inputArg << "has an error:" << reader.errorString();
0165             break;
0166         }
0167 
0168         writer.writeEndElement();
0169         // </svg>
0170 
0171         writer.writeEndDocument();
0172 
0173         if (!outputFilename.isEmpty() && !outputContents.isEmpty()) {
0174             outputMap.insert(outputFilename, outputContents);
0175         }
0176     }
0177     return outputMap;
0178 }
0179 
0180 int main(int argc, char **argv)
0181 {
0182     QCoreApplication app(argc, argv);
0183 
0184     KAboutData aboutData(app.applicationName(), app.applicationName(), "1.0"_L1,
0185                          "Splits Plasma/KSVG SVGs into individual SVGs"_L1,
0186                          KAboutLicense::LGPL_V2, "2023 Noah Davis"_L1);
0187     aboutData.addAuthor("Noah Davis"_L1, {}, "noahadvs@gmail.com"_L1);
0188     KAboutData::setApplicationData(aboutData);
0189 
0190     QCommandLineParser commandLineParser;
0191     commandLineParser.addPositionalArgument("inputs"_L1, "Input files (separated by spaces)"_L1, "inputs..."_L1);
0192     commandLineParser.addPositionalArgument("output"_L1, "Output folder (optional, must exist). The default output folder is the current working directory."_L1, "[output]"_L1);
0193     aboutData.setupCommandLine(&commandLineParser);
0194 
0195     commandLineParser.process(app);
0196     aboutData.processCommandLine(&commandLineParser);
0197 
0198     const QStringList &positionalArguments = commandLineParser.positionalArguments();
0199     if (positionalArguments.isEmpty()) {
0200         qWarning() << "The arguments are missing.";
0201         return 1;
0202     }
0203 
0204     QFileInfo lastArgInfo(positionalArguments.last());
0205     if (positionalArguments.size() == 1 && lastArgInfo.isDir()) {
0206         qWarning() << "Input file arguments are missing.";
0207         return 1;
0208     }
0209 
0210     QDir outputDir = lastArgInfo.isDir() ? lastArgInfo.absoluteFilePath() : QDir::currentPath();
0211     QFileInfo outputDirInfo(outputDir.absolutePath());
0212     if (!outputDirInfo.isWritable()) {
0213         // Using the arg instead of just path or filename so the user sees what they typed.
0214         auto output = lastArgInfo.isDir() ? positionalArguments.last() : QDir::currentPath();
0215         qWarning() << output << "is not a writable output folder.";
0216         return 1;
0217     }
0218 
0219     QStringList inputArgs;
0220     QStringList ignoredArgs;
0221     for (int i = 0; i < positionalArguments.size() - lastArgInfo.isDir(); ++i) {
0222         if (!QFileInfo::exists(positionalArguments[i])) {
0223             ignoredArgs << positionalArguments[i];
0224             continue;
0225         }
0226         inputArgs << positionalArguments[i];
0227     }
0228 
0229     if (inputArgs.isEmpty()) {
0230         qWarning() << "None of the input files could be found.";
0231         return 1;
0232     }
0233 
0234     if (!ignoredArgs.isEmpty()) {
0235         // Using the arg instead of path or filename so the user sees what they typed.
0236         qWarning() << "The following input files could not be found:";
0237         qWarning().noquote() << joinedStrings(ignoredArgs);
0238     }
0239 
0240     bool wasAnyFileWritten = false;
0241     for (const QString &inputArg : inputArgs) {
0242         QFileInfo inputInfo(inputArg);
0243 
0244         const QString &absoluteInputPath = inputInfo.absoluteFilePath();
0245         // Avoid reading from a theme with relative paths by accident.
0246         s_ksvg.setImagePath(absoluteInputPath);
0247         if (!s_ksvg.isValid()) {
0248             qWarning() << inputArg << "is not a valid Plasma theme SVG.";
0249             continue;
0250         }
0251 
0252         KCompressionDevice inputFile(absoluteInputPath, KCompressionDevice::GZip);
0253         if (!inputFile.open(QIODevice::ReadOnly)) {
0254             qWarning() << inputArg << "could not be read.";
0255             continue;
0256         }
0257         const auto outputMap = splitSvg(inputArg, inputFile.readAll());
0258         inputFile.close();
0259 
0260         if (outputMap.isEmpty()) {
0261             qWarning() << inputArg << "could not be split.";
0262             continue;
0263         }
0264 
0265         const auto outputSubDirPath = outputDir.absoluteFilePath(inputInfo.baseName());
0266         outputDir.mkpath(outputSubDirPath);
0267         QDir outputSubDir(outputSubDirPath);
0268         QStringList unwrittenFiles;
0269         QStringList invalidSvgs;
0270         for (auto it = outputMap.cbegin(); it != outputMap.cend(); ++it) {
0271             const QString &key = it.key();
0272             const QByteArray &value = it.value();
0273             if (key.isEmpty() || value.isEmpty()) {
0274                 unwrittenFiles << key;
0275                 continue;
0276             }
0277             const auto absoluteOutputPath = outputSubDir.absoluteFilePath(key);
0278             QFile outputFile(absoluteOutputPath);
0279             if (!outputFile.open(QIODevice::WriteOnly)) {
0280                 unwrittenFiles << key;
0281                 continue;
0282             }
0283             wasAnyFileWritten |= outputFile.write(value);
0284             outputFile.close();
0285             s_renderer.load(absoluteOutputPath);
0286             if (!s_renderer.isValid()) {
0287                 // Write it even if it isn't valid so that the user can examine the output.
0288                 invalidSvgs << key;
0289             }
0290         }
0291         if (unwrittenFiles.size() == outputMap.size()) {
0292             qWarning().nospace() << "No files could be written for " << inputArg << ".";
0293         } else if (!unwrittenFiles.isEmpty()) {
0294             qWarning().nospace() << "The following files could not be written for " << inputArg << ":";
0295             qWarning().noquote() << joinedStrings(unwrittenFiles);
0296         }
0297         if (!invalidSvgs.isEmpty()) {
0298             qWarning().nospace() << "The following files written for " << inputArg << " are not valid SVGs:";
0299             qWarning().noquote() << joinedStrings(invalidSvgs);
0300         }
0301     }
0302 
0303     return wasAnyFileWritten ? 0 : 1;
0304 }