From 14c4f257fe7e9ef5953e60dacca36b7140454328 Mon Sep 17 00:00:00 2001 From: Henning Gruendl Date: Mon, 11 Oct 2021 09:46:21 +0200 Subject: [PATCH] QmlDesigner: Allow inserting SVG snippets Allows to paste SVG clipboard content into the Form Editor. It will create a Group item which acts as the SVGs view box and groups multiple items together. All SVG items will be transformed into SvgPathItem. * Supports all basic SVG shapes path, rect, polygon, circle, ellipse * Supports the following SVG presentation attributes as CSS-inline definition, XML-attribute, style element: fill, stroke, stroke-width, opacity, fill-opacity, stroke-opacity * Supports all transform operations Task-number: QDS-5259 Change-Id: I9b7027992de60e5c87f2031251348dbb31fe03fe Reviewed-by: Qt CI Bot Reviewed-by: Thomas Hartmann --- src/plugins/qmldesigner/CMakeLists.txt | 3 +- .../componentcore/componentcore.pri | 2 + .../componentcore/svgpasteaction.cpp | 1232 +++++++++++++++++ .../components/componentcore/svgpasteaction.h | 56 + .../components/integration/designdocument.cpp | 46 +- .../components/integration/designdocument.h | 2 + src/plugins/qmldesigner/qmldesignerplugin.pro | 2 +- src/plugins/qmldesigner/qmldesignerplugin.qbs | 4 +- 8 files changed, 1342 insertions(+), 5 deletions(-) create mode 100644 src/plugins/qmldesigner/components/componentcore/svgpasteaction.cpp create mode 100644 src/plugins/qmldesigner/components/componentcore/svgpasteaction.h diff --git a/src/plugins/qmldesigner/CMakeLists.txt b/src/plugins/qmldesigner/CMakeLists.txt index 1aac7a17cd8..48cdb6f123d 100644 --- a/src/plugins/qmldesigner/CMakeLists.txt +++ b/src/plugins/qmldesigner/CMakeLists.txt @@ -7,7 +7,7 @@ add_qtc_plugin(QmlDesigner CONDITION TARGET Qt5::QuickWidgets DEPENDS QmlJS LanguageUtils QmlEditorWidgets AdvancedDockingSystem - Qt5::QuickWidgets Qt5::CorePrivate Sqlite + Qt5::QuickWidgets Qt5::CorePrivate Sqlite Qt5::Xml Qt5::Svg DEFINES DESIGNER_CORE_LIBRARY IDE_LIBRARY_BASENAME=\"${IDE_LIBRARY_BASE_PATH}\" @@ -139,6 +139,7 @@ extend_qtc_plugin(QmlDesigner theme.cpp theme.h zoomaction.cpp zoomaction.h hdrimage.cpp hdrimage.h + svgpasteaction.cpp svgpasteaction.h ) extend_qtc_plugin(QmlDesigner diff --git a/src/plugins/qmldesigner/components/componentcore/componentcore.pri b/src/plugins/qmldesigner/components/componentcore/componentcore.pri index 49d646d4627..d47c4cf8a4b 100644 --- a/src/plugins/qmldesigner/components/componentcore/componentcore.pri +++ b/src/plugins/qmldesigner/components/componentcore/componentcore.pri @@ -20,6 +20,7 @@ SOURCES += crumblebar.cpp SOURCES += qmldesignericonprovider.cpp SOURCES += zoomaction.cpp SOURCES += hdrimage.cpp +SOURCES += svgpasteaction.cpp HEADERS += modelnodecontextmenu.h HEADERS += addimagesdialog.h @@ -43,6 +44,7 @@ HEADERS += crumblebar.h HEADERS += qmldesignericonprovider.h HEADERS += zoomaction.h HEADERS += hdrimage.h +HEADERS += svgpasteaction.h FORMS += \ $$PWD/addsignalhandlerdialog.ui diff --git a/src/plugins/qmldesigner/components/componentcore/svgpasteaction.cpp b/src/plugins/qmldesigner/components/componentcore/svgpasteaction.cpp new file mode 100644 index 00000000000..1d39d1458b9 --- /dev/null +++ b/src/plugins/qmldesigner/components/componentcore/svgpasteaction.cpp @@ -0,0 +1,1232 @@ +/**************************************************************************** +** +** Copyright (C) 2021 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of Qt Creator. +** +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3 as published by the Free Software +** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-3.0.html. +** +****************************************************************************/ + +#include "svgpasteaction.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace QmlDesigner { + +namespace { + +/* Copied from qquicksvgparser.cpp 3e783b26a8fb41e3f5a53b883735f5d10fbbd98a */ + +// '0' is 0x30 and '9' is 0x39 +static inline bool isDigit(ushort ch) +{ + static quint16 magic = 0x3ff; + return ((ch >> 4) == 3) && (magic >> (ch & 15)); +} + +static qreal toDouble(const QChar *&str) +{ + const int maxLen = 255;//technically doubles can go til 308+ but whatever + char temp[maxLen+1]; + int pos = 0; + + if (*str == QLatin1Char('-')) { + temp[pos++] = '-'; + ++str; + } else if (*str == QLatin1Char('+')) { + ++str; + } + while (isDigit(str->unicode()) && pos < maxLen) { + temp[pos++] = str->toLatin1(); + ++str; + } + if (*str == QLatin1Char('.') && pos < maxLen) { + temp[pos++] = '.'; + ++str; + } + while (isDigit(str->unicode()) && pos < maxLen) { + temp[pos++] = str->toLatin1(); + ++str; + } + bool exponent = false; + if ((*str == QLatin1Char('e') || *str == QLatin1Char('E')) && pos < maxLen) { + exponent = true; + temp[pos++] = 'e'; + ++str; + if ((*str == QLatin1Char('-') || *str == QLatin1Char('+')) && pos < maxLen) { + temp[pos++] = str->toLatin1(); + ++str; + } + while (isDigit(str->unicode()) && pos < maxLen) { + temp[pos++] = str->toLatin1(); + ++str; + } + } + + temp[pos] = '\0'; + + qreal val; + if (!exponent && pos < 10) { + int ival = 0; + const char *t = temp; + bool neg = false; + if (*t == '-') { + neg = true; + ++t; + } + while (*t && *t != '.') { + ival *= 10; + ival += (*t) - '0'; + ++t; + } + if (*t == '.') { + ++t; + int div = 1; + while (*t) { + ival *= 10; + ival += (*t) - '0'; + div *= 10; + ++t; + } + val = ((qreal)ival)/((qreal)div); + } else { + val = ival; + } + if (neg) + val = -val; + } else { + bool ok = false; + val = qstrtod(temp, nullptr, &ok); + } + return val; +} + +static inline void parseNumbersArray(const QChar *&str, QVarLengthArray &points) +{ + while (str->isSpace()) + ++str; + while (isDigit(str->unicode()) || + *str == QLatin1Char('-') || *str == QLatin1Char('+') || + *str == QLatin1Char('.')) { + + points.append(toDouble(str)); + + while (str->isSpace()) + ++str; + if (*str == QLatin1Char(',')) + ++str; + + //eat the rest of space + while (str->isSpace()) + ++str; + } +} + +static void pathArcSegment(QPainterPath &path, + qreal xc, qreal yc, + qreal th0, qreal th1, + qreal rx, qreal ry, qreal xAxisRotation) +{ + qreal sinTh, cosTh; + qreal a00, a01, a10, a11; + qreal x1, y1, x2, y2, x3, y3; + qreal t; + qreal thHalf; + + sinTh = qSin(qDegreesToRadians(xAxisRotation)); + cosTh = qCos(qDegreesToRadians(xAxisRotation)); + + a00 = cosTh * rx; + a01 = -sinTh * ry; + a10 = sinTh * rx; + a11 = cosTh * ry; + + thHalf = 0.5 * (th1 - th0); + t = (8.0 / 3.0) * qSin(thHalf * 0.5) * qSin(thHalf * 0.5) / qSin(thHalf); + x1 = xc + qCos(th0) - t * qSin(th0); + y1 = yc + qSin(th0) + t * qCos(th0); + x3 = xc + qCos(th1); + y3 = yc + qSin(th1); + x2 = x3 + t * qSin(th1); + y2 = y3 - t * qCos(th1); + + path.cubicTo(a00 * x1 + a01 * y1, a10 * x1 + a11 * y1, + a00 * x2 + a01 * y2, a10 * x2 + a11 * y2, + a00 * x3 + a01 * y3, a10 * x3 + a11 * y3); +} + +void pathArc(QPainterPath &path, + qreal rx, qreal ry, + qreal x_axis_rotation, + int large_arc_flag, + int sweep_flag, + qreal x, qreal y, + qreal curx, qreal cury) +{ + qreal sin_th, cos_th; + qreal a00, a01, a10, a11; + qreal x0, y0, x1, y1, xc, yc; + qreal d, sfactor, sfactor_sq; + qreal th0, th1, th_arc; + int i, n_segs; + qreal dx, dy, dx1, dy1, Pr1, Pr2, Px, Py, check; + + rx = qAbs(rx); + ry = qAbs(ry); + + sin_th = qSin(qDegreesToRadians(x_axis_rotation)); + cos_th = qCos(qDegreesToRadians(x_axis_rotation)); + + dx = (curx - x) / 2.0; + dy = (cury - y) / 2.0; + dx1 = cos_th * dx + sin_th * dy; + dy1 = -sin_th * dx + cos_th * dy; + Pr1 = rx * rx; + Pr2 = ry * ry; + Px = dx1 * dx1; + Py = dy1 * dy1; + /* Spec : check if radii are large enough */ + check = Px / Pr1 + Py / Pr2; + if (check > 1) { + rx = rx * qSqrt(check); + ry = ry * qSqrt(check); + } + + a00 = cos_th / rx; + a01 = sin_th / rx; + a10 = -sin_th / ry; + a11 = cos_th / ry; + x0 = a00 * curx + a01 * cury; + y0 = a10 * curx + a11 * cury; + x1 = a00 * x + a01 * y; + y1 = a10 * x + a11 * y; + /* (x0, y0) is current point in transformed coordinate space. + (x1, y1) is new point in transformed coordinate space. + The arc fits a unit-radius circle in this space. + */ + d = (x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0); + sfactor_sq = 1.0 / d - 0.25; + if (sfactor_sq < 0) sfactor_sq = 0; + sfactor = qSqrt(sfactor_sq); + if (sweep_flag == large_arc_flag) sfactor = -sfactor; + xc = 0.5 * (x0 + x1) - sfactor * (y1 - y0); + yc = 0.5 * (y0 + y1) + sfactor * (x1 - x0); + /* (xc, yc) is center of the circle. */ + + th0 = qAtan2(y0 - yc, x0 - xc); + th1 = qAtan2(y1 - yc, x1 - xc); + + th_arc = th1 - th0; + if (th_arc < 0 && sweep_flag) + th_arc += 2 * M_PI; + else if (th_arc > 0 && !sweep_flag) + th_arc -= 2 * M_PI; + + n_segs = qCeil(qAbs(th_arc / (M_PI * 0.5 + 0.001))); + + for (i = 0; i < n_segs; i++) { + pathArcSegment(path, xc, yc, + th0 + i * th_arc / n_segs, + th0 + (i + 1) * th_arc / n_segs, + rx, ry, x_axis_rotation); + } +} + + +bool parsePathDataFast(const QString &dataStr, QPainterPath &path) +{ + qreal x0 = 0, y0 = 0; // starting point + qreal x = 0, y = 0; // current point + char lastMode = 0; + QPointF ctrlPt; + const QChar *str = dataStr.constData(); + const QChar *end = str + dataStr.size(); + + while (str != end) { + while (str->isSpace()) + ++str; + QChar pathElem = *str; + ++str; + QVarLengthArray arg; + parseNumbersArray(str, arg); + if (pathElem == QLatin1Char('z') || pathElem == QLatin1Char('Z')) + arg.append(0);//dummy + const qreal *num = arg.constData(); + int count = arg.count(); + while (count > 0) { + qreal offsetX = x; // correction offsets + qreal offsetY = y; // for relative commands + switch (pathElem.unicode()) { + case 'm': { + if (count < 2) { + num++; + count--; + break; + } + x = x0 = num[0] + offsetX; + y = y0 = num[1] + offsetY; + num += 2; + count -= 2; + path.moveTo(x0, y0); + + // As per 1.2 spec 8.3.2 The "moveto" commands + // If a 'moveto' is followed by multiple pairs of coordinates without explicit commands, + // the subsequent pairs shall be treated as implicit 'lineto' commands. + pathElem = QLatin1Char('l'); + } + break; + case 'M': { + if (count < 2) { + num++; + count--; + break; + } + x = x0 = num[0]; + y = y0 = num[1]; + num += 2; + count -= 2; + path.moveTo(x0, y0); + + // As per 1.2 spec 8.3.2 The "moveto" commands + // If a 'moveto' is followed by multiple pairs of coordinates without explicit commands, + // the subsequent pairs shall be treated as implicit 'lineto' commands. + pathElem = QLatin1Char('L'); + } + break; + case 'z': + case 'Z': { + x = x0; + y = y0; + count--; // skip dummy + num++; + path.closeSubpath(); + } + break; + case 'l': { + if (count < 2) { + num++; + count--; + break; + } + x = num[0] + offsetX; + y = num[1] + offsetY; + num += 2; + count -= 2; + path.lineTo(x, y); + + } + break; + case 'L': { + if (count < 2) { + num++; + count--; + break; + } + x = num[0]; + y = num[1]; + num += 2; + count -= 2; + path.lineTo(x, y); + } + break; + case 'h': { + x = num[0] + offsetX; + num++; + count--; + path.lineTo(x, y); + } + break; + case 'H': { + x = num[0]; + num++; + count--; + path.lineTo(x, y); + } + break; + case 'v': { + y = num[0] + offsetY; + num++; + count--; + path.lineTo(x, y); + } + break; + case 'V': { + y = num[0]; + num++; + count--; + path.lineTo(x, y); + } + break; + case 'c': { + if (count < 6) { + num += count; + count = 0; + break; + } + QPointF c1(num[0] + offsetX, num[1] + offsetY); + QPointF c2(num[2] + offsetX, num[3] + offsetY); + QPointF e(num[4] + offsetX, num[5] + offsetY); + num += 6; + count -= 6; + path.cubicTo(c1, c2, e); + ctrlPt = c2; + x = e.x(); + y = e.y(); + break; + } + case 'C': { + if (count < 6) { + num += count; + count = 0; + break; + } + QPointF c1(num[0], num[1]); + QPointF c2(num[2], num[3]); + QPointF e(num[4], num[5]); + num += 6; + count -= 6; + path.cubicTo(c1, c2, e); + ctrlPt = c2; + x = e.x(); + y = e.y(); + break; + } + case 's': { + if (count < 4) { + num += count; + count = 0; + break; + } + QPointF c1; + if (lastMode == 'c' || lastMode == 'C' || + lastMode == 's' || lastMode == 'S') + c1 = QPointF(2*x-ctrlPt.x(), 2*y-ctrlPt.y()); + else + c1 = QPointF(x, y); + QPointF c2(num[0] + offsetX, num[1] + offsetY); + QPointF e(num[2] + offsetX, num[3] + offsetY); + num += 4; + count -= 4; + path.cubicTo(c1, c2, e); + ctrlPt = c2; + x = e.x(); + y = e.y(); + break; + } + case 'S': { + if (count < 4) { + num += count; + count = 0; + break; + } + QPointF c1; + if (lastMode == 'c' || lastMode == 'C' || + lastMode == 's' || lastMode == 'S') + c1 = QPointF(2*x-ctrlPt.x(), 2*y-ctrlPt.y()); + else + c1 = QPointF(x, y); + QPointF c2(num[0], num[1]); + QPointF e(num[2], num[3]); + num += 4; + count -= 4; + path.cubicTo(c1, c2, e); + ctrlPt = c2; + x = e.x(); + y = e.y(); + break; + } + case 'q': { + if (count < 4) { + num += count; + count = 0; + break; + } + QPointF c(num[0] + offsetX, num[1] + offsetY); + QPointF e(num[2] + offsetX, num[3] + offsetY); + num += 4; + count -= 4; + path.quadTo(c, e); + ctrlPt = c; + x = e.x(); + y = e.y(); + break; + } + case 'Q': { + if (count < 4) { + num += count; + count = 0; + break; + } + QPointF c(num[0], num[1]); + QPointF e(num[2], num[3]); + num += 4; + count -= 4; + path.quadTo(c, e); + ctrlPt = c; + x = e.x(); + y = e.y(); + break; + } + case 't': { + if (count < 2) { + num += count; + count = 0; + break; + } + QPointF e(num[0] + offsetX, num[1] + offsetY); + num += 2; + count -= 2; + QPointF c; + if (lastMode == 'q' || lastMode == 'Q' || + lastMode == 't' || lastMode == 'T') + c = QPointF(2*x-ctrlPt.x(), 2*y-ctrlPt.y()); + else + c = QPointF(x, y); + path.quadTo(c, e); + ctrlPt = c; + x = e.x(); + y = e.y(); + break; + } + case 'T': { + if (count < 2) { + num += count; + count = 0; + break; + } + QPointF e(num[0], num[1]); + num += 2; + count -= 2; + QPointF c; + if (lastMode == 'q' || lastMode == 'Q' || + lastMode == 't' || lastMode == 'T') + c = QPointF(2*x-ctrlPt.x(), 2*y-ctrlPt.y()); + else + c = QPointF(x, y); + path.quadTo(c, e); + ctrlPt = c; + x = e.x(); + y = e.y(); + break; + } + case 'a': { + if (count < 7) { + num += count; + count = 0; + break; + } + qreal rx = (*num++); + qreal ry = (*num++); + qreal xAxisRotation = (*num++); + qreal largeArcFlag = (*num++); + qreal sweepFlag = (*num++); + qreal ex = (*num++) + offsetX; + qreal ey = (*num++) + offsetY; + count -= 7; + qreal curx = x; + qreal cury = y; + pathArc(path, rx, ry, xAxisRotation, int(largeArcFlag), + int(sweepFlag), ex, ey, curx, cury); + + x = ex; + y = ey; + } + break; + case 'A': { + if (count < 7) { + num += count; + count = 0; + break; + } + qreal rx = (*num++); + qreal ry = (*num++); + qreal xAxisRotation = (*num++); + qreal largeArcFlag = (*num++); + qreal sweepFlag = (*num++); + qreal ex = (*num++); + qreal ey = (*num++); + count -= 7; + qreal curx = x; + qreal cury = y; + pathArc(path, rx, ry, xAxisRotation, int(largeArcFlag), + int(sweepFlag), ex, ey, curx, cury); + + x = ex; + y = ey; + } + break; + default: + return false; + } + lastMode = pathElem.toLatin1(); + } + } + return true; +} + +/* Copied from qquicksvgparser.cpp 3e783b26a8fb41e3f5a53b883735f5d10fbbd98a */ + +double round(double value, int decimal_places) { + const double multiplier = std::pow(10.0, decimal_places); + return std::round(value * multiplier) / multiplier; +} + +static const std::initializer_list tagAllowList{ + u"path", u"rect", u"polygon", u"circle", u"ellipse" +}; + +// fillOpacity and strokeOpacity aren't actual QML properties, but get mapped anyways +// for completeness. +static const std::initializer_list> mapping{ + {u"fill", "fillColor"}, + {u"stroke", "strokeColor"}, + {u"stroke-width", "strokeWidth"}, + {u"opacity", "opacity"}, + {u"fill-opacity", "fillOpacity"}, + {u"stroke-opacity", "strokeOpacity"} +}; + +template +bool contains(const Container &c, const QStringView &stringView) { + return std::find(std::begin(c), std::end(c), stringView) != std::end(c); +} + +template +auto findKey(const Container &c, const QStringView &key) { + return std::find_if(std::begin(c), std::end(c), [&](const auto &pair){ + return pair.first == key; + }); +} + +template +void depthFirstTraversal(const QDomNode &node, + const Callable &action) +{ + QDomNode currentNode = node; + + while (!currentNode.isNull()) { + action(currentNode); + depthFirstTraversal(currentNode.firstChild(), action); + currentNode = currentNode.nextSibling(); + } +} + +template +void topToBottomTraversal(const QDomNode &node, + const Callable &action) +{ + if (node.isNull()) + return; + + topToBottomTraversal(node.parentNode(), action); + action(node); +} + +QTransform parseMatrix(const QString &values) +{ + // matrix( ) + // [a c e] [m11 m21 m31] + // SVG [b d f] Qt [m12 m22 m32] + // [0 0 1] [m13 m23 m33] + static const QRegularExpression re("([0-9-.]+)"); + QRegularExpressionMatchIterator iter = re.globalMatch(values.simplified()); + + std::array arr = {1, 0, 0, 1, 0, 0}; + int i = 0; + while (iter.hasNext()) { + QRegularExpressionMatch match = iter.next(); + arr[i] = match.captured(1).toFloat(); + ++i; + } + + if (i != 6) + return QTransform(); + + return QTransform(arr[0], arr[1], 0, arr[2], arr[3], 0, arr[4], arr[5], 1); +} + +QTransform parseTranslate(const QString &values) +{ + // translate( []) translate([,]) + static const QRegularExpression re(R"(^([\d.-]+)(?:(?:\s*,\s*|\s+)([\d.-]+))?$)"); + QRegularExpressionMatch m = re.match(values.simplified()); + + if (!m.hasMatch()) + return QTransform(); + + return QTransform().translate(m.captured(1).toFloat(), m.captured(2).toFloat()); +} + +QTransform parseScale(const QString &values) +{ + // scale( []) scale([,]) + static const QRegularExpression re(R"(^([\d.-]+)(?:(?:\s*,\s*|\s+)([\d.-]+))?$)"); + QRegularExpressionMatch m = re.match(values.simplified()); + + if (!m.hasMatch()) + return QTransform(); + + float x = m.captured(1).toFloat(); + // If y is not provided, it is assumed to be equal to x. + float y = (m.captured(2).isEmpty()) ? x : m.captured(2).toFloat(); + + return QTransform().scale(x, y); +} + +QTransform parseRotate(const QString &values) +{ + // rotate( [ ]) rotate([,,]) + static const QRegularExpression re(R"(^([\d.-]+)(?:(?:\s*,\s*|\s+)([\d.-]+)(?:\s*,\s*|\s+)([\d.-]+))?$)"); + QRegularExpressionMatch m = re.match(values.simplified()); + + if (!m.hasMatch()) + return QTransform(); + + float a = m.captured(1).toFloat(); + + QTransform transform; + + if (m.captured(2).isEmpty() || m.captured(3).isEmpty()) { + transform.rotate(a); + } else { + float x = m.captured(2).toFloat(); + float y = m.captured(3).toFloat(); + transform.translate(x, y); + transform.rotate(a); + transform.translate(-x, -y); + } + + return transform; +} + +QTransform parseSkewX(const QString &values) +{ + // skewX() + static const QRegularExpression re(R"(^([\d.-]+)$)"); + QRegularExpressionMatch m = re.match(values.simplified()); + + if (!m.hasMatch()) + return QTransform(); + + float a = m.captured(1).toFloat(); + + return QTransform(1, 0, 0, std::tan(a * M_PI / 180.0), 1, 0, 0, 0, 1); +} + +QTransform parseSkewY(const QString &values) +{ + // skewY() + static const QRegularExpression re(R"(^([\d.-]+)$)"); + QRegularExpressionMatch m = re.match(values.simplified()); + + if (!m.hasMatch()) + return QTransform(); + + float a = m.captured(1).toFloat(); + + return QTransform(1, std::tan(a * M_PI / 180.0), 0, 0, 1, 0, 0, 0, 1); +} + +QTransform parseTransform(const QString &transformStr) +{ + if (transformStr.isEmpty()) + return QTransform(); + + std::vector transforms; + + static const QRegularExpression reTransform(R"(([\w]+)\(([\s\S]*?)\))"); + QRegularExpressionMatchIterator i = reTransform.globalMatch(transformStr.simplified()); + + while (i.hasNext()) { + QRegularExpressionMatch match = i.next(); + + const QString function = match.captured(1).simplified(); + const QString values = match.captured(2).simplified(); + + if (function == "matrix") + transforms.push_back(parseMatrix(values)); + else if (function == "translate") + transforms.push_back(parseTranslate(values)); + else if (function == "scale") + transforms.push_back(parseScale(values)); + else if (function == "rotate") + transforms.push_back(parseRotate(values)); + else if (function == "skewX") + transforms.push_back(parseSkewX(values)); + else if (function == "skewY") + transforms.push_back(parseSkewY(values)); + } + + QTransform transform; + std::for_each(transforms.rbegin(), transforms.rend(), [&](const QTransform &t) + { + transform *= t; + }); + + return transform; +} + +QString convertQPainterPathtoSVGPath(const QPainterPath &path) +{ + QByteArray byteArray; + QBuffer buffer(&byteArray); + + QSvgGenerator generator; + generator.setOutputDevice(&buffer); + + QPainter p; + p.begin(&generator); + p.drawPath(path); + p.end(); + + QDomDocument tmpDomDocument; + if (!tmpDomDocument.setContent(byteArray)) + return QString(); + + QDomElement pathElement; + + auto extractPathElement = [&pathElement](const QDomNode &node) { + QDomElement element = node.toElement(); + if (!element.isNull()) { + if (element.tagName() == "path") + pathElement = element; + } + }; + + depthFirstTraversal(tmpDomDocument.firstChild(), extractPathElement); + + return pathElement.attribute("d"); +} + +QVariant convertValue(const QByteArray &key, const QString &value) +{ + if (key == "fillOpacity" || key == "strokeOpacity") { + if (value.contains("%")) + return QString(value).replace("%", "").toFloat() / 100.0f; + + return value.toFloat(); + } else if (key == "strokeWidth") { + return value.toInt(); + } else if (key == "opacity") { + return value.toFloat(); + } + + return value; +} + +CSSRule parseCSSRule(const QString &ruleStr) +{ + static const QRegularExpression reRules(R"(([\s\S]*?):([\s\S]*?)(?:;|;?$))"); + + CSSRule rule; + QRegularExpressionMatchIterator i = reRules.globalMatch(ruleStr); + while (i.hasNext()) { + QRegularExpressionMatch match = i.next(); + + if (match.lastCapturedIndex() != 2) + continue; + + CSSProperty property; + property.directive = match.captured(1).trimmed(); + property.value = match.captured(2).trimmed(); + + rule.push_back(property); + } + + return rule; +} + +CSSRules parseCSS(const QDomElement &styleElement) +{ + static const QRegularExpression reCSS(R"(([\s\S]*?){([\s\S]*?)})"); + + CSSRules cssRules; + QRegularExpressionMatchIterator i = reCSS.globalMatch(styleElement.text().simplified()); + while (i.hasNext()) { + QRegularExpressionMatch match = i.next(); + + if (match.lastCapturedIndex() != 2) + continue; + + cssRules.insert(match.captured(1).trimmed(), + parseCSSRule(match.captured(2).trimmed())); + } + + return cssRules; +} + +void applyCSSRules(const CSSRule &cssRule, PropertyMap &properties) +{ + for (const CSSProperty &property : cssRule) { + const QString directive = property.directive; + if (auto iter = findKey(mapping, directive); iter != mapping.end()) { + const QByteArray directive = iter->second.toByteArray(); + properties.insert(directive, convertValue(directive, property.value)); + } + } +} + +// This merges potential fill and/or stroke opacity with fill and/or stroke color +void mergeOpacity(PropertyMap &properties) +{ + auto merge = [&](const QByteArray &opacityKey, const QByteArray &colorKey) { + + if (!properties.contains(opacityKey)) + return; + + QColor color; // By default black same as SVG + + if (properties.contains(colorKey)) + color = QColor(properties[colorKey].toString()); + + color.setAlphaF(properties[opacityKey].toFloat()); + // Insert/replace merged color and remove opacity + properties.insert(colorKey, color.name(QColor::HexArgb)); + properties.remove(opacityKey); + }; + + merge("fillOpacity", "fillColor"); + merge("strokeOpacity", "strokeColor"); +} + +void flattenTransformsAndStyles(const QDomElement &element, + const CSSRules &cssRules, + QTransform &transform, + PropertyMap &properties) +{ + properties.insert("fillColor", "black"); // overwrite default fillColor + properties.insert("strokeWidth", -1); // overwrite default strokeWidth of 4 + + auto collectTransformAndStyle = [&](const QDomNode &node) { + QDomElement e = node.toElement(); + transform *= parseTransform(e.attribute("transform")); + + // Parse and assign presentation attributes contained in mapping + for (const auto &p : mapping) { + const QString attributeValue = e.attribute(p.first.toString()).trimmed(); + if (attributeValue.isEmpty()) + continue; + + const QByteArray directive = p.second.toByteArray(); + properties.insert(directive, convertValue(directive, attributeValue)); + } + + // Parse and assign css styles + if (e.hasAttribute("class")) { + // Replace all commas with whitespaces, if there are commas contained + const QString classStr = e.attribute("class").replace(",", " ").simplified(); + const QStringList classes = classStr.split(" ", Qt::SkipEmptyParts); + + for (const auto &c : classes) + applyCSSRules(cssRules["." + e.attribute("class")], properties); + } + + if (e.hasAttribute("id")) { + const QString id = e.attribute("id").simplified(); + applyCSSRules(cssRules["#" + id], properties); + } + + // Parse and assign inline style + if (e.hasAttribute("style")) { + const QString rule = e.attribute("style").simplified(); + applyCSSRules(parseCSSRule(rule), properties); + } + }; + + topToBottomTraversal(element, collectTransformAndStyle); + + mergeOpacity(properties); +} + +bool applyMinimumBoundingBox(QPainterPath &path, PropertyMap &properties) +{ + const QRectF boundingRect = path.boundingRect(); + + path.translate(-boundingRect.topLeft()); + + properties.insert("x", round(boundingRect.x(), 2)); + properties.insert("y", round(boundingRect.y(), 2)); + properties.insert("width", round(boundingRect.width(), 2)); + properties.insert("height", round(boundingRect.height(), 2)); + + const QString svgPath = convertQPainterPathtoSVGPath(path); + + if (svgPath.isEmpty()) + return false; + + properties.insert("path", svgPath); + + return true; +} + +PropertyMap generateRectProperties(const QDomElement &e, const CSSRules &cssRules) +{ + QRectF rect(e.attribute("x").toFloat(), + e.attribute("y").toFloat(), + e.attribute("width").toFloat(), + e.attribute("height").toFloat()); + + if (!rect.isValid()) + return {}; + + QPainterPath path; + path.addRect(rect); + + PropertyMap properties; + QTransform transform; + flattenTransformsAndStyles(e, cssRules, transform, properties); + + path = transform.map(path); + + if (!applyMinimumBoundingBox(path, properties)) + return {}; + + return properties; +} + +PropertyMap generateEllipseProperties(const QDomElement &e, const CSSRules &cssRules) +{ + const QPointF center(e.attribute("cx").toFloat(), e.attribute("cy").toFloat()); + qreal radiusX = 0; + qreal radiusY = 0; + + if (e.tagName() == "circle") + radiusX = radiusY = e.attribute("r").toFloat(); + else if (e.tagName() == "ellipse") { + radiusX = e.attribute("rx").toFloat(); + radiusY = e.attribute("ry").toFloat(); + } + + if (radiusX <= 0 || radiusY <= 0) + return {}; + + QPainterPath path; + path.addEllipse(center, radiusX, radiusY); + + PropertyMap properties; + QTransform transform; + flattenTransformsAndStyles(e, cssRules, transform, properties); + + path = transform.map(path); + + if (!applyMinimumBoundingBox(path, properties)) + return {}; + + return properties; +} + +PropertyMap generatePathProperties(const QDomElement &e, const CSSRules &cssRules) +{ + if (!e.hasAttribute("d")) + return {}; + + QPainterPath path; + + if (!parsePathDataFast(e.attribute("d"), path)) + return {}; + + PropertyMap properties; + QTransform transform; + flattenTransformsAndStyles(e, cssRules, transform, properties); + + path = transform.map(path); + + if (!applyMinimumBoundingBox(path, properties)) + return {}; + + return properties; +} + +PropertyMap generatePolygonProperties(const QDomElement &e, const CSSRules &cssRules) +{ + if (!e.hasAttribute("points")) + return {}; + + // Replace all commas with whitespaces, if there are commas contained + const QString pointsStr = e.attribute("points").replace(",", " ").simplified(); + const QStringList pointList = pointsStr.split(" ", Qt::SkipEmptyParts); + + if (pointList.isEmpty() || pointList.length() < 4) + return {}; + + QPolygonF polygon; + + for (int i = 0; i < pointList.length(); i += 2) + polygon.emplace_back(pointList[i].toFloat(), pointList[i + 1].toFloat()); + + if (!polygon.isClosed() && polygon.size()) + polygon.push_back(polygon.front()); + + QPainterPath path; + path.addPolygon(polygon); + + PropertyMap properties; + QTransform transform; + flattenTransformsAndStyles(e, cssRules, transform, properties); + + path = transform.map(path); + + if (!applyMinimumBoundingBox(path, properties)) + return {}; + + return properties; +} + +ModelNode createPathNode(ModelNode parent, const PropertyMap &properties) +{ + ItemLibraryEntry itemLibraryEntry; + itemLibraryEntry.setName("SVG Path Item"); + itemLibraryEntry.setType("QtQuick.Studio.Components.SvgPathItem", 1, 0); + + ModelNode node = QmlItemNode::createQmlObjectNode( + parent.view(), itemLibraryEntry, {}, parent.defaultNodeAbstractProperty(), false); + + PropertyMap::const_iterator i = properties.constBegin(); + while (i != properties.constEnd()) { + node.variantProperty(i.key()).setValue(i.value()); + ++i; + } + + return node; +} + +ModelNode createGroupNode(ModelNode parent, const PropertyMap &properties) +{ + ItemLibraryEntry itemLibraryEntry; + itemLibraryEntry.setName("Group"); + itemLibraryEntry.setType("QtQuick.Studio.Components.GroupItem", 1, 0); + + ModelNode node = QmlItemNode::createQmlObjectNode( + parent.view(), itemLibraryEntry, {}, parent.defaultNodeAbstractProperty(), false); + + PropertyMap::const_iterator i = properties.constBegin(); + while (i != properties.constEnd()) { + node.variantProperty(i.key()).setValue(i.value()); + ++i; + } + + return node; +} + +} // namespace + +SVGPasteAction::SVGPasteAction() + : m_domDocument() +{} + +bool SVGPasteAction::containsSVG(const QString &str) +{ + if (!m_domDocument.setContent(str, true)) + return false; // TODO error reporting + + if (m_domDocument.documentElement().namespaceURI() == "http://www.w3.org/2000/svg") + return true; + + return false; +} + +QmlObjectNode SVGPasteAction::createQmlObjectNode(QmlDesigner::ModelNode &targetNode) +{ + const QDomElement rootElement = m_domDocument.documentElement(); + if (rootElement.isNull()) { + qWarning() << Q_FUNC_INFO << "Couldn't create a root element."; + return {}; + } + + PropertyMap viewBoxProperties; + QRectF viewBox(0, 0, 100, 100); + + if (rootElement.hasAttribute("viewBox")) { + // Replace all commas with whitespaces, if there are commas contained + const QString viewBoxStr = rootElement.attribute("viewBox").replace(",", " ").simplified(); + const QStringList tmp = viewBoxStr.split(" ", Qt::SkipEmptyParts); + + if (tmp.size() == 4) { + viewBox = QRectF(round(tmp[0].toFloat(), 2), + round(tmp[1].toFloat(), 2), + round(tmp[2].toFloat(), 2), + round(tmp[3].toFloat(), 2)); + } + } + + viewBoxProperties.insert("x", viewBox.x()); + viewBoxProperties.insert("y", viewBox.y()); + viewBoxProperties.insert("width", viewBox.width()); + viewBoxProperties.insert("height", viewBox.height()); + + const QDomNode node = rootElement.firstChild(); + std::vector shapeElements; + CSSRules cssRules; + + auto processStyleAndCollectShapes = [&](const QDomNode &node) { + QDomElement element = node.toElement(); + if (!element.isNull()) { + if (element.tagName() == "style"/* && element.attribute("type") == "text/css"*/) + cssRules = parseCSS(element); + + if (contains(tagAllowList, element.tagName())) + shapeElements.push_back(element); + } + }; + + depthFirstTraversal(node, processStyleAndCollectShapes); + + viewBoxProperties.insert("clip", true); + + ModelNode groupNode = createGroupNode(targetNode, viewBoxProperties); + + for (const QDomElement &e : shapeElements) { + PropertyMap pathProperties; + + if (e.tagName() == "path") + pathProperties = generatePathProperties(e, cssRules); + else if (e.tagName() == "rect") + pathProperties = generateRectProperties(e, cssRules); + else if (e.tagName() == "polygon") + pathProperties = generatePolygonProperties(e, cssRules); + else if (e.tagName() == "circle" || e.tagName() == "ellipse") + pathProperties = generateEllipseProperties(e, cssRules); + + if (pathProperties.empty()) + continue; + + const QPointF topLeft = -viewBox.topLeft(); + + pathProperties["x"] = pathProperties["x"].toDouble() + topLeft.x(); + pathProperties["y"] = pathProperties["y"].toDouble() + topLeft.y(); + + createPathNode(groupNode, pathProperties); + } + + return groupNode; +} + +} // namespace QmlDesigner diff --git a/src/plugins/qmldesigner/components/componentcore/svgpasteaction.h b/src/plugins/qmldesigner/components/componentcore/svgpasteaction.h new file mode 100644 index 00000000000..0fc144369d7 --- /dev/null +++ b/src/plugins/qmldesigner/components/componentcore/svgpasteaction.h @@ -0,0 +1,56 @@ +/**************************************************************************** +** +** Copyright (C) 2021 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of Qt Creator. +** +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3 as published by the Free Software +** Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-3.0.html. +** +****************************************************************************/ +#pragma once + +#include + +#include + +namespace QmlDesigner { + +struct CSSProperty +{ + QString directive; + QString value; +}; + +using CSSRule = std::vector; +using CSSRules = QHash; +using PropertyMap = QHash; + +class SVGPasteAction +{ +public: + SVGPasteAction(); + + bool containsSVG(const QString &str); + + QmlObjectNode createQmlObjectNode(QmlDesigner::ModelNode &targetNode); + +private: + QDomDocument m_domDocument; +}; + +} // namespace QmlDesigner diff --git a/src/plugins/qmldesigner/components/integration/designdocument.cpp b/src/plugins/qmldesigner/components/integration/designdocument.cpp index b2610b0982a..4b5e2170972 100644 --- a/src/plugins/qmldesigner/components/integration/designdocument.cpp +++ b/src/plugins/qmldesigner/components/integration/designdocument.cpp @@ -51,9 +51,12 @@ #include #include #include +#include #include +#include + #include #include #include @@ -62,6 +65,7 @@ #include #include #include +#include using namespace ProjectExplorer; @@ -171,6 +175,41 @@ Model* DesignDocument::createInFileComponentModel() return model; } +bool DesignDocument::pasteSVG() +{ + SVGPasteAction svgPasteAction; + + if (!svgPasteAction.containsSVG(QApplication::clipboard()->text())) + return false; + + rewriterView()->executeInTransaction("DesignDocument::paste1", [&]() { + ModelNode targetNode; + + // If nodes are currently selected make the first node in selection the target + if (!view()->selectedModelNodes().isEmpty()) + targetNode = view()->firstSelectedModelNode(); + + // If target is still invalid make the root node the target + if (!targetNode.isValid()) + targetNode = view()->rootModelNode(); + + // Check if document has studio components import, if not create it + QmlDesigner::Import import = QmlDesigner::Import::createLibraryImport("QtQuick.Studio.Components", "1.0"); + if (!currentModel()->hasImport(import, true, true)) { + QmlDesigner::Import studioComponentsImport = QmlDesigner::Import::createLibraryImport("QtQuick.Studio.Components", "1.0"); + try { + currentModel()->changeImports({studioComponentsImport}, {}); + } catch (const QmlDesigner::Exception &) { + QTC_ASSERT(false, return); + } + } + + svgPasteAction.createQmlObjectNode(targetNode); + }); + + return true; +} + QList DesignDocument::qmlParseWarnings() const { return m_rewriterView->warnings(); @@ -495,6 +534,9 @@ static void scatterItem(const ModelNode &pastedNode, const ModelNode &targetNode void DesignDocument::paste() { + if (pasteSVG()) + return; + if (TimelineActions::clipboardContainsKeyframes()) // pasting keyframes is handled in TimelineView return; @@ -518,7 +560,7 @@ void DesignDocument::paste() ModelNode targetNode; if (!view.selectedModelNodes().isEmpty()) - targetNode = view.selectedModelNodes().constFirst(); + targetNode = view.firstSelectedModelNode(); // in case we copy and paste a selection we paste in the parent item if ((view.selectedModelNodes().count() == selectedNodes.count()) && targetNode.isValid() && targetNode.hasParentProperty()) { @@ -572,7 +614,7 @@ void DesignDocument::paste() ModelNode targetNode; if (!view.selectedModelNodes().isEmpty()) { - targetNode = view.selectedModelNodes().constFirst(); + targetNode = view.firstSelectedModelNode(); } else { // if selection is empty and this is a 3D Node, paste it under the active scene if (pastedNode.isSubclassOf("QtQuick3D.Node")) { diff --git a/src/plugins/qmldesigner/components/integration/designdocument.h b/src/plugins/qmldesigner/components/integration/designdocument.h index 0d80f344de1..ed2ef9ed112 100644 --- a/src/plugins/qmldesigner/components/integration/designdocument.h +++ b/src/plugins/qmldesigner/components/integration/designdocument.h @@ -145,6 +145,8 @@ private: // functions Model *createInFileComponentModel(); + bool pasteSVG(); + private: // variables QScopedPointer m_documentModel; QScopedPointer m_inFileComponentModel; diff --git a/src/plugins/qmldesigner/qmldesignerplugin.pro b/src/plugins/qmldesigner/qmldesignerplugin.pro index 41e7568ff7e..78fe856fb0d 100644 --- a/src/plugins/qmldesigner/qmldesignerplugin.pro +++ b/src/plugins/qmldesigner/qmldesignerplugin.pro @@ -1,4 +1,4 @@ -QT += quickwidgets core-private +QT += quickwidgets core-private xml svg CONFIG += exceptions INCLUDEPATH += $$PWD diff --git a/src/plugins/qmldesigner/qmldesignerplugin.qbs b/src/plugins/qmldesigner/qmldesignerplugin.qbs index 2ff2d3cb2bb..80b54747a6e 100644 --- a/src/plugins/qmldesigner/qmldesignerplugin.qbs +++ b/src/plugins/qmldesigner/qmldesignerplugin.qbs @@ -10,7 +10,7 @@ Project { Depends { name: "Qt"; submodules: [ - "core-private", "quickwidgets" + "core-private", "quickwidgets", "xml", "svg" ] } Depends { name: "AdvancedDockingSystem" } @@ -482,6 +482,8 @@ Project { "componentcore/zoomaction.h", "componentcore/hdrimage.cpp", "componentcore/hdrimage.h", + "componentcore/svgpasteaction.cpp", + "componentcore/svgpasteaction.h", "texteditor/texteditorstatusbar.cpp", "texteditor/texteditorstatusbar.h", "componentcore/changestyleaction.cpp",