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 }