KSvg

split-plasma-svgs.cpp
1/* SPDX-FileCopyrightText: 2023 Noah Davis <noahadvs@gmail.com>
2 * SPDX-License-Identifier: LGPL-2.0-or-later
3 */
4
5#include <KAboutData>
6#include <KCompressionDevice>
7#include <KSvg/Svg>
8#include <QCommandLineOption>
9#include <QCommandLineParser>
10#include <QCoreApplication>
11#include <QDebug>
12#include <QDir>
13#include <QFile>
14#include <QRegularExpression>
15#include <QSvgRenderer>
16#include <QXmlStreamReader>
17#include <QXmlStreamWriter>
18
19using namespace Qt::Literals::StringLiterals; // for ""_L1
20
21static KSvg::Svg s_ksvg;
22static QSvgRenderer s_renderer;
23
24// https://developer.mozilla.org/en-US/docs/Web/SVG/Element#renderable_elements
25static const QStringList s_renderableElements = {
26 "a"_L1, "circle"_L1, "ellipse"_L1, "foreignObject"_L1, "g"_L1, "image"_L1,
27 "line"_L1, "path"_L1, "polygon"_L1, "polyline"_L1, "rect"_L1, // excluding <svg>
28 "switch"_L1, "symbol"_L1, "text"_L1, "textPath"_L1, "tspan"_L1, "use"_L1
29};
30
31QString joinedStrings(const QStringList &strings)
32{
33 return strings.join("\", \""_L1).prepend("\""_L1).append("\""_L1);
34}
35
36// Translate the current element to (0,0) if possible.
37// FIXME: Does not necessarily translate to (0,0) in one go.
38void writeElementTranslation(QXmlStreamReader &reader, QXmlStreamWriter &writer, qreal dx, qreal dy)
39{
40 if ((qIsFinite(dx) && dx != 0) || (qIsFinite(dy) && dy != 0)) {
41 writer.writeStartElement(reader.qualifiedName()); // The thing reader has currently read.
42 auto attributes = reader.attributes();
43 bool wasTranslated = false;
44 QString svgTranslate = "translate(%1,%2)"_L1.arg(QString::number(dx), QString::number(dy));
45 for (int i = 0; i < attributes.size(); ++i) {
46 if (attributes[i].qualifiedName() == "transform"_L1) {
47 auto svgTransform = attributes[i].value().toString();
48 if (!svgTransform.isEmpty()) {
49 svgTransform += " "_L1;
50 }
51 attributes[i] = {"transform"_L1, svgTransform + svgTranslate};
52 wasTranslated = true;
53 }
54 writer.writeAttribute(attributes[i]);
55 }
56 if (!wasTranslated) {
57 writer.writeAttribute("transform"_L1, svgTranslate);
58 }
59 } else {
60 writer.writeCurrentToken(reader); // The thing reader has currently read.
61 }
62}
63
64QMap<QString, QByteArray> splitSvg(const QString &inputArg, const QByteArray &inputContents)
65{
66 s_renderer.load(inputContents);
67 QMap<QString, QByteArray> outputMap; // filename, contents
68 QXmlStreamReader reader(inputContents);
69 reader.setNamespaceProcessing(false);
70
71 QString stylesheet;
72
73 while (!reader.atEnd() && !reader.hasError()) {
74 reader.readNextStartElement();
75 if (reader.hasError()) {
76 break;
77 }
78
79 const auto qualifiedName = reader.qualifiedName();
80 const auto attributes = reader.attributes();
81 QString id = attributes.value("id"_L1).toString();
82
83 // Skip elements without IDs since they aren't icons.
84 // Make sure you don't miss children when you make the output contents though.
85 // Also skip hints and groups with the layer1 ID
86 if (id.isEmpty() || id.startsWith("hint-"_L1) || (qualifiedName == "g"_L1 && id == "layer1"_L1)) {
87 continue;
88 }
89
90 // Some SVGs have multiple stylesheets.
91 // They really shouldn't, but that's just how it is sometimes.
92 // The last stylesheet with the correct ID is the one we will use.
93 static const auto s_stylesheetId = "current-color-scheme"_L1;
94 if (qualifiedName == "style"_L1 && id == s_stylesheetId) {
95 reader.readNext();
96 auto text = reader.text();
97 if (!text.isEmpty()) {
98 stylesheet = text.toString();
99 }
100 continue;
101 }
102
103 // ignore non-renderable elements
104 if (!s_renderableElements.contains(qualifiedName)) {
105 continue;
106 }
107
108 // NOTE: Does not include its own transform.
110 QRectF mappedRect = transform.mapRect(s_renderer.boundsOnElement(id));
111
112 // Skip invisible renderable elements.
113 if (mappedRect.isEmpty()) {
114 continue;
115 }
116
117 QString outputFilename = id + ".svg"_L1;
118 QByteArray outputContents;
119 QXmlStreamWriter writer(&outputContents);
120 // Start writing document
121 writer.setAutoFormatting(true);
122 writer.writeStartDocument();
123
124 // <svg>
125 writer.writeStartElement("svg"_L1);
126 writer.writeDefaultNamespace("http://www.w3.org/2000/svg"_L1);
127 writer.writeNamespace("http://www.w3.org/1999/xlink"_L1, "xlink"_L1);
128 writer.writeNamespace("http://creativecommons.org/ns#"_L1, "cc"_L1);
129 writer.writeNamespace("http://purl.org/dc/elements/1.1/"_L1, "dc"_L1);
130 writer.writeNamespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#"_L1, "rdf"_L1);
131 writer.writeNamespace("http://www.inkscape.org/namespaces/inkscape"_L1, "inkscape"_L1);
132 writer.writeNamespace("http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"_L1, "sodipodi"_L1);
133 writer.writeAttribute("width"_L1, QString::number(mappedRect.width()));
134 writer.writeAttribute("height"_L1, QString::number(mappedRect.height()));
135
136 // <style>
137 writer.writeStartElement("style"_L1);
138 writer.writeAttribute("type"_L1, "text/css"_L1);
139 writer.writeAttribute("id"_L1, s_stylesheetId);
140 // CSS
141 writer.writeCharacters(stylesheet);
142 writer.writeEndElement();
143 // </style>
144
145 // Translation via parent
146 auto dx = -mappedRect.x();
147 auto dy = -mappedRect.y();
148 writeElementTranslation(reader, writer, dx, dy);
149
150 // Write contents until we're no longer writing the current element or any of its children.
151 int depth = 0;
152 while (depth >= 0 && !reader.atEnd() && !reader.hasError()) {
153 reader.readNext();
154 if (reader.isStartElement()) {
155 ++depth;
156 }
157 if (reader.isEndElement()) {
158 --depth;
159 }
160 writer.writeCurrentToken(reader);
161 }
162
163 if (reader.hasError()) {
164 qWarning() << inputArg << "has an error:" << reader.errorString();
165 break;
166 }
167
168 writer.writeEndElement();
169 // </svg>
170
171 writer.writeEndDocument();
172
173 if (!outputFilename.isEmpty() && !outputContents.isEmpty()) {
174 outputMap.insert(outputFilename, outputContents);
175 }
176 }
177 return outputMap;
178}
179
180int main(int argc, char **argv)
181{
182 QCoreApplication app(argc, argv);
183
184 KAboutData aboutData(app.applicationName(), app.applicationName(), "1.0"_L1,
185 "Splits Plasma/KSVG SVGs into individual SVGs"_L1,
186 KAboutLicense::LGPL_V2, "2023 Noah Davis"_L1);
187 aboutData.addAuthor("Noah Davis"_L1, {}, "noahadvs@gmail.com"_L1);
189
190 QCommandLineParser commandLineParser;
191 commandLineParser.addPositionalArgument("inputs"_L1, "Input files (separated by spaces)"_L1, "inputs..."_L1);
192 commandLineParser.addPositionalArgument("output"_L1, "Output folder (optional, must exist). The default output folder is the current working directory."_L1, "[output]"_L1);
193 aboutData.setupCommandLine(&commandLineParser);
194
195 commandLineParser.process(app);
196 aboutData.processCommandLine(&commandLineParser);
197
198 const QStringList &positionalArguments = commandLineParser.positionalArguments();
199 if (positionalArguments.isEmpty()) {
200 qWarning() << "The arguments are missing.";
201 return 1;
202 }
203
204 QFileInfo lastArgInfo(positionalArguments.last());
205 if (positionalArguments.size() == 1 && lastArgInfo.isDir()) {
206 qWarning() << "Input file arguments are missing.";
207 return 1;
208 }
209
210 QDir outputDir = lastArgInfo.isDir() ? lastArgInfo.absoluteFilePath() : QDir::currentPath();
211 QFileInfo outputDirInfo(outputDir.absolutePath());
212 if (!outputDirInfo.isWritable()) {
213 // Using the arg instead of just path or filename so the user sees what they typed.
214 auto output = lastArgInfo.isDir() ? positionalArguments.last() : QDir::currentPath();
215 qWarning() << output << "is not a writable output folder.";
216 return 1;
217 }
218
219 QStringList inputArgs;
220 QStringList ignoredArgs;
221 for (int i = 0; i < positionalArguments.size() - lastArgInfo.isDir(); ++i) {
222 if (!QFileInfo::exists(positionalArguments[i])) {
223 ignoredArgs << positionalArguments[i];
224 continue;
225 }
226 inputArgs << positionalArguments[i];
227 }
228
229 if (inputArgs.isEmpty()) {
230 qWarning() << "None of the input files could be found.";
231 return 1;
232 }
233
234 if (!ignoredArgs.isEmpty()) {
235 // Using the arg instead of path or filename so the user sees what they typed.
236 qWarning() << "The following input files could not be found:";
237 qWarning().noquote() << joinedStrings(ignoredArgs);
238 }
239
240 bool wasAnyFileWritten = false;
241 for (const QString &inputArg : inputArgs) {
242 QFileInfo inputInfo(inputArg);
243
244 const QString &absoluteInputPath = inputInfo.absoluteFilePath();
245 // Avoid reading from a theme with relative paths by accident.
246 s_ksvg.setImagePath(absoluteInputPath);
247 if (!s_ksvg.isValid()) {
248 qWarning() << inputArg << "is not a valid Plasma theme SVG.";
249 continue;
250 }
251
252 KCompressionDevice inputFile(absoluteInputPath, KCompressionDevice::GZip);
253 if (!inputFile.open(QIODevice::ReadOnly)) {
254 qWarning() << inputArg << "could not be read.";
255 continue;
256 }
257 const auto outputMap = splitSvg(inputArg, inputFile.readAll());
258 inputFile.close();
259
260 if (outputMap.isEmpty()) {
261 qWarning() << inputArg << "could not be split.";
262 continue;
263 }
264
265 const auto outputSubDirPath = outputDir.absoluteFilePath(inputInfo.baseName());
266 outputDir.mkpath(outputSubDirPath);
267 QDir outputSubDir(outputSubDirPath);
268 QStringList unwrittenFiles;
269 QStringList invalidSvgs;
270 for (auto it = outputMap.cbegin(); it != outputMap.cend(); ++it) {
271 const QString &key = it.key();
272 const QByteArray &value = it.value();
273 if (key.isEmpty() || value.isEmpty()) {
274 unwrittenFiles << key;
275 continue;
276 }
277 const auto absoluteOutputPath = outputSubDir.absoluteFilePath(key);
278 QFile outputFile(absoluteOutputPath);
279 if (!outputFile.open(QIODevice::WriteOnly)) {
280 unwrittenFiles << key;
281 continue;
282 }
283 wasAnyFileWritten |= outputFile.write(value);
284 outputFile.close();
285 s_renderer.load(absoluteOutputPath);
286 if (!s_renderer.isValid()) {
287 // Write it even if it isn't valid so that the user can examine the output.
288 invalidSvgs << key;
289 }
290 }
291 if (unwrittenFiles.size() == outputMap.size()) {
292 qWarning().nospace() << "No files could be written for " << inputArg << ".";
293 } else if (!unwrittenFiles.isEmpty()) {
294 qWarning().nospace() << "The following files could not be written for " << inputArg << ":";
295 qWarning().noquote() << joinedStrings(unwrittenFiles);
296 }
297 if (!invalidSvgs.isEmpty()) {
298 qWarning().nospace() << "The following files written for " << inputArg << " are not valid SVGs:";
299 qWarning().noquote() << joinedStrings(invalidSvgs);
300 }
301 }
302
303 return wasAnyFileWritten ? 0 : 1;
304}
static void setApplicationData(const KAboutData &aboutData)
A theme aware image-centric SVG class.
Definition svg.h:46
virtual void setImagePath(const QString &svgFilePath)
This method sets the SVG file to render.
Definition svg.cpp:1039
Q_INVOKABLE bool isValid() const
This method checks whether this object is backed by a valid SVG file.
Definition svg.cpp:1010
KDOCTOOLS_EXPORT QString transform(const QString &file, const QString &stylesheet, const QList< const char * > &params=QList< const char * >())
bool isEmpty() const const
void addPositionalArgument(const QString &name, const QString &description, const QString &syntax)
QStringList positionalArguments() const const
void process(const QCoreApplication &app)
QString absoluteFilePath(const QString &fileName) const const
QString absolutePath() const const
QString currentPath()
bool mkpath(const QString &dirPath) const const
bool exists() const const
bool isEmpty() const const
T & last()
qsizetype size() const const
const_iterator cbegin() const const
const_iterator cend() const const
iterator insert(const Key &key, const T &value)
bool isEmpty() const const
size_type size() const const
qreal height() const const
bool isEmpty() const const
qreal width() const const
qreal x() const const
qreal y() const const
QString & append(QChar ch)
QString arg(Args &&... args) const const
bool isEmpty() const const
QString number(double n, char format, int precision)
QString & prepend(QChar ch)
bool contains(QLatin1StringView str, Qt::CaseSensitivity cs) const const
QString join(QChar separator) const const
QRectF boundsOnElement(const QString &id) const const
bool isValid() const const
bool load(QXmlStreamReader *contents)
QTransform transformForElement(const QString &id) const const
bool atEnd() const const
QXmlStreamAttributes attributes() const const
QString errorString() const const
bool hasError() const const
bool isEndElement() const const
bool isStartElement() const const
void setNamespaceProcessing(bool)
QStringView qualifiedName() const const
TokenType readNext()
bool readNextStartElement()
QStringView text() const const
void setAutoFormatting(bool enable)
void writeAttribute(QAnyStringView namespaceUri, QAnyStringView name, QAnyStringView value)
void writeCharacters(QAnyStringView text)
void writeCurrentToken(const QXmlStreamReader &reader)
void writeDefaultNamespace(QAnyStringView namespaceUri)
void writeEndDocument()
void writeEndElement()
void writeNamespace(QAnyStringView namespaceUri, QAnyStringView prefix)
void writeStartDocument()
void writeStartElement(QAnyStringView namespaceUri, QAnyStringView name)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Sat Dec 21 2024 17:00:08 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.