From 9ecb6aa14a711d7ee88e071bd242b76751b1f0df Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 9 Jun 2026 14:08:14 +0200 Subject: [PATCH 01/16] first commit --- src/app/GUI/mainwindow.cpp | 34 ++++ src/app/GUI/mainwindow.h | 2 + src/app/GUI/menu.cpp | 8 + src/core/CMakeLists.txt | 2 + src/core/svganimationimporter.cpp | 289 ++++++++++++++++++++++++++++++ src/core/svganimationimporter.h | 27 +++ 6 files changed, 362 insertions(+) create mode 100644 src/core/svganimationimporter.cpp create mode 100644 src/core/svganimationimporter.h diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 2b5c68ee8..effab43b8 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -24,6 +24,7 @@ // Fork of enve - Copyright (C) 2016-2020 Maurycy Liebner #include "mainwindow.h" +#include "svganimationimporter.h" #include "GUI/Expressions/expressiondialog.h" #include "canvas.h" #include @@ -111,6 +112,7 @@ MainWindow::MainWindow(Document& document, , mLinkedAct(nullptr) , mImportAct(nullptr) , mImportSeqAct(nullptr) + , mImportSVGAnimationAct(nullptr) , mRevertAct(nullptr) , mSelectAllAct(nullptr) , mInvertSelAct(nullptr) @@ -390,6 +392,7 @@ void MainWindow::updateSettingsForCurrentCanvas(Canvas* const scene) if (mLinkedAct) { mLinkedAct->setEnabled(scene); } if (mImportAct) { mImportAct->setEnabled(scene); } if (mImportSeqAct) { mImportSeqAct->setEnabled(scene); } + if (mImportSVGAnimationAct) { mImportSVGAnimationAct->setEnabled(scene); } if (mRevertAct) { mRevertAct->setEnabled(scene); } if (mSelectAllAct) { mSelectAllAct->setEnabled(scene); } if (mInvertSelAct) { mInvertSelAct->setEnabled(scene); } @@ -1287,6 +1290,37 @@ void MainWindow::importImageSequence() if (!folder.isEmpty()) { mActions.importFile(folder); } } +void MainWindow::importSVGAnimation() +{ + if (!mDocument.fActiveScene) { return; } + disableEventFilter(); + const QString recentDir = AppSupport::getSettings("files", + "recentImportDir", + QDir::homePath()).toString(); + const QString title = tr("Import SVG Animation", + "ImportSVGAnimationDialog_Title"); + const QString path = eDialogs::openFile(title, recentDir, + tr("SVG Files (*.svg)")); + enableEventFilter(); + if (path.isEmpty()) { return; } + + try { + Canvas* const scene = mDocument.fActiveScene; + ContainerBox* const target = scene->getCurrentGroup(); + auto block = scene->blockUndoRedo(); + const auto imported = ImportSVGAnimation::loadSVGFile(path, scene); + if (!imported) { return; } + block.reset(); + target->prp_pushUndoRedoName(tr("Import SVG Animation")); + target->insertContained(0, imported); + imported->planCenterPivotPosition(); + AppSupport::setSettings("files", "recentImportDir", + QFileInfo(path).absoluteDir().absolutePath()); + } catch (const std::exception& e) { + gPrintExceptionCritical(e); + } +} + void MainWindow::revert() { const int ask = QMessageBox::question(this, diff --git a/src/app/GUI/mainwindow.h b/src/app/GUI/mainwindow.h index 57e149f4b..03d2ee805 100644 --- a/src/app/GUI/mainwindow.h +++ b/src/app/GUI/mainwindow.h @@ -174,6 +174,7 @@ class MainWindow : public QMainWindow bool closeProject(); void linkFile(); void importImageSequence(); + void importSVGAnimation(); void importFile(); void revert(); void updateAutoSaveBackupState(); @@ -248,6 +249,7 @@ class MainWindow : public QMainWindow QAction *mLinkedAct; QAction *mImportAct; QAction *mImportSeqAct; + QAction *mImportSVGAnimationAct; QAction *mRevertAct; QAction *mSelectAllAct; QAction *mInvertSelAct; diff --git a/src/app/GUI/menu.cpp b/src/app/GUI/menu.cpp index 862aa985a..8b78866d4 100644 --- a/src/app/GUI/menu.cpp +++ b/src/app/GUI/menu.cpp @@ -87,6 +87,14 @@ void MainWindow::setupMenuBar() mImportSeqAct->setEnabled(false); cmdAddAction(mImportSeqAct); + mImportSVGAnimationAct = mFileMenu->addAction( + QIcon::fromTheme("file_import"), + tr("Import SVG Animation", "MenuBar_File"), + this, &MainWindow::importSVGAnimation); + mImportSVGAnimationAct->setEnabled(false); + mImportSVGAnimationAct->setObjectName("ImportSVGAnimationAct"); + cmdAddAction(mImportSVGAnimationAct); + mRevertAct = mFileMenu->addAction(QIcon::fromTheme("loop_back"), tr("Revert", "MenuBar_File"), this, &MainWindow::revert); diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 0747e965c..be3ee0bd8 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -316,6 +316,7 @@ set( svgexporter.cpp svgexporthelpers.cpp svgimporter.cpp + svganimationimporter.cpp switchablecontext.cpp swt_abstraction.cpp swt_rulescollection.cpp @@ -651,6 +652,7 @@ set( svgexporter.h svgexporthelpers.h svgimporter.h + svganimationimporter.h switchablecontext.h swt_abstraction.h swt_rulescollection.h diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp new file mode 100644 index 000000000..8422e167c --- /dev/null +++ b/src/core/svganimationimporter.cpp @@ -0,0 +1,289 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#include "svganimationimporter.h" + +#include +#include +#include + +#include "Animators/qpointfanimator.h" +#include "Animators/qrealanimator.h" +#include "Animators/qrealkey.h" +#include "Animators/transformanimator.h" +#include "Boxes/boundingbox.h" +#include "Boxes/containerbox.h" +#include "canvas.h" +#include "exceptions.h" +#include "svgimporter.h" + +namespace { + +struct AnimationTrack { + QString targetId; + QString attribute; + QString type; + QString calcMode; + QList times; + QStringList values; + QList> splines; + qreal begin = 0; + qreal duration = 0; +}; + +qreal parseClock(const QString& value, bool* ok = nullptr) +{ + QString text = value.trimmed(); + qreal multiplier = 1; + if (text.endsWith("ms")) { + multiplier = 0.001; + text.chop(2); + } else if (text.endsWith('s')) { + text.chop(1); + } + bool parsed = false; + const qreal result = text.toDouble(&parsed) * multiplier; + if (ok) { *ok = parsed; } + return parsed ? result : 0; +} + +QList parseNumbers(const QString& value) +{ + QList result; + const auto parts = value.split(QRegularExpression("[,\\s]+"), + Qt::SkipEmptyParts); + for (const QString& part : parts) { + bool ok = false; + const qreal number = part.toDouble(&ok); + if (ok) { result.append(number); } + } + return result; +} + +QList parseSemicolonNumbers(const QString& value) +{ + QList result; + for (const QString& part : value.split(';', Qt::SkipEmptyParts)) { + bool ok = false; + const qreal number = part.trimmed().toDouble(&ok); + if (ok) { result.append(number); } + } + return result; +} + +BoundingBox* findBox(BoundingBox* box, const QString& name) +{ + if (!box) { return nullptr; } + if (box->prp_getName() == name) { return box; } + const auto container = enve_cast(box); + if (!container) { return nullptr; } + for (BoundingBox* child : container->getContainedBoxes()) { + if (const auto found = findBox(child, name)) { return found; } + } + return nullptr; +} + +QString ensureTargetId(QDomElement& target, int& nextId) +{ + QString id = target.attribute("id"); + if (id.isEmpty()) { + id = QString("__friction_svg_animation_%1").arg(nextId++); + target.setAttribute("id", id); + } + return id; +} + +QList collectTracks(QDomDocument& document) +{ + QList result; + int nextId = 0; + const QStringList tags{"animate", "animateTransform"}; + for (const QString& tag : tags) { + const QDomNodeList nodes = document.elementsByTagName(tag); + for (int i = 0; i < nodes.count(); ++i) { + const QDomElement animation = nodes.at(i).toElement(); + QDomElement target = animation.parentNode().toElement(); + if (target.isNull()) { continue; } + + AnimationTrack track; + track.targetId = ensureTargetId(target, nextId); + track.attribute = animation.attribute("attributeName"); + track.type = animation.attribute("type"); + track.calcMode = animation.attribute("calcMode", "linear"); + + bool durationOk = false; + track.duration = parseClock(animation.attribute("dur"), &durationOk); + if (!durationOk || track.duration <= 0) { continue; } + + bool beginOk = false; + track.begin = parseClock(animation.attribute("begin", "0s"), &beginOk); + if (!beginOk) { track.begin = 0; } + + track.values = animation.attribute("values") + .split(';', Qt::SkipEmptyParts); + if (track.values.isEmpty()) { + const QString from = animation.attribute("from"); + const QString to = animation.attribute("to"); + if (!from.isEmpty() && !to.isEmpty()) { + track.values << from << to; + } + } + if (track.values.size() < 2) { continue; } + + track.times = parseSemicolonNumbers(animation.attribute("keyTimes")); + if (track.times.size() != track.values.size()) { + track.times.clear(); + const qreal divisor = track.values.size() - 1; + for (int valueId = 0; valueId < track.values.size(); ++valueId) { + track.times.append(valueId / divisor); + } + } + + for (const QString& spline : + animation.attribute("keySplines").split(';', Qt::SkipEmptyParts)) { + track.splines.append(parseNumbers(spline)); + } + result.append(track); + } + } + return result; +} + +void applySpline(QrealAnimator* animator, + const int frame0, const int frame1, + const qreal value0, const qreal value1, + const QList& spline) +{ + if (!animator || spline.size() != 4 || frame0 == frame1) { return; } + auto key0 = animator->anim_getKeyAtRelFrame(frame0); + auto key1 = animator->anim_getKeyAtRelFrame(frame1); + if (!key0 || !key1) { return; } + + const qreal frameSpan = frame1 - frame0; + const qreal valueSpan = value1 - value0; + key0->setC1Enabled(true); + key0->setC1Frame(frame0 + spline.at(0) * frameSpan); + key0->setC1Value(value0 + spline.at(1) * valueSpan); + key1->setC0Enabled(true); + key1->setC0Frame(frame0 + spline.at(2) * frameSpan); + key1->setC0Value(value0 + spline.at(3) * valueSpan); +} + +void applyScalarTrack(QrealAnimator* animator, + const AnimationTrack& track, + const qreal fps, + const qreal multiplier = 1) +{ + if (!animator) { return; } + QList frames; + QList values; + for (int i = 0; i < track.values.size(); ++i) { + bool ok = false; + const qreal value = track.values.at(i).trimmed().toDouble(&ok) * multiplier; + if (!ok) { return; } + const int frame = qRound((track.begin + track.times.at(i) * + track.duration) * fps); + animator->saveValueToKey(frame, value); + frames.append(frame); + values.append(value); + } + if (track.calcMode != "spline") { return; } + for (int i = 0; i + 1 < frames.size() && i < track.splines.size(); ++i) { + applySpline(animator, frames.at(i), frames.at(i + 1), + values.at(i), values.at(i + 1), track.splines.at(i)); + } +} + +void applyPointTrack(QPointFAnimator* animator, + const AnimationTrack& track, + const qreal fps, + const bool uniformSingleValue) +{ + if (!animator) { return; } + AnimationTrack xTrack = track; + AnimationTrack yTrack = track; + xTrack.values.clear(); + yTrack.values.clear(); + for (const QString& value : track.values) { + const auto numbers = parseNumbers(value); + if (numbers.isEmpty()) { return; } + xTrack.values.append(QString::number(numbers.at(0))); + const qreal y = numbers.size() > 1 ? numbers.at(1) : + uniformSingleValue ? numbers.at(0) : 0; + yTrack.values.append(QString::number(y)); + } + applyScalarTrack(animator->getXAnimator(), xTrack, fps); + applyScalarTrack(animator->getYAnimator(), yTrack, fps); +} + +void applyTrack(BoundingBox* box, const AnimationTrack& track, const qreal fps) +{ + const auto transform = box ? box->getBoxTransformAnimator() : nullptr; + if (!transform) { return; } + if (track.attribute == "opacity") { + applyScalarTrack(transform->getOpacityAnimator(), track, fps, 100); + } else if (track.attribute == "transform" && track.type == "translate") { + applyPointTrack(transform->getPosAnimator(), track, fps, false); + } else if (track.attribute == "transform" && track.type == "scale") { + applyPointTrack(transform->getScaleAnimator(), track, fps, true); + } else if (track.attribute == "transform" && track.type == "rotate") { + AnimationTrack rotation = track; + rotation.values.clear(); + for (const QString& value : track.values) { + const auto numbers = parseNumbers(value); + if (numbers.isEmpty()) { return; } + rotation.values.append(QString::number(numbers.first())); + } + applyScalarTrack(transform->getRotAnimator(), rotation, fps); + } else if (track.attribute == "transform" && track.type == "skewX") { + applyScalarTrack(transform->getShearAnimator()->getXAnimator(), + track, fps, 1. / 45.); + } else if (track.attribute == "transform" && track.type == "skewY") { + applyScalarTrack(transform->getShearAnimator()->getYAnimator(), + track, fps, 1. / 45.); + } +} + +} // namespace + +qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, + Canvas* scene) +{ + if (!scene) { RuntimeThrow("SVG animation import requires an active scene"); } + QFile file(filename); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + RuntimeThrow("Cannot open file " + filename); + } + QDomDocument document; + if (!document.setContent(&file)) { + RuntimeThrow("Cannot parse SVG animation file " + filename); + } + + const auto tracks = collectTracks(document); + const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; + const auto result = ImportSVG::loadSVGFile(document, gradientCreator); + if (!result) { return nullptr; } + + for (const AnimationTrack& track : tracks) { + applyTrack(findBox(result.get(), track.targetId), track, scene->getFps()); + } + int lastFrame = scene->getMaxFrame(); + for (const AnimationTrack& track : tracks) { + lastFrame = qMax(lastFrame, + qRound((track.begin + track.duration) * scene->getFps())); + } + if (lastFrame > scene->getMaxFrame()) { + scene->setFrameRange({scene->getMinFrame(), lastFrame}); + } + return result; +} diff --git a/src/core/svganimationimporter.h b/src/core/svganimationimporter.h new file mode 100644 index 000000000..b849b65b0 --- /dev/null +++ b/src/core/svganimationimporter.h @@ -0,0 +1,27 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#ifndef SVGANIMATIONIMPORTER_H +#define SVGANIMATIONIMPORTER_H + +#include "smartPointers/selfref.h" + +class BoundingBox; +class Canvas; + +namespace ImportSVGAnimation { + CORE_EXPORT qsptr loadSVGFile(const QString& filename, + Canvas* scene); +} + +#endif // SVGANIMATIONIMPORTER_H From 5d2f4d887fd87d31ad699c56827e6f97e1e9968c Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 10 Jun 2026 18:25:53 +0200 Subject: [PATCH 02/16] Improve animated SVG geometry import --- src/core/svganimationimporter.cpp | 187 ++++++++++++++++++++++++++++-- 1 file changed, 179 insertions(+), 8 deletions(-) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 8422e167c..145a65fdb 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -20,9 +20,13 @@ #include "Animators/qpointfanimator.h" #include "Animators/qrealanimator.h" #include "Animators/qrealkey.h" +#include "Animators/SmartPath/smartpathanimator.h" #include "Animators/transformanimator.h" #include "Boxes/boundingbox.h" +#include "Boxes/circle.h" #include "Boxes/containerbox.h" +#include "Boxes/rectangle.h" +#include "Boxes/smartvectorpath.h" #include "canvas.h" #include "exceptions.h" #include "svgimporter.h" @@ -31,6 +35,7 @@ namespace { struct AnimationTrack { QString targetId; + QString targetName; QString attribute; QString type; QString calcMode; @@ -95,11 +100,15 @@ BoundingBox* findBox(BoundingBox* box, const QString& name) QString ensureTargetId(QDomElement& target, int& nextId) { - QString id = target.attribute("id"); - if (id.isEmpty()) { - id = QString("__friction_svg_animation_%1").arg(nextId++); - target.setAttribute("id", id); - } + const QString existing = target.attribute("data-friction-animation-target"); + if (!existing.isEmpty()) { return existing; } + const QString id = QString("__friction_svg_animation_%1").arg(nextId++); + const QString name = target.attribute("inkscape:label", + target.attribute("id", + target.tagName())); + target.setAttribute("data-friction-animation-target", id); + target.setAttribute("data-friction-animation-name", name); + target.setAttribute("inkscape:label", id); return id; } @@ -117,6 +126,7 @@ QList collectTracks(QDomDocument& document) AnimationTrack track; track.targetId = ensureTargetId(target, nextId); + track.targetName = target.attribute("data-friction-animation-name"); track.attribute = animation.attribute("attributeName"); track.type = animation.attribute("type"); track.calcMode = animation.attribute("calcMode", "linear"); @@ -226,9 +236,159 @@ void applyPointTrack(QPointFAnimator* animator, applyScalarTrack(animator->getYAnimator(), yTrack, fps); } -void applyTrack(BoundingBox* box, const AnimationTrack& track, const qreal fps) +AnimationTrack offsetTrack(const AnimationTrack& source, const qreal offset) +{ + AnimationTrack result = source; + result.values.clear(); + for (const QString& value : source.values) { + bool ok = false; + const qreal number = value.trimmed().toDouble(&ok); + if (!ok) { return AnimationTrack(); } + result.values.append(QString::number(number + offset)); + } + return result; +} + +const AnimationTrack* findTrack(const QList& tracks, + const QString& targetId, + const QString& attribute) +{ + for (const AnimationTrack& track : tracks) { + if (track.targetId == targetId && track.attribute == attribute) { + return &track; + } + } + return nullptr; +} + +AnimationTrack sumTracks(const AnimationTrack& primary, + const AnimationTrack* secondary, + const qreal secondaryFallback) +{ + if (!secondary || + secondary->values.size() != primary.values.size() || + secondary->times != primary.times || + !qFuzzyCompare(secondary->begin + 1, primary.begin + 1) || + !qFuzzyCompare(secondary->duration + 1, primary.duration + 1)) { + return offsetTrack(primary, secondaryFallback); + } + AnimationTrack result = primary; + result.values.clear(); + for (int i = 0; i < primary.values.size(); ++i) { + bool primaryOk = false; + bool secondaryOk = false; + const qreal primaryValue = primary.values.at(i).trimmed().toDouble(&primaryOk); + const qreal secondaryValue = + secondary->values.at(i).trimmed().toDouble(&secondaryOk); + if (!primaryOk || !secondaryOk) { return AnimationTrack(); } + result.values.append(QString::number(primaryValue + secondaryValue)); + } + return result; +} + +void applyCircleTrack(Circle* circle, const AnimationTrack& track, + const qreal fps) +{ + if (track.attribute == "cx") { + applyScalarTrack(circle->getCenterAnimator()->getXAnimator(), track, fps); + } else if (track.attribute == "cy") { + applyScalarTrack(circle->getCenterAnimator()->getYAnimator(), track, fps); + } else if (track.attribute == "rx") { + applyScalarTrack(circle->getHRadiusAnimator()->getXAnimator(), track, fps); + } else if (track.attribute == "ry") { + applyScalarTrack(circle->getVRadiusAnimator()->getYAnimator(), track, fps); + } else if (track.attribute == "r") { + applyScalarTrack(circle->getHRadiusAnimator()->getXAnimator(), track, fps); + applyScalarTrack(circle->getVRadiusAnimator()->getYAnimator(), track, fps); + } +} + +void applyRectangleTrack(RectangleBox* rectangle, const AnimationTrack& track, + const QList& tracks, const qreal fps) { - const auto transform = box ? box->getBoxTransformAnimator() : nullptr; + const QPointF topLeft = rectangle->getTopLeftAnimator()->getBaseValue(); + const QPointF bottomRight = rectangle->getBottomRightAnimator()->getBaseValue(); + if (track.attribute == "x") { + applyScalarTrack(rectangle->getTopLeftAnimator()->getXAnimator(), + track, fps); + if (!findTrack(tracks, track.targetId, "width")) { + applyScalarTrack(rectangle->getBottomRightAnimator()->getXAnimator(), + offsetTrack(track, bottomRight.x() - topLeft.x()), fps); + } + } else if (track.attribute == "y") { + applyScalarTrack(rectangle->getTopLeftAnimator()->getYAnimator(), + track, fps); + if (!findTrack(tracks, track.targetId, "height")) { + applyScalarTrack(rectangle->getBottomRightAnimator()->getYAnimator(), + offsetTrack(track, bottomRight.y() - topLeft.y()), fps); + } + } else if (track.attribute == "width") { + applyScalarTrack(rectangle->getBottomRightAnimator()->getXAnimator(), + sumTracks(track, findTrack(tracks, track.targetId, "x"), + topLeft.x()), fps); + } else if (track.attribute == "height") { + applyScalarTrack(rectangle->getBottomRightAnimator()->getYAnimator(), + sumTracks(track, findTrack(tracks, track.targetId, "y"), + topLeft.y()), fps); + } else if (track.attribute == "rx") { + applyScalarTrack(rectangle->getRadiusAnimator()->getXAnimator(), + track, fps); + } else if (track.attribute == "ry") { + applyScalarTrack(rectangle->getRadiusAnimator()->getYAnimator(), + track, fps); + } +} + +void applyPathTrack(SmartVectorPath* vectorPath, const AnimationTrack& track, + const qreal fps) +{ + if (track.attribute != "d") { return; } + const auto collection = vectorPath->getPathAnimator(); + if (!collection || collection->ca_getNumberOfChildren() != 1) { return; } + const auto animator = collection->getChild(0); + QList keys; + for (int i = 0; i < track.values.size(); ++i) { + SkPath path; + const auto pathString = track.values.at(i).trimmed().toStdString(); + if (!SkParsePath::FromSVGString(pathString.c_str(), &path)) { return; } + const int frame = qRound((track.begin + track.times.at(i) * + track.duration) * fps); + const auto key = enve::make_shared(SmartPath(path), + frame, animator); + animator->anim_appendKey(key); + keys.append(key.get()); + } + if (track.calcMode != "spline") { return; } + for (int i = 0; i + 1 < keys.size() && i < track.splines.size(); ++i) { + const auto& spline = track.splines.at(i); + if (spline.size() != 4) { continue; } + const qreal frame0 = keys.at(i)->getRelFrame(); + const qreal frame1 = keys.at(i + 1)->getRelFrame(); + const qreal span = frame1 - frame0; + keys.at(i)->setC1Enabled(true); + keys.at(i)->setC1Frame(frame0 + spline.at(0) * span); + keys.at(i)->setC1Value(frame0 + spline.at(1) * span); + keys.at(i + 1)->setC0Enabled(true); + keys.at(i + 1)->setC0Frame(frame0 + spline.at(2) * span); + keys.at(i + 1)->setC0Value(frame0 + spline.at(3) * span); + } +} + +void applyTrack(BoundingBox* box, const AnimationTrack& track, + const QList& tracks, const qreal fps) +{ + if (!box) { return; } + if (const auto circle = enve_cast(box)) { + applyCircleTrack(circle, track, fps); + } + if (const auto rectangle = enve_cast(box)) { + applyRectangleTrack(rectangle, track, tracks, fps); + } + if (const auto vectorPath = enve_cast(box)) { + applyPathTrack(vectorPath, track, fps); + } + + const auto transform = box->getBoxTransformAnimator(); if (!transform) { return; } if (track.attribute == "opacity") { applyScalarTrack(transform->getOpacityAnimator(), track, fps, 100); @@ -274,8 +434,19 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, const auto result = ImportSVG::loadSVGFile(document, gradientCreator); if (!result) { return nullptr; } + QHash targets; for (const AnimationTrack& track : tracks) { - applyTrack(findBox(result.get(), track.targetId), track, scene->getFps()); + if (!targets.contains(track.targetId)) { + targets.insert(track.targetId, findBox(result.get(), track.targetId)); + } + applyTrack(targets.value(track.targetId), track, tracks, scene->getFps()); + } + for (const AnimationTrack& track : tracks) { + BoundingBox* const target = targets.value(track.targetId); + if (target && !track.targetName.isEmpty() && + target->prp_getName() != track.targetName) { + target->prp_setName(track.targetName); + } } int lastFrame = scene->getMaxFrame(); for (const AnimationTrack& track : tracks) { From 7f4795e2ac47f795550c9ec0ba9a368ede6cc413 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Wed, 10 Jun 2026 22:47:32 +0200 Subject: [PATCH 03/16] Import animated SVG paint properties --- src/app/GUI/mainwindow.cpp | 4 + src/core/svganimationimporter.cpp | 120 +++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 1 deletion(-) diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index effab43b8..94123d4a7 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -1313,7 +1313,11 @@ void MainWindow::importSVGAnimation() block.reset(); target->prp_pushUndoRedoName(tr("Import SVG Animation")); target->insertContained(0, imported); + imported->prp_setName(QFileInfo(path).completeBaseName()); imported->planCenterPivotPosition(); + imported->updateAllBoxes(UpdateReason::userChange); + scene->requestUpdate(); + mDocument.actionFinished(); AppSupport::setSettings("files", "recentImportDir", QFileInfo(path).absoluteDir().absolutePath()); } catch (const std::exception& e) { diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 145a65fdb..accb1890c 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -20,6 +20,9 @@ #include "Animators/qpointfanimator.h" #include "Animators/qrealanimator.h" #include "Animators/qrealkey.h" +#include "Animators/coloranimator.h" +#include "Animators/outlinesettingsanimator.h" +#include "Animators/paintsettingsanimator.h" #include "Animators/SmartPath/smartpathanimator.h" #include "Animators/transformanimator.h" #include "Boxes/boundingbox.h" @@ -46,6 +49,10 @@ struct AnimationTrack { qreal duration = 0; }; +const AnimationTrack* findTrack(const QList& tracks, + const QString& targetId, + const QString& attribute); + qreal parseClock(const QString& value, bool* ok = nullptr) { QString text = value.trimmed(); @@ -86,6 +93,38 @@ QList parseSemicolonNumbers(const QString& value) return result; } +bool parseColor(const QString& value, QColor& color) +{ + const QString text = value.trimmed(); + color = QColor(text); + if (color.isValid()) { return true; } + + static const QRegularExpression function( + "^rgba?\\s*\\(([^)]*)\\)$", + QRegularExpression::CaseInsensitiveOption); + const auto match = function.match(text); + if (!match.hasMatch()) { return false; } + const auto parts = match.captured(1).split(QRegularExpression("[,\\s]+"), + Qt::SkipEmptyParts); + if (parts.size() < 3) { return false; } + bool rOk = false; + bool gOk = false; + bool bOk = false; + const int r = parts.at(0).toInt(&rOk); + const int g = parts.at(1).toInt(&gOk); + const int b = parts.at(2).toInt(&bOk); + if (!rOk || !gOk || !bOk) { return false; } + qreal alpha = 1; + if (parts.size() > 3) { + bool alphaOk = false; + alpha = parts.at(3).toDouble(&alphaOk); + if (!alphaOk) { return false; } + } + color.setRgb(r, g, b); + color.setAlphaF(qBound(0., alpha, 1.)); + return true; +} + BoundingBox* findBox(BoundingBox* box, const QString& name) { if (!box) { return nullptr; } @@ -203,10 +242,16 @@ void applyScalarTrack(QrealAnimator* animator, if (!ok) { return; } const int frame = qRound((track.begin + track.times.at(i) * track.duration) * fps); - animator->saveValueToKey(frame, value); frames.append(frame); values.append(value); } + for (int i = 0; i < frames.size(); ++i) { + if (track.calcMode == "discrete" && i > 0 && + frames.at(i) - 1 > frames.at(i - 1)) { + animator->saveValueToKey(frames.at(i) - 1, values.at(i - 1)); + } + animator->saveValueToKey(frames.at(i), values.at(i)); + } if (track.calcMode != "spline") { return; } for (int i = 0; i + 1 < frames.size() && i < track.splines.size(); ++i) { applySpline(animator, frames.at(i), frames.at(i + 1), @@ -214,6 +259,59 @@ void applyScalarTrack(QrealAnimator* animator, } } +void applyColorTrack(ColorAnimator* animator, const AnimationTrack& track, + const QList& tracks, const qreal fps, + const QString& opacityAttribute) +{ + if (!animator) { return; } + AnimationTrack red = track; + AnimationTrack green = track; + AnimationTrack blue = track; + AnimationTrack alpha = track; + red.values.clear(); + green.values.clear(); + blue.values.clear(); + alpha.values.clear(); + const bool separateOpacity = + findTrack(tracks, track.targetId, opacityAttribute); + for (const QString& value : track.values) { + QColor color; + if (!parseColor(value, color)) { return; } + red.values.append(QString::number(color.redF())); + green.values.append(QString::number(color.greenF())); + blue.values.append(QString::number(color.blueF())); + alpha.values.append(QString::number(color.alphaF())); + } + animator->setColorMode(ColorMode::rgb); + applyScalarTrack(animator->getVal1Animator(), red, fps); + applyScalarTrack(animator->getVal2Animator(), green, fps); + applyScalarTrack(animator->getVal3Animator(), blue, fps); + if (!separateOpacity) { + applyScalarTrack(animator->getAlphaAnimator(), alpha, fps); + } +} + +void applyPaintTrack(PaintSettingsAnimator* paint, + const AnimationTrack& track, + const QList& tracks, + const qreal fps, + const QString& colorAttribute, + const QString& opacityAttribute) +{ + if (!paint) { return; } + if (track.attribute == colorAttribute) { + for (const QString& value : track.values) { + if (value.trimmed() == "none") { return; } + } + paint->setPaintType(PaintType::FLATPAINT); + applyColorTrack(paint->getColorAnimator(), track, tracks, fps, + opacityAttribute); + } else if (track.attribute == opacityAttribute) { + applyScalarTrack(paint->getColorAnimator()->getAlphaAnimator(), + track, fps); + } +} + void applyPointTrack(QPointFAnimator* animator, const AnimationTrack& track, const qreal fps, @@ -347,16 +445,26 @@ void applyPathTrack(SmartVectorPath* vectorPath, const AnimationTrack& track, if (!collection || collection->ca_getNumberOfChildren() != 1) { return; } const auto animator = collection->getChild(0); QList keys; + SmartPath previousPath; + int previousFrame = 0; for (int i = 0; i < track.values.size(); ++i) { SkPath path; const auto pathString = track.values.at(i).trimmed().toStdString(); if (!SkParsePath::FromSVGString(pathString.c_str(), &path)) { return; } const int frame = qRound((track.begin + track.times.at(i) * track.duration) * fps); + if (track.calcMode == "discrete" && i > 0 && + frame - 1 > previousFrame) { + const auto holdKey = enve::make_shared( + previousPath, frame - 1, animator); + animator->anim_appendKey(holdKey); + } const auto key = enve::make_shared(SmartPath(path), frame, animator); animator->anim_appendKey(key); keys.append(key.get()); + previousPath = SmartPath(path); + previousFrame = frame; } if (track.calcMode != "spline") { return; } for (int i = 0; i + 1 < keys.size() && i < track.splines.size(); ++i) { @@ -387,6 +495,16 @@ void applyTrack(BoundingBox* box, const AnimationTrack& track, if (const auto vectorPath = enve_cast(box)) { applyPathTrack(vectorPath, track, fps); } + if (const auto pathBox = enve_cast(box)) { + applyPaintTrack(pathBox->getFillSettings(), track, tracks, fps, + "fill", "fill-opacity"); + applyPaintTrack(pathBox->getStrokeSettings(), track, tracks, fps, + "stroke", "stroke-opacity"); + if (track.attribute == "stroke-width") { + applyScalarTrack(pathBox->getStrokeSettings()->getLineWidthAnimator(), + track, fps); + } + } const auto transform = box->getBoxTransformAnimator(); if (!transform) { return; } From a7b63d25b317e81ae2ac1b2ebb9ed1755ee33c3a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Fri, 12 Jun 2026 08:58:15 +0200 Subject: [PATCH 04/16] Add animated SVG import options --- src/app/CMakeLists.txt | 2 + .../GUI/Dialogs/svganimationimportdialog.cpp | 149 ++++++++++++++++++ .../GUI/Dialogs/svganimationimportdialog.h | 54 +++++++ src/app/GUI/mainwindow.cpp | 36 ++++- src/core/svganimationimporter.cpp | 69 +++++++- src/core/svganimationimporter.h | 3 +- 6 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 src/app/GUI/Dialogs/svganimationimportdialog.cpp create mode 100644 src/app/GUI/Dialogs/svganimationimportdialog.h diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 3c6d783fa..8a73a6e75 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -176,6 +176,7 @@ set( main.cpp GUI/BoxesList/boxscroller.cpp GUI/Dialogs/dialogsinterfaceimpl.cpp + GUI/Dialogs/svganimationimportdialog.cpp GUI/Expressions/expressiondialog.cpp GUI/Expressions/expressioneditor.cpp GUI/Expressions/expressionhighlighter.cpp @@ -228,6 +229,7 @@ set( HEADERS GUI/BoxesList/boxscroller.h GUI/Dialogs/dialogsinterfaceimpl.h + GUI/Dialogs/svganimationimportdialog.h GUI/Expressions/expressiondialog.h GUI/Expressions/expressioneditor.h GUI/Expressions/expressionhighlighter.h diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.cpp b/src/app/GUI/Dialogs/svganimationimportdialog.cpp new file mode 100644 index 000000000..1190c5fac --- /dev/null +++ b/src/app/GUI/Dialogs/svganimationimportdialog.cpp @@ -0,0 +1,149 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#include "svganimationimportdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +qreal readDimension(const QString& value) { + static const QRegularExpression number( + QStringLiteral("^\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)")); + const auto match = number.match(value); + if (!match.hasMatch() || value.trimmed().endsWith('%')) { return 0; } + bool ok = false; + const qreal result = match.captured(1).toDouble(&ok); + return ok && result > 0 ? result : 0; +} + +QString sizeText(const QSizeF& size) { + if (size.width() <= 0 || size.height() <= 0) { + return SVGAnimationImportDialog::tr("Unknown"); + } + return SVGAnimationImportDialog::tr("%1 x %2") + .arg(size.width(), 0, 'g', 8) + .arg(size.height(), 0, 'g', 8); +} + +} + +SVGAnimationImportDialog::SVGAnimationImportDialog( + const QSizeF& svgSize, const QSize& sceneSize, QWidget* const parent) : + QDialog(parent) { + setWindowTitle(tr("Import SVG Animation")); + + const auto mainLayout = new QVBoxLayout(this); + const auto dimensions = new QFormLayout(); + dimensions->addRow(tr("Original SVG dimensions:"), new QLabel(sizeText(svgSize))); + dimensions->addRow(tr("Active scene dimensions:"), + new QLabel(sizeText(sceneSize))); + mainLayout->addLayout(dimensions); + + mOriginal = new QRadioButton(tr("Import at original scale"), this); + mProportional = new QRadioButton( + tr("Scale proportionally to the active scene"), this); + mStretch = new QRadioButton( + tr("Scale to the active scene and deform the original"), this); + mFitDimension = new QComboBox(this); + mFitDimension->addItem(tr("Fit width")); + mFitDimension->addItem(tr("Fit height")); + + mOriginal->setChecked(true); + mFitDimension->setEnabled(false); + connect(mProportional, &QRadioButton::toggled, + mFitDimension, &QComboBox::setEnabled); + + const bool validSize = svgSize.width() > 0 && svgSize.height() > 0 && + sceneSize.width() > 0 && sceneSize.height() > 0; + mProportional->setEnabled(validSize); + mStretch->setEnabled(validSize); + + mainLayout->addWidget(mOriginal); + mainLayout->addWidget(mProportional); + mainLayout->addWidget(mFitDimension); + mainLayout->addWidget(mStretch); + + mExtendSceneTime = new QCheckBox( + tr("Extend scene time if necessary"), this); + mExtendSceneTime->setChecked(true); + mainLayout->addWidget(mExtendSceneTime); + + const auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | + QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); +} + +SVGAnimationImportDialog::ScaleMode SVGAnimationImportDialog::scaleMode() const { + if (mStretch->isChecked()) { return ScaleMode::stretch; } + if (mProportional->isChecked()) { + return mFitDimension->currentIndex() == 0 ? + ScaleMode::fitWidth : ScaleMode::fitHeight; + } + return ScaleMode::original; +} + +bool SVGAnimationImportDialog::extendSceneTime() const { + return mExtendSceneTime->isChecked(); +} + +bool SVGAnimationImportDialog::sExec( + const QString& path, const QSize& sceneSize, QSizeF& svgSize, + ScaleMode& scaleMode, bool& extendSceneTime, QWidget* const parent) { + svgSize = readSVGSize(path); + SVGAnimationImportDialog dialog(svgSize, sceneSize, parent); + if (dialog.exec() != QDialog::Accepted) { return false; } + scaleMode = dialog.scaleMode(); + extendSceneTime = dialog.extendSceneTime(); + return true; +} + +QSizeF SVGAnimationImportDialog::readSVGSize(const QString& path) { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { return {}; } + + QDomDocument document; + if (!document.setContent(&file)) { return {}; } + const QDomElement svg = document.documentElement(); + if (svg.tagName().compare(QStringLiteral("svg"), + Qt::CaseInsensitive) != 0) { + return {}; + } + + const QStringList viewBox = svg.attribute(QStringLiteral("viewBox")) + .split(QRegularExpression(QStringLiteral("[,\\s]+")), + Qt::SkipEmptyParts); + if (viewBox.size() == 4) { + bool widthOk = false; + bool heightOk = false; + const qreal width = viewBox.at(2).toDouble(&widthOk); + const qreal height = viewBox.at(3).toDouble(&heightOk); + if (widthOk && heightOk && width > 0 && height > 0) { + return QSizeF(width, height); + } + } + + return QSizeF(readDimension(svg.attribute(QStringLiteral("width"))), + readDimension(svg.attribute(QStringLiteral("height")))); +} diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.h b/src/app/GUI/Dialogs/svganimationimportdialog.h new file mode 100644 index 000000000..251a0a5f6 --- /dev/null +++ b/src/app/GUI/Dialogs/svganimationimportdialog.h @@ -0,0 +1,54 @@ +/* +# +# Friction - https://friction.graphics +# +# Copyright (c) Ole-André Rodlie and contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +*/ + +#ifndef SVGANIMATIONIMPORTDIALOG_H +#define SVGANIMATIONIMPORTDIALOG_H + +#include +#include + +class QComboBox; +class QCheckBox; +class QRadioButton; + +class SVGAnimationImportDialog : public QDialog { +public: + enum class ScaleMode { + original, + fitWidth, + fitHeight, + stretch + }; + + SVGAnimationImportDialog(const QSizeF& svgSize, const QSize& sceneSize, + QWidget* const parent); + + ScaleMode scaleMode() const; + bool extendSceneTime() const; + + static bool sExec(const QString& path, const QSize& sceneSize, + QSizeF& svgSize, ScaleMode& scaleMode, + bool& extendSceneTime, + QWidget* const parent); + +private: + static QSizeF readSVGSize(const QString& path); + + QRadioButton* mOriginal; + QRadioButton* mProportional; + QRadioButton* mStretch; + QComboBox* mFitDimension; + QCheckBox* mExtendSceneTime; +}; + +#endif // SVGANIMATIONIMPORTDIALOG_H diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 94123d4a7..f1ebdc563 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -25,8 +25,10 @@ #include "mainwindow.h" #include "svganimationimporter.h" +#include "GUI/Dialogs/svganimationimportdialog.h" #include "GUI/Expressions/expressiondialog.h" #include "canvas.h" +#include "Animators/transformanimator.h" #include #include #include @@ -1306,16 +1308,40 @@ void MainWindow::importSVGAnimation() try { Canvas* const scene = mDocument.fActiveScene; + QSizeF svgSize; + SVGAnimationImportDialog::ScaleMode scaleMode; + bool extendSceneTime = true; + if (!SVGAnimationImportDialog::sExec( + path, QSize(scene->getCanvasWidth(), scene->getCanvasHeight()), + svgSize, scaleMode, extendSceneTime, this)) { + return; + } + + qreal scaleX = 1; + qreal scaleY = 1; + if (scaleMode == SVGAnimationImportDialog::ScaleMode::fitWidth) { + scaleX = scaleY = scene->getCanvasWidth()/svgSize.width(); + } else if (scaleMode == SVGAnimationImportDialog::ScaleMode::fitHeight) { + scaleX = scaleY = scene->getCanvasHeight()/svgSize.height(); + } else if (scaleMode == SVGAnimationImportDialog::ScaleMode::stretch) { + scaleX = scene->getCanvasWidth()/svgSize.width(); + scaleY = scene->getCanvasHeight()/svgSize.height(); + } + ContainerBox* const target = scene->getCurrentGroup(); auto block = scene->blockUndoRedo(); - const auto imported = ImportSVGAnimation::loadSVGFile(path, scene); + const auto imported = ImportSVGAnimation::loadSVGFile( + path, scene, extendSceneTime); if (!imported) { return; } + const auto wrapper = enve::make_shared( + QFileInfo(path).completeBaseName(), eBoxType::group); + wrapper->addContained(imported); + wrapper->getBoxTransformAnimator()->setScale(scaleX, scaleY); block.reset(); target->prp_pushUndoRedoName(tr("Import SVG Animation")); - target->insertContained(0, imported); - imported->prp_setName(QFileInfo(path).completeBaseName()); - imported->planCenterPivotPosition(); - imported->updateAllBoxes(UpdateReason::userChange); + target->insertContained(0, wrapper); + wrapper->planCenterPivotPosition(); + wrapper->updateAllBoxes(UpdateReason::userChange); scene->requestUpdate(); mDocument.actionFinished(); AppSupport::setSettings("files", "recentImportDir", diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index accb1890c..0b4ce880f 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -252,10 +252,14 @@ void applyScalarTrack(QrealAnimator* animator, } animator->saveValueToKey(frames.at(i), values.at(i)); } - if (track.calcMode != "spline") { return; } - for (int i = 0; i + 1 < frames.size() && i < track.splines.size(); ++i) { + if (track.calcMode == "discrete") { return; } + const QList linearSpline{1./3, 1./3, 2./3, 2./3}; + for (int i = 0; i + 1 < frames.size(); ++i) { + const QList spline = + track.calcMode == "spline" && i < track.splines.size() ? + track.splines.at(i) : linearSpline; applySpline(animator, frames.at(i), frames.at(i + 1), - values.at(i), values.at(i + 1), track.splines.at(i)); + values.at(i), values.at(i + 1), spline); } } @@ -372,6 +376,8 @@ AnimationTrack sumTracks(const AnimationTrack& primary, } AnimationTrack result = primary; result.values.clear(); + QList primaryValues; + QList secondaryValues; for (int i = 0; i < primary.values.size(); ++i) { bool primaryOk = false; bool secondaryOk = false; @@ -379,8 +385,57 @@ AnimationTrack sumTracks(const AnimationTrack& primary, const qreal secondaryValue = secondary->values.at(i).trimmed().toDouble(&secondaryOk); if (!primaryOk || !secondaryOk) { return AnimationTrack(); } + primaryValues.append(primaryValue); + secondaryValues.append(secondaryValue); result.values.append(QString::number(primaryValue + secondaryValue)); } + + const QList linearSpline{1./3, 1./3, 2./3, 2./3}; + result.calcMode = "spline"; + result.splines.clear(); + for (int i = 0; i + 1 < result.values.size(); ++i) { + const auto primarySpline = + primary.calcMode == "spline" && i < primary.splines.size() ? + primary.splines.at(i) : linearSpline; + const auto secondarySpline = + secondary->calcMode == "spline" && i < secondary->splines.size() ? + secondary->splines.at(i) : linearSpline; + if (primarySpline.size() != 4 || secondarySpline.size() != 4) { + result.splines.append(linearSpline); + continue; + } + + const qreal primarySpan = primaryValues.at(i + 1) - primaryValues.at(i); + const qreal secondarySpan = + secondaryValues.at(i + 1) - secondaryValues.at(i); + const qreal resultSpan = primarySpan + secondarySpan; + if (qFuzzyIsNull(resultSpan)) { + result.splines.append(linearSpline); + continue; + } + + QList spline = primarySpline; + if (primary.calcMode != "spline" && secondary->calcMode == "spline") { + spline[0] = secondarySpline.at(0); + spline[2] = secondarySpline.at(2); + spline[1] = (primarySpan * spline.at(0) + + secondarySpan * secondarySpline.at(1)) / resultSpan; + spline[3] = (primarySpan * spline.at(2) + + secondarySpan * secondarySpline.at(3)) / resultSpan; + } else if (primary.calcMode == "spline" && + secondary->calcMode != "spline") { + spline[1] = (primarySpan * primarySpline.at(1) + + secondarySpan * spline.at(0)) / resultSpan; + spline[3] = (primarySpan * primarySpline.at(3) + + secondarySpan * spline.at(2)) / resultSpan; + } else { + spline[1] = (primarySpan * primarySpline.at(1) + + secondarySpan * secondarySpline.at(1)) / resultSpan; + spline[3] = (primarySpan * primarySpline.at(3) + + secondarySpan * secondarySpline.at(3)) / resultSpan; + } + result.splines.append(spline); + } return result; } @@ -535,7 +590,8 @@ void applyTrack(BoundingBox* box, const AnimationTrack& track, } // namespace qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, - Canvas* scene) + Canvas* scene, + const bool extendSceneTime) { if (!scene) { RuntimeThrow("SVG animation import requires an active scene"); } QFile file(filename); @@ -569,9 +625,10 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, int lastFrame = scene->getMaxFrame(); for (const AnimationTrack& track : tracks) { lastFrame = qMax(lastFrame, - qRound((track.begin + track.duration) * scene->getFps())); + qCeil((track.begin + track.duration) * + scene->getFps() - 0.000001)); } - if (lastFrame > scene->getMaxFrame()) { + if (extendSceneTime && lastFrame > scene->getMaxFrame()) { scene->setFrameRange({scene->getMinFrame(), lastFrame}); } return result; diff --git a/src/core/svganimationimporter.h b/src/core/svganimationimporter.h index b849b65b0..8d9602e4b 100644 --- a/src/core/svganimationimporter.h +++ b/src/core/svganimationimporter.h @@ -21,7 +21,8 @@ class Canvas; namespace ImportSVGAnimation { CORE_EXPORT qsptr loadSVGFile(const QString& filename, - Canvas* scene); + Canvas* scene, + bool extendSceneTime = true); } #endif // SVGANIMATIONIMPORTER_H From e97e438c022493f2539db183c19443b8694a2182 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sat, 13 Jun 2026 12:03:03 +0200 Subject: [PATCH 05/16] Improve animated SVG import controls --- .../GUI/Dialogs/svganimationimportdialog.cpp | 143 +++++++++++++----- .../GUI/Dialogs/svganimationimportdialog.h | 28 +++- src/app/GUI/mainwindow.cpp | 38 +++-- src/core/svganimationimporter.cpp | 139 ++++++++++++++++- src/core/svganimationimporter.h | 14 +- 5 files changed, 306 insertions(+), 56 deletions(-) diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.cpp b/src/app/GUI/Dialogs/svganimationimportdialog.cpp index 1190c5fac..58a561840 100644 --- a/src/app/GUI/Dialogs/svganimationimportdialog.cpp +++ b/src/app/GUI/Dialogs/svganimationimportdialog.cpp @@ -18,10 +18,13 @@ #include #include #include -#include +#include +#include #include #include #include +#include +#include #include namespace { @@ -45,47 +48,107 @@ QString sizeText(const QSizeF& size) { .arg(size.height(), 0, 'g', 8); } +QString durationText(const qreal seconds) { + return SVGAnimationImportDialog::tr("%1 seconds").arg(seconds, 0, 'g', 8); +} + +QTableWidgetItem* tableItem(const QString& text) { + return new QTableWidgetItem(text); +} + } SVGAnimationImportDialog::SVGAnimationImportDialog( - const QSizeF& svgSize, const QSize& sceneSize, QWidget* const parent) : + const QSizeF& svgSize, const SceneInfo& scene, + const ImportSVGAnimation::Analysis& analysis, QWidget* const parent) : QDialog(parent) { setWindowTitle(tr("Import SVG Animation")); const auto mainLayout = new QVBoxLayout(this); - const auto dimensions = new QFormLayout(); - dimensions->addRow(tr("Original SVG dimensions:"), new QLabel(sizeText(svgSize))); - dimensions->addRow(tr("Active scene dimensions:"), - new QLabel(sizeText(sceneSize))); - mainLayout->addLayout(dimensions); - - mOriginal = new QRadioButton(tr("Import at original scale"), this); - mProportional = new QRadioButton( - tr("Scale proportionally to the active scene"), this); - mStretch = new QRadioButton( - tr("Scale to the active scene and deform the original"), this); - mFitDimension = new QComboBox(this); - mFitDimension->addItem(tr("Fit width")); - mFitDimension->addItem(tr("Fit height")); - - mOriginal->setChecked(true); - mFitDimension->setEnabled(false); - connect(mProportional, &QRadioButton::toggled, - mFitDimension, &QComboBox::setEnabled); + const int sceneFrameCount = scene.lastFrame - scene.firstFrame + 1; + const qreal sceneDuration = scene.fps > 0 ? sceneFrameCount / scene.fps : 0; + const int svgFrames = qRound(analysis.duration * scene.fps); + + const auto comparison = new QTableWidget(4, 3, this); + comparison->setHorizontalHeaderLabels( + {tr("Property"), tr("SVG"), tr("Active scene")}); + comparison->verticalHeader()->hide(); + comparison->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::ResizeToContents); + comparison->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::Stretch); + comparison->horizontalHeader()->setSectionResizeMode( + 2, QHeaderView::Stretch); + comparison->setEditTriggers(QAbstractItemView::NoEditTriggers); + comparison->setSelectionMode(QAbstractItemView::NoSelection); + comparison->setFocusPolicy(Qt::NoFocus); + comparison->setShowGrid(false); + + comparison->setItem(0, 0, tableItem(tr("Dimensions"))); + comparison->setItem(0, 1, tableItem(sizeText(svgSize))); + comparison->setItem(0, 2, tableItem(sizeText(scene.size))); + comparison->setItem(1, 0, tableItem(tr("Duration"))); + comparison->setItem(1, 1, tableItem(durationText(analysis.duration))); + comparison->setItem(1, 2, tableItem(durationText(sceneDuration))); + comparison->setItem(2, 0, tableItem(tr("Frames"))); + comparison->setItem(2, 1, tableItem(tr("%1 at scene FPS").arg(svgFrames))); + comparison->setItem(2, 2, tableItem(tr("%1 to %2 (%3 total)") + .arg(scene.firstFrame) + .arg(scene.lastFrame) + .arg(sceneFrameCount))); + comparison->setItem(3, 0, tableItem(tr("Animation tracks"))); + comparison->setItem(3, 1, tableItem(tr("%1 supported of %2") + .arg(analysis.supportedTracks) + .arg(analysis.totalTracks))); + comparison->setItem(3, 2, tableItem(tr("Not applicable"))); + comparison->resizeRowsToContents(); + comparison->setMinimumWidth(460); + comparison->setFixedHeight(comparison->horizontalHeader()->height() + + comparison->verticalHeader()->length() + 2); + mainLayout->addWidget(comparison); + + if (!analysis.unsupported.isEmpty()) { + const auto warning = new QLabel( + tr("Unsupported animations will be skipped: %1") + .arg(analysis.unsupported.join(", ")), this); + warning->setWordWrap(true); + mainLayout->addWidget(warning); + } + + const auto scaleGroup = new QGroupBox(tr("Scale mode"), this); + const auto scaleLayout = new QVBoxLayout(scaleGroup); + mScaleMode = new QComboBox(scaleGroup); + mScaleMode->addItem(tr("Don't scale")); + mScaleMode->addItem(tr("Scale proportionally (width fit)")); + mScaleMode->addItem(tr("Scale proportionally (height fit)")); + mScaleMode->addItem(tr("Deform (fit to scene)")); const bool validSize = svgSize.width() > 0 && svgSize.height() > 0 && - sceneSize.width() > 0 && sceneSize.height() > 0; - mProportional->setEnabled(validSize); - mStretch->setEnabled(validSize); + scene.size.width() > 0 && scene.size.height() > 0; + if (!validSize) { + const auto model = qobject_cast(mScaleMode->model()); + for (int i = 1; model && i < mScaleMode->count(); ++i) { + model->item(i)->setEnabled(false); + } + } + + scaleLayout->addWidget(mScaleMode); + mainLayout->addWidget(scaleGroup); - mainLayout->addWidget(mOriginal); - mainLayout->addWidget(mProportional); - mainLayout->addWidget(mFitDimension); - mainLayout->addWidget(mStretch); + const auto structureGroup = new QGroupBox(tr("Import structure"), this); + const auto structureLayout = new QVBoxLayout(structureGroup); + mNamedGroup = new QRadioButton(tr("Grouped"), structureGroup); + mDirectObjects = new QRadioButton(tr("Ungrouped"), structureGroup); + mNamedGroup->setChecked(true); + structureLayout->addWidget(mNamedGroup); + structureLayout->addWidget(mDirectObjects); + mainLayout->addWidget(structureGroup); mExtendSceneTime = new QCheckBox( tr("Extend scene time if necessary"), this); - mExtendSceneTime->setChecked(true); + const bool extensionNeeded = analysis.duration > sceneDuration; + mExtendSceneTime->setChecked(extensionNeeded); + mExtendSceneTime->setEnabled(extensionNeeded); mainLayout->addWidget(mExtendSceneTime); const auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | @@ -96,12 +159,13 @@ SVGAnimationImportDialog::SVGAnimationImportDialog( } SVGAnimationImportDialog::ScaleMode SVGAnimationImportDialog::scaleMode() const { - if (mStretch->isChecked()) { return ScaleMode::stretch; } - if (mProportional->isChecked()) { - return mFitDimension->currentIndex() == 0 ? - ScaleMode::fitWidth : ScaleMode::fitHeight; - } - return ScaleMode::original; + return static_cast(mScaleMode->currentIndex()); +} + +SVGAnimationImportDialog::StructureMode +SVGAnimationImportDialog::structureMode() const { + return mDirectObjects->isChecked() ? + StructureMode::directObjects : StructureMode::namedGroup; } bool SVGAnimationImportDialog::extendSceneTime() const { @@ -109,12 +173,15 @@ bool SVGAnimationImportDialog::extendSceneTime() const { } bool SVGAnimationImportDialog::sExec( - const QString& path, const QSize& sceneSize, QSizeF& svgSize, - ScaleMode& scaleMode, bool& extendSceneTime, QWidget* const parent) { + const QString& path, const SceneInfo& scene, QSizeF& svgSize, + ScaleMode& scaleMode, StructureMode& structureMode, + bool& extendSceneTime, QWidget* const parent) { svgSize = readSVGSize(path); - SVGAnimationImportDialog dialog(svgSize, sceneSize, parent); + const auto analysis = ImportSVGAnimation::analyzeSVGFile(path); + SVGAnimationImportDialog dialog(svgSize, scene, analysis, parent); if (dialog.exec() != QDialog::Accepted) { return false; } scaleMode = dialog.scaleMode(); + structureMode = dialog.structureMode(); extendSceneTime = dialog.extendSceneTime(); return true; } diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.h b/src/app/GUI/Dialogs/svganimationimportdialog.h index 251a0a5f6..107b8c449 100644 --- a/src/app/GUI/Dialogs/svganimationimportdialog.h +++ b/src/app/GUI/Dialogs/svganimationimportdialog.h @@ -17,12 +17,21 @@ #include #include +#include "svganimationimporter.h" + class QComboBox; class QCheckBox; class QRadioButton; class SVGAnimationImportDialog : public QDialog { public: + struct SceneInfo { + QSize size; + int firstFrame; + int lastFrame; + qreal fps; + }; + enum class ScaleMode { original, fitWidth, @@ -30,24 +39,31 @@ class SVGAnimationImportDialog : public QDialog { stretch }; - SVGAnimationImportDialog(const QSizeF& svgSize, const QSize& sceneSize, + enum class StructureMode { + namedGroup, + directObjects + }; + + SVGAnimationImportDialog(const QSizeF& svgSize, const SceneInfo& scene, + const ImportSVGAnimation::Analysis& analysis, QWidget* const parent); ScaleMode scaleMode() const; + StructureMode structureMode() const; bool extendSceneTime() const; - static bool sExec(const QString& path, const QSize& sceneSize, + static bool sExec(const QString& path, const SceneInfo& scene, QSizeF& svgSize, ScaleMode& scaleMode, + StructureMode& structureMode, bool& extendSceneTime, QWidget* const parent); private: static QSizeF readSVGSize(const QString& path); - QRadioButton* mOriginal; - QRadioButton* mProportional; - QRadioButton* mStretch; - QComboBox* mFitDimension; + QComboBox* mScaleMode; + QRadioButton* mNamedGroup; + QRadioButton* mDirectObjects; QCheckBox* mExtendSceneTime; }; diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index f1ebdc563..3be9c9246 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -1310,10 +1310,15 @@ void MainWindow::importSVGAnimation() Canvas* const scene = mDocument.fActiveScene; QSizeF svgSize; SVGAnimationImportDialog::ScaleMode scaleMode; + SVGAnimationImportDialog::StructureMode structureMode; bool extendSceneTime = true; + const SVGAnimationImportDialog::SceneInfo sceneInfo{ + QSize(scene->getCanvasWidth(), scene->getCanvasHeight()), + scene->getMinFrame(), scene->getMaxFrame(), scene->getFps() + }; if (!SVGAnimationImportDialog::sExec( - path, QSize(scene->getCanvasWidth(), scene->getCanvasHeight()), - svgSize, scaleMode, extendSceneTime, this)) { + path, sceneInfo, svgSize, scaleMode, structureMode, + extendSceneTime, this)) { return; } @@ -1330,18 +1335,33 @@ void MainWindow::importSVGAnimation() ContainerBox* const target = scene->getCurrentGroup(); auto block = scene->blockUndoRedo(); + bool technicalRoot = false; const auto imported = ImportSVGAnimation::loadSVGFile( - path, scene, extendSceneTime); + path, scene, extendSceneTime, &technicalRoot); if (!imported) { return; } - const auto wrapper = enve::make_shared( - QFileInfo(path).completeBaseName(), eBoxType::group); - wrapper->addContained(imported); - wrapper->getBoxTransformAnimator()->setScale(scaleX, scaleY); + const QString importName = QFileInfo(path).completeBaseName(); block.reset(); target->prp_pushUndoRedoName(tr("Import SVG Animation")); + + const bool directObjects = structureMode == + SVGAnimationImportDialog::StructureMode::directObjects; + qsptr wrapper; + if (technicalRoot) { + wrapper = qSharedPointerCast(imported); + wrapper->prp_setName(importName); + } else { + wrapper = enve::make_shared(importName, + eBoxType::group); + wrapper->addContained(imported); + } + wrapper->getBoxTransformAnimator()->setScale(scaleX, scaleY); target->insertContained(0, wrapper); - wrapper->planCenterPivotPosition(); - wrapper->updateAllBoxes(UpdateReason::userChange); + if (directObjects) { + wrapper->ungroupKeepTransform_k(); + } else { + wrapper->planCenterPivotPosition(); + wrapper->updateAllBoxes(UpdateReason::userChange); + } scene->requestUpdate(); mDocument.actionFinished(); AppSupport::setSettings("files", "recentImportDir", diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 0b4ce880f..8e9cedc97 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -52,8 +52,97 @@ struct AnimationTrack { const AnimationTrack* findTrack(const QList& tracks, const QString& targetId, const QString& attribute); +qreal parseClock(const QString& value, bool* ok = nullptr); -qreal parseClock(const QString& value, bool* ok = nullptr) +bool hasAnimationValues(const QDomElement& animation) +{ + const int valuesCount = animation.attribute("values") + .split(';', Qt::SkipEmptyParts).size(); + return valuesCount >= 2 || + (!animation.attribute("from").isEmpty() && + !animation.attribute("to").isEmpty()); +} + +bool supportsAnimation(const QDomElement& animation) +{ + if (animation.tagName() != "animate" && + animation.tagName() != "animateTransform") { + return false; + } + + bool durationOk = false; + parseClock(animation.attribute("dur"), &durationOk); + if (!durationOk || !hasAnimationValues(animation)) { return false; } + + const QDomElement target = animation.parentNode().toElement(); + const QString tag = target.tagName().toLower(); + const QString attribute = animation.attribute("attributeName"); + if (attribute == "opacity") { return true; } + if (attribute == "transform") { + const QString type = animation.attribute("type"); + return type == "translate" || type == "scale" || type == "rotate" || + type == "skewX" || type == "skewY"; + } + + const QStringList paintedTags{ + "circle", "ellipse", "rect", "path", "polygon", "polyline", "line" + }; + if (paintedTags.contains(tag) && + (attribute == "fill" || attribute == "fill-opacity" || + attribute == "stroke" || attribute == "stroke-opacity" || + attribute == "stroke-width")) { + return true; + } + if ((tag == "circle" || tag == "ellipse") && + (attribute == "cx" || attribute == "cy" || attribute == "rx" || + attribute == "ry" || attribute == "r")) { + return true; + } + if (tag == "rect" && + (attribute == "x" || attribute == "y" || attribute == "width" || + attribute == "height" || attribute == "rx" || attribute == "ry")) { + return true; + } + return tag == "path" && attribute == "d"; +} + +QString unsupportedDescription(const QDomElement& animation) +{ + const QString attribute = animation.attribute("attributeName"); + if (attribute.isEmpty()) { return animation.tagName(); } + if (animation.tagName() == "animateTransform") { + return attribute + ":" + animation.attribute("type"); + } + return attribute; +} + +bool isImportedRootElement(const QDomElement& element) +{ + const QString tag = element.tagName().toLower(); + return tag == "g" || tag == "text" || tag == "circle" || + tag == "ellipse" || tag == "rect" || tag == "path" || + tag == "polyline" || tag == "polygon" || tag == "line"; +} + +bool hasTechnicalRoot(const QDomDocument& document) +{ + int importedChildren = 0; + const QDomElement root = document.firstChildElement("svg"); + if (root.hasAttribute("transform")) { return false; } + for (QDomElement child = root.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + if (child.tagName() == "animate" || + child.tagName() == "animateTransform") { + return false; + } + if (isImportedRootElement(child) && ++importedChildren > 1) { + return true; + } + } + return false; +} + +qreal parseClock(const QString& value, bool* ok) { QString text = value.trimmed(); qreal multiplier = 1; @@ -589,9 +678,50 @@ void applyTrack(BoundingBox* box, const AnimationTrack& track, } // namespace +ImportSVGAnimation::Analysis ImportSVGAnimation::analyzeSVGFile( + const QString& filename) +{ + QFile file(filename); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + RuntimeThrow("Cannot open file " + filename); + } + QDomDocument document; + if (!document.setContent(&file)) { + RuntimeThrow("Cannot parse SVG animation file " + filename); + } + + Analysis result; + const QStringList tags{"animate", "animateTransform", "animateMotion", "set"}; + for (const QString& tag : tags) { + const QDomNodeList nodes = document.elementsByTagName(tag); + for (int i = 0; i < nodes.count(); ++i) { + const QDomElement animation = nodes.at(i).toElement(); + ++result.totalTracks; + if (supportsAnimation(animation)) { + ++result.supportedTracks; + } else { + result.unsupported.append(unsupportedDescription(animation)); + } + bool durationOk = false; + const qreal duration = parseClock(animation.attribute("dur"), + &durationOk); + bool beginOk = false; + const qreal begin = parseClock(animation.attribute("begin", "0s"), + &beginOk); + if (durationOk) { + result.duration = qMax(result.duration, + duration + (beginOk ? begin : 0)); + } + } + } + result.unsupported.removeDuplicates(); + return result; +} + qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, Canvas* scene, - const bool extendSceneTime) + const bool extendSceneTime, + bool* const technicalRoot) { if (!scene) { RuntimeThrow("SVG animation import requires an active scene"); } QFile file(filename); @@ -602,11 +732,16 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, if (!document.setContent(&file)) { RuntimeThrow("Cannot parse SVG animation file " + filename); } + const bool documentHasTechnicalRoot = hasTechnicalRoot(document); const auto tracks = collectTracks(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; const auto result = ImportSVG::loadSVGFile(document, gradientCreator); if (!result) { return nullptr; } + if (technicalRoot) { + *technicalRoot = documentHasTechnicalRoot && + enve_cast(result.get()); + } QHash targets; for (const AnimationTrack& track : tracks) { diff --git a/src/core/svganimationimporter.h b/src/core/svganimationimporter.h index 8d9602e4b..bacc98ed4 100644 --- a/src/core/svganimationimporter.h +++ b/src/core/svganimationimporter.h @@ -16,13 +16,25 @@ #include "smartPointers/selfref.h" +#include + class BoundingBox; class Canvas; namespace ImportSVGAnimation { + struct CORE_EXPORT Analysis { + int totalTracks = 0; + int supportedTracks = 0; + qreal duration = 0; + QStringList unsupported; + }; + + CORE_EXPORT Analysis analyzeSVGFile(const QString& filename); + CORE_EXPORT qsptr loadSVGFile(const QString& filename, Canvas* scene, - bool extendSceneTime = true); + bool extendSceneTime = true, + bool* technicalRoot = nullptr); } #endif // SVGANIMATIONIMPORTER_H From 1bbe332aff6dce5dcec674d7a41869a2d75209d8 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sat, 13 Jun 2026 12:59:56 +0200 Subject: [PATCH 06/16] Preserve animated SVG transforms --- src/core/svganimationimporter.cpp | 100 +++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 8e9cedc97..c7abd9ea1 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -15,7 +15,10 @@ #include #include +#include #include +#include +#include #include "Animators/qpointfanimator.h" #include "Animators/qrealanimator.h" @@ -26,6 +29,7 @@ #include "Animators/SmartPath/smartpathanimator.h" #include "Animators/transformanimator.h" #include "Boxes/boundingbox.h" +#include "Boxes/boxrenderdata.h" #include "Boxes/circle.h" #include "Boxes/containerbox.h" #include "Boxes/rectangle.h" @@ -182,6 +186,76 @@ QList parseSemicolonNumbers(const QString& value) return result; } +bool parseTransformList(const QString& value, QMatrix& result) +{ + static const QRegularExpression function( + QStringLiteral("([A-Za-z]+)\\s*\\(([^)]*)\\)")); + auto match = function.globalMatch(value); + int parsedEnd = 0; + bool parsedAny = false; + while (match.hasNext()) { + const auto current = match.next(); + if (!value.mid(parsedEnd, current.capturedStart() - parsedEnd) + .trimmed().isEmpty()) { + return false; + } + parsedEnd = current.capturedEnd(); + parsedAny = true; + + const QString name = current.captured(1); + const QList values = parseNumbers(current.captured(2)); + if (name.compare("translate", Qt::CaseInsensitive) == 0 && + (values.size() == 1 || values.size() == 2)) { + result.translate(values.at(0), + values.size() == 2 ? values.at(1) : 0); + } else if (name.compare("scale", Qt::CaseInsensitive) == 0 && + (values.size() == 1 || values.size() == 2)) { + result.scale(values.at(0), + values.size() == 2 ? values.at(1) : values.at(0)); + } else if (name.compare("rotate", Qt::CaseInsensitive) == 0 && + (values.size() == 1 || values.size() == 3)) { + if (values.size() == 3) { + result.translate(values.at(1), values.at(2)); + } + result.rotate(values.at(0)); + if (values.size() == 3) { + result.translate(-values.at(1), -values.at(2)); + } + } else if (name.compare("skewX", Qt::CaseInsensitive) == 0 && + values.size() == 1) { + result.shear(qTan(qDegreesToRadians(values.at(0))), 0); + } else if (name.compare("skewY", Qt::CaseInsensitive) == 0 && + values.size() == 1) { + result.shear(0, qTan(qDegreesToRadians(values.at(0)))); + } else { + return false; + } + } + return parsedAny && value.mid(parsedEnd).trimmed().isEmpty(); +} + +void normalizeStaticTransforms(QDomElement element) +{ + if (element.hasAttribute("transform")) { + QMatrix matrix; + if (parseTransformList(element.attribute("transform"), matrix)) { + element.setAttribute( + "transform", + QStringLiteral("matrix(%1 %2 %3 %4 %5 %6)") + .arg(matrix.m11(), 0, 'g', 15) + .arg(matrix.m12(), 0, 'g', 15) + .arg(matrix.m21(), 0, 'g', 15) + .arg(matrix.m22(), 0, 'g', 15) + .arg(matrix.dx(), 0, 'g', 15) + .arg(matrix.dy(), 0, 'g', 15)); + } + } + for (QDomElement child = element.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + normalizeStaticTransforms(child); + } +} + bool parseColor(const QString& value, QColor& color) { const QString text = value.trimmed(); @@ -226,6 +300,22 @@ BoundingBox* findBox(BoundingBox* box, const QString& name) return nullptr; } +void keepSVGTransformOrigin(BoundingBox* const box) +{ + if (!box) { return; } + + // ImportSVG plans centered pivots for imported boxes. SVG transforms use + // the coordinate-system origin unless an explicit center is provided. + // Consume the pending centering before adding animated transforms. + const auto renderData = box->createRenderData(0); + if (renderData) { + renderData->fRelBoundingRectSet = true; + renderData->fRelBoundingRect = box->getRelBoundingRect(); + box->updateCurrentPreviewDataFromRenderData(renderData.get()); + } + box->setPivotRelPos(QPointF()); +} + QString ensureTargetId(QDomElement& target, int& nextId) { const QString existing = target.attribute("data-friction-animation-target"); @@ -733,6 +823,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, RuntimeThrow("Cannot parse SVG animation file " + filename); } const bool documentHasTechnicalRoot = hasTechnicalRoot(document); + normalizeStaticTransforms(document.documentElement()); const auto tracks = collectTracks(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; @@ -744,9 +835,16 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, } QHash targets; + QSet preparedTransformTargets; for (const AnimationTrack& track : tracks) { if (!targets.contains(track.targetId)) { - targets.insert(track.targetId, findBox(result.get(), track.targetId)); + BoundingBox* const target = findBox(result.get(), track.targetId); + targets.insert(track.targetId, target); + } + if (track.attribute == "transform" && + !preparedTransformTargets.contains(track.targetId)) { + keepSVGTransformOrigin(targets.value(track.targetId)); + preparedTransformTargets.insert(track.targetId); } applyTrack(targets.value(track.targetId), track, tracks, scene->getFps()); } From 3cda7fa8564fa3178b54e9aef13e263b933455c8 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 17:37:58 +0200 Subject: [PATCH 07/16] Preserve imported SVG text styling --- src/core/svganimationimporter.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index c7abd9ea1..5b7329023 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -256,6 +256,31 @@ void normalizeStaticTransforms(QDomElement element) } } +void normalizeTextPresentationAttributes(QDomElement element) +{ + static const QStringList textAttributes{ + "font-family", "font-size", "font-style", "font-weight", "text-anchor" + }; + QStringList presentationStyles; + for (const QString& attribute : textAttributes) { + if (element.hasAttribute(attribute)) { + presentationStyles.append(attribute + ':' + + element.attribute(attribute)); + } + } + if (!presentationStyles.isEmpty()) { + QString style = element.attribute("style").trimmed(); + if (!style.isEmpty() && !style.startsWith(';')) { + style.prepend(';'); + } + element.setAttribute("style", presentationStyles.join(';') + style); + } + for (QDomElement child = element.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + normalizeTextPresentationAttributes(child); + } +} + bool parseColor(const QString& value, QColor& color) { const QString text = value.trimmed(); @@ -823,6 +848,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, RuntimeThrow("Cannot parse SVG animation file " + filename); } const bool documentHasTechnicalRoot = hasTechnicalRoot(document); + normalizeTextPresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); const auto tracks = collectTracks(document); From 93f518d80397bac52988910d48fb9761d43b923e Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 17:43:35 +0200 Subject: [PATCH 08/16] Promote composited SVG groups to layers --- src/core/svganimationimporter.cpp | 99 +++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 5b7329023..388d93984 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -53,6 +53,11 @@ struct AnimationTrack { qreal duration = 0; }; +struct LayerCandidate { + QString targetId; + QString targetName; +}; + const AnimationTrack* findTrack(const QList& tracks, const QString& targetId, const QString& attribute); @@ -256,13 +261,14 @@ void normalizeStaticTransforms(QDomElement element) } } -void normalizeTextPresentationAttributes(QDomElement element) +void normalizePresentationAttributes(QDomElement element) { - static const QStringList textAttributes{ - "font-family", "font-size", "font-style", "font-weight", "text-anchor" + static const QStringList presentationAttributes{ + "font-family", "font-size", "font-style", "font-weight", "text-anchor", + "opacity" }; QStringList presentationStyles; - for (const QString& attribute : textAttributes) { + for (const QString& attribute : presentationAttributes) { if (element.hasAttribute(attribute)) { presentationStyles.append(attribute + ':' + element.attribute(attribute)); @@ -277,7 +283,7 @@ void normalizeTextPresentationAttributes(QDomElement element) } for (QDomElement child = element.firstChildElement(); !child.isNull(); child = child.nextSiblingElement()) { - normalizeTextPresentationAttributes(child); + normalizePresentationAttributes(child); } } @@ -355,6 +361,77 @@ QString ensureTargetId(QDomElement& target, int& nextId) return id; } +QString styleProperty(const QDomElement& element, const QString& property) +{ + for (const QString& declaration : + element.attribute("style").split(';', Qt::SkipEmptyParts)) { + const int separator = declaration.indexOf(':'); + if (separator < 0) { continue; } + if (declaration.left(separator).trimmed() == property) { + return declaration.mid(separator + 1).trimmed(); + } + } + return QString(); +} + +bool groupNeedsLayer(const QDomElement& group) +{ + for (QDomElement child = group.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + const QString tag = child.tagName(); + if (tag == "animate" || tag == "animateTransform" || + tag == "animateMotion" || tag == "set") { + return true; + } + } + + QString opacity = group.attribute("opacity"); + if (opacity.isEmpty()) { opacity = styleProperty(group, "opacity"); } + bool opacityOk = false; + const qreal opacityValue = opacity.toDouble(&opacityOk); + if (opacityOk && !qFuzzyCompare(opacityValue + 1., 2.)) { return true; } + + static const QStringList rasterProperties{ + "filter", "mask", "clip-path", "mix-blend-mode", "isolation" + }; + for (const QString& property : rasterProperties) { + QString value = group.attribute(property); + if (value.isEmpty()) { value = styleProperty(group, property); } + if (!value.isEmpty() && value != "none" && value != "normal" && + value != "auto") { + return true; + } + } + return false; +} + +QList collectLayerCandidates(QDomDocument& document) +{ + QList result; + int nextId = 0; + const QDomNodeList groups = document.elementsByTagName("g"); + for (int i = 0; i < groups.count(); ++i) { + QDomElement group = groups.at(i).toElement(); + if (!groupNeedsLayer(group)) { continue; } + + LayerCandidate candidate; + candidate.targetId = + group.attribute("data-friction-animation-target"); + candidate.targetName = + group.attribute("data-friction-animation-name"); + if (candidate.targetId.isEmpty()) { + candidate.targetId = + QString("__friction_svg_layer_%1").arg(nextId++); + candidate.targetName = + group.attribute("inkscape:label", + group.attribute("id", "Group")); + group.setAttribute("inkscape:label", candidate.targetId); + } + result.append(candidate); + } + return result; +} + QList collectTracks(QDomDocument& document) { QList result; @@ -848,10 +925,11 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, RuntimeThrow("Cannot parse SVG animation file " + filename); } const bool documentHasTechnicalRoot = hasTechnicalRoot(document); - normalizeTextPresentationAttributes(document.documentElement()); + normalizePresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); const auto tracks = collectTracks(document); + const auto layerCandidates = collectLayerCandidates(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; const auto result = ImportSVG::loadSVGFile(document, gradientCreator); if (!result) { return nullptr; } @@ -874,6 +952,15 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, } applyTrack(targets.value(track.targetId), track, tracks, scene->getFps()); } + for (const LayerCandidate& candidate : layerCandidates) { + const auto group = enve_cast( + findBox(result.get(), candidate.targetId)); + if (!group) { continue; } + group->promoteToLayer(); + if (!candidate.targetName.isEmpty()) { + group->prp_setName(candidate.targetName); + } + } for (const AnimationTrack& track : tracks) { BoundingBox* const target = targets.value(track.targetId); if (target && !track.targetName.isEmpty() && From 5bbd4594a110c0fbcbe7df5d68edaad5d42e3734 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 17:53:53 +0200 Subject: [PATCH 09/16] Import SVG dashed strokes --- src/core/svganimationimporter.cpp | 141 ++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 388d93984..9d51742d8 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -34,6 +34,7 @@ #include "Boxes/containerbox.h" #include "Boxes/rectangle.h" #include "Boxes/smartvectorpath.h" +#include "PathEffects/dashpatheffect.h" #include "canvas.h" #include "exceptions.h" #include "svgimporter.h" @@ -58,6 +59,12 @@ struct LayerCandidate { QString targetName; }; +struct DashCandidate { + QString targetId; + QString targetName; + qreal size; +}; + const AnimationTrack* findTrack(const QList& tracks, const QString& targetId, const QString& attribute); @@ -374,6 +381,124 @@ QString styleProperty(const QDomElement& element, const QString& property) return QString(); } +QString presentationProperty(const QDomElement& element, + const QString& property) +{ + const QString styled = styleProperty(element, property); + return styled.isEmpty() ? element.attribute(property) : styled; +} + +bool parseAbsoluteSVGLength(const QString& value, qreal& result) +{ + static const QRegularExpression length( + QStringLiteral("^\\s*" + "([+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)" + "(?:[eE][+-]?\\d+)?)" + "\\s*(px|pt|pc|mm|cm|in)?\\s*$"), + QRegularExpression::CaseInsensitiveOption); + const auto match = length.match(value); + if (!match.hasMatch()) { return false; } + + bool ok = false; + result = match.captured(1).toDouble(&ok); + if (!ok || result < 0) { return false; } + + const QString unit = match.captured(2).toLower(); + if (unit == "pt") { + result *= 96. / 72.; + } else if (unit == "pc") { + result *= 16.; + } else if (unit == "mm") { + result *= 96. / 25.4; + } else if (unit == "cm") { + result *= 96. / 2.54; + } else if (unit == "in") { + result *= 96.; + } + return true; +} + +bool equivalentDashSize(const QString& value, qreal& result) +{ + const QString text = value.trimmed(); + if (text.isEmpty() || text.compare("none", Qt::CaseInsensitive) == 0) { + return false; + } + + qreal total = 0; + int count = 0; + for (const QString& part : + text.split(QRegularExpression("[,\\s]+"), Qt::SkipEmptyParts)) { + qreal interval = 0; + if (!parseAbsoluteSVGLength(part, interval)) { return false; } + total += interval; + ++count; + } + if (!count || qFuzzyIsNull(total)) { return false; } + + // Friction's Dash effect uses one equal dash/gap size. The average SVG + // interval preserves exact symmetric patterns and approximates the period + // of patterns that Friction cannot represent directly. + result = total / count; + return result >= 0.1; +} + +bool isDashTarget(const QDomElement& element) +{ + static const QStringList pathTags{ + "circle", "ellipse", "rect", "path", "polygon", "polyline", "line", + "text" + }; + return pathTags.contains(element.tagName().toLower()); +} + +void collectDashCandidates(QDomElement element, + const QString& inheritedDashArray, + int& nextId, + QList& result) +{ + QString dashArray = presentationProperty(element, "stroke-dasharray"); + if (dashArray.isEmpty() || + dashArray.compare("inherit", Qt::CaseInsensitive) == 0) { + dashArray = inheritedDashArray; + } else if (dashArray.compare("none", Qt::CaseInsensitive) == 0) { + dashArray.clear(); + } + + qreal dashSize = 0; + if (isDashTarget(element) && equivalentDashSize(dashArray, dashSize)) { + DashCandidate candidate; + candidate.targetId = + element.attribute("data-friction-animation-target"); + candidate.targetName = + element.attribute("data-friction-animation-name"); + if (candidate.targetId.isEmpty()) { + candidate.targetId = + QString("__friction_svg_dash_%1").arg(nextId++); + candidate.targetName = + element.attribute("inkscape:label", + element.attribute("id", + element.tagName())); + element.setAttribute("inkscape:label", candidate.targetId); + } + candidate.size = dashSize; + result.append(candidate); + } + + for (QDomElement child = element.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + collectDashCandidates(child, dashArray, nextId, result); + } +} + +QList collectDashCandidates(QDomDocument& document) +{ + QList result; + int nextId = 0; + collectDashCandidates(document.documentElement(), QString(), nextId, result); + return result; +} + bool groupNeedsLayer(const QDomElement& group) { for (QDomElement child = group.firstChildElement(); !child.isNull(); @@ -930,6 +1055,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, const auto tracks = collectTracks(document); const auto layerCandidates = collectLayerCandidates(document); + const auto dashCandidates = collectDashCandidates(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; const auto result = ImportSVG::loadSVGFile(document, gradientCreator); if (!result) { return nullptr; } @@ -952,6 +1078,21 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, } applyTrack(targets.value(track.targetId), track, tracks, scene->getFps()); } + for (const DashCandidate& candidate : dashCandidates) { + const auto path = enve_cast( + findBox(result.get(), candidate.targetId)); + if (!path) { continue; } + const auto effect = enve::make_shared(); + const auto size = + effect->ca_getFirstDescendantWithName("size"); + if (!size) { continue; } + size->setCurrentBaseValue(candidate.size); + path->addOutlineBasePathEffect(effect); + path->setOutlineBaseEffectsEnabled(true); + if (!candidate.targetName.isEmpty()) { + path->prp_setName(candidate.targetName); + } + } for (const LayerCandidate& candidate : layerCandidates) { const auto group = enve_cast( findBox(result.get(), candidate.targetId)); From 87caefed3909d1b79c0e404985ef2c54c3222850 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 18:03:35 +0200 Subject: [PATCH 10/16] Expand repeating SVG animation tracks --- src/core/svganimationimporter.cpp | 73 ++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 9d51742d8..14daeb531 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -52,6 +52,7 @@ struct AnimationTrack { QList> splines; qreal begin = 0; qreal duration = 0; + bool repeatIndefinitely = false; }; struct LayerCandidate { @@ -575,6 +576,9 @@ QList collectTracks(QDomDocument& document) track.attribute = animation.attribute("attributeName"); track.type = animation.attribute("type"); track.calcMode = animation.attribute("calcMode", "linear"); + track.repeatIndefinitely = + animation.attribute("repeatCount") + .compare("indefinite", Qt::CaseInsensitive) == 0; bool durationOk = false; track.duration = parseClock(animation.attribute("dur"), &durationOk); @@ -614,6 +618,73 @@ QList collectTracks(QDomDocument& document) return result; } +AnimationTrack repeatedTrack(const AnimationTrack& source, + const int repetitions) +{ + if (!source.repeatIndefinitely || repetitions <= 1) { return source; } + + AnimationTrack result = source; + result.duration *= repetitions; + result.times.clear(); + result.values.clear(); + result.splines.clear(); + result.repeatIndefinitely = false; + + qreal rotationCycle = 0; + if (source.attribute == "transform" && source.type == "rotate") { + const auto first = parseNumbers(source.values.first()); + const auto last = parseNumbers(source.values.last()); + if (!first.isEmpty() && !last.isEmpty()) { + rotationCycle = last.first() - first.first(); + } + } + + for (int repetition = 0; repetition < repetitions; ++repetition) { + for (int valueId = 0; valueId < source.values.size(); ++valueId) { + if (repetition > 0 && valueId == 0) { continue; } + + result.times.append((repetition + source.times.at(valueId)) / + repetitions); + if (!qFuzzyIsNull(rotationCycle)) { + const auto numbers = parseNumbers(source.values.at(valueId)); + if (numbers.isEmpty()) { return source; } + QStringList shifted; + shifted.append(QString::number( + numbers.first() + + repetition * rotationCycle)); + for (int numberId = 1; numberId < numbers.size(); ++numberId) { + shifted.append(QString::number(numbers.at(numberId))); + } + result.values.append(shifted.join(' ')); + } else { + result.values.append(source.values.at(valueId)); + } + } + result.splines.append(source.splines); + } + return result; +} + +QList expandIndefiniteTracks( + const QList& tracks) +{ + qreal end = 0; + for (const AnimationTrack& track : tracks) { + end = qMax(end, track.begin + track.duration); + } + + QList result; + result.reserve(tracks.size()); + for (const AnimationTrack& track : tracks) { + const qreal available = end - track.begin; + const int repetitions = qMax(1, qFloor( + available / track.duration + + 0.000001)); + result.append(repeatedTrack(track, repetitions)); + } + return result; +} + void applySpline(QrealAnimator* animator, const int frame0, const int frame1, const qreal value0, const qreal value1, @@ -1053,7 +1124,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, normalizePresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); - const auto tracks = collectTracks(document); + const auto tracks = expandIndefiniteTracks(collectTracks(document)); const auto layerCandidates = collectLayerCandidates(document); const auto dashCandidates = collectDashCandidates(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; From 2ba993e807a1888efe1ccd8245b592c5c23cc8a0 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 18:03:49 +0200 Subject: [PATCH 11/16] Apply imported SVG dashes as path effects --- src/core/svganimationimporter.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index 14daeb531..cb169a656 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -1158,8 +1158,8 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, effect->ca_getFirstDescendantWithName("size"); if (!size) { continue; } size->setCurrentBaseValue(candidate.size); - path->addOutlineBasePathEffect(effect); - path->setOutlineBaseEffectsEnabled(true); + path->addPathEffect(effect); + path->setPathEffectsEnabled(true); if (!candidate.targetName.isEmpty()) { path->prp_setName(candidate.targetName); } From 21313145562dcf8e798d850e876ce43c88e8cf47 Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Sun, 14 Jun 2026 18:23:20 +0200 Subject: [PATCH 12/16] Import SVG layer blend modes --- src/core/svganimationimporter.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index cb169a656..f34a054b3 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -58,6 +58,7 @@ struct AnimationTrack { struct LayerCandidate { QString targetId; QString targetName; + SkBlendMode blendMode = SkBlendMode::kSrcOver; }; struct DashCandidate { @@ -531,6 +532,31 @@ bool groupNeedsLayer(const QDomElement& group) return false; } +SkBlendMode svgBlendMode(const QDomElement& element) +{ + const QString value = + presentationProperty(element, "mix-blend-mode").trimmed().toLower(); + if (value == "multiply") { return SkBlendMode::kMultiply; } + if (value == "screen") { return SkBlendMode::kScreen; } + if (value == "overlay") { return SkBlendMode::kOverlay; } + if (value == "darken") { return SkBlendMode::kDarken; } + if (value == "lighten") { return SkBlendMode::kLighten; } + if (value == "color-dodge") { return SkBlendMode::kColorDodge; } + if (value == "color-burn") { return SkBlendMode::kColorBurn; } + if (value == "hard-light") { return SkBlendMode::kHardLight; } + if (value == "soft-light") { return SkBlendMode::kSoftLight; } + if (value == "difference") { return SkBlendMode::kDifference; } + if (value == "exclusion") { return SkBlendMode::kExclusion; } + if (value == "hue") { return SkBlendMode::kHue; } + if (value == "saturation") { return SkBlendMode::kSaturation; } + if (value == "color") { return SkBlendMode::kColor; } + if (value == "luminosity") { return SkBlendMode::kLuminosity; } + if (value == "plus-lighter" || value == "plus") { + return SkBlendMode::kPlus; + } + return SkBlendMode::kSrcOver; +} + QList collectLayerCandidates(QDomDocument& document) { QList result; @@ -553,6 +579,7 @@ QList collectLayerCandidates(QDomDocument& document) group.attribute("id", "Group")); group.setAttribute("inkscape:label", candidate.targetId); } + candidate.blendMode = svgBlendMode(group); result.append(candidate); } return result; @@ -1169,6 +1196,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, findBox(result.get(), candidate.targetId)); if (!group) { continue; } group->promoteToLayer(); + group->setBlendModeSk(candidate.blendMode); if (!candidate.targetName.isEmpty()) { group->prp_setName(candidate.targetName); } From 049a0bd8658e8d8cd4961f8bb5c503d78a7c381a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Mon, 15 Jun 2026 10:16:58 +0200 Subject: [PATCH 13/16] Import SVG masks as compositing layers --- src/core/svganimationimporter.cpp | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index f34a054b3..f9419c8fe 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -67,6 +67,12 @@ struct DashCandidate { qreal size; }; +struct MaskCandidate { + QString targetId; + QString targetName; + SkBlendMode blendMode; +}; + const AnimationTrack* findTrack(const QList& tracks, const QString& targetId, const QString& attribute); @@ -390,6 +396,78 @@ QString presentationProperty(const QDomElement& element, return styled.isEmpty() ? element.attribute(property) : styled; } +QString urlReferenceId(const QString& value) +{ + static const QRegularExpression url( + QStringLiteral("^\\s*url\\(\\s*#([^\\s)]+)\\s*\\)\\s*$"), + QRegularExpression::CaseInsensitiveOption); + const auto match = url.match(value); + return match.hasMatch() ? match.captured(1) : QString(); +} + +bool isFullMaskBackground(const QDomElement& element) +{ + if (element.tagName().toLower() != "rect") { return false; } + return element.attribute("width").trimmed() == "100%" && + element.attribute("height").trimmed() == "100%"; +} + +QList materializeMasks(QDomDocument& document) +{ + QList result; + QHash masks; + const QDomNodeList maskNodes = document.elementsByTagName("mask"); + for (int i = 0; i < maskNodes.count(); ++i) { + const QDomElement mask = maskNodes.at(i).toElement(); + if (!mask.attribute("id").isEmpty()) { + masks.insert(mask.attribute("id"), mask); + } + } + + int nextId = 0; + const QDomNodeList groups = document.elementsByTagName("g"); + for (int i = 0; i < groups.count(); ++i) { + QDomElement group = groups.at(i).toElement(); + QString maskValue = presentationProperty(group, "mask"); + const QString maskId = urlReferenceId(maskValue); + if (maskId.isEmpty() || !masks.contains(maskId)) { continue; } + + const QDomElement mask = masks.value(maskId); + QList content; + bool hasFullBackground = false; + for (QDomElement child = mask.firstChildElement(); !child.isNull(); + child = child.nextSiblingElement()) { + if (isFullMaskBackground(child)) { + hasFullBackground = true; + } else { + content.append(child); + } + } + if (content.isEmpty()) { continue; } + + const QString targetId = + QString("__friction_svg_mask_%1").arg(nextId++); + const QString targetName = maskId; + QDomElement importedMask = document.createElement("g"); + for (const QDomElement& child : content) { + importedMask.appendChild(child.cloneNode(true)); + } + // ImportSVG flattens single-child groups. A non-element node preserves + // the container without creating an extra visible object. + importedMask.appendChild(document.createComment("friction-mask-layer")); + importedMask.setAttribute("inkscape:label", targetId); + group.appendChild(importedMask); + + MaskCandidate candidate; + candidate.targetId = targetId; + candidate.targetName = targetName; + candidate.blendMode = hasFullBackground ? + SkBlendMode::kDstOut : SkBlendMode::kDstIn; + result.append(candidate); + } + return result; +} + bool parseAbsoluteSVGLength(const QString& value, qreal& result) { static const QRegularExpression length( @@ -1151,6 +1229,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, normalizePresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); + const auto maskCandidates = materializeMasks(document); const auto tracks = expandIndefiniteTracks(collectTracks(document)); const auto layerCandidates = collectLayerCandidates(document); const auto dashCandidates = collectDashCandidates(document); @@ -1201,6 +1280,14 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, group->prp_setName(candidate.targetName); } } + for (const MaskCandidate& candidate : maskCandidates) { + const auto maskLayer = enve_cast( + findBox(result.get(), candidate.targetId)); + if (!maskLayer) { continue; } + maskLayer->promoteToLayer(); + maskLayer->setBlendModeSk(candidate.blendMode); + maskLayer->prp_setName(candidate.targetName); + } for (const AnimationTrack& track : tracks) { BoundingBox* const target = targets.value(track.targetId); if (target && !track.targetName.isEmpty() && From b0b1de53933a0740ac2a9981ea6c33c7ba227f1b Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Mon, 15 Jun 2026 11:00:51 +0200 Subject: [PATCH 14/16] Improve SVG import options and animation timing --- .../GUI/Dialogs/svganimationimportdialog.cpp | 82 ++++++++-------- .../GUI/Dialogs/svganimationimportdialog.h | 13 ++- src/app/GUI/mainwindow.cpp | 6 +- src/core/svganimationimporter.cpp | 94 ++++++++++++++----- src/core/svganimationimporter.h | 9 +- 5 files changed, 131 insertions(+), 73 deletions(-) diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.cpp b/src/app/GUI/Dialogs/svganimationimportdialog.cpp index 58a561840..3467914ac 100644 --- a/src/app/GUI/Dialogs/svganimationimportdialog.cpp +++ b/src/app/GUI/Dialogs/svganimationimportdialog.cpp @@ -13,15 +13,13 @@ #include "svganimationimportdialog.h" -#include #include #include #include #include -#include +#include #include #include -#include #include #include #include @@ -66,10 +64,11 @@ SVGAnimationImportDialog::SVGAnimationImportDialog( const auto mainLayout = new QVBoxLayout(this); const int sceneFrameCount = scene.lastFrame - scene.firstFrame + 1; - const qreal sceneDuration = scene.fps > 0 ? sceneFrameCount / scene.fps : 0; + const qreal sceneDuration = scene.fps > 0 ? + (scene.lastFrame - scene.firstFrame) / scene.fps : 0; const int svgFrames = qRound(analysis.duration * scene.fps); - const auto comparison = new QTableWidget(4, 3, this); + const auto comparison = new QTableWidget(3, 3, this); comparison->setHorizontalHeaderLabels( {tr("Property"), tr("SVG"), tr("Active scene")}); comparison->verticalHeader()->hide(); @@ -96,12 +95,12 @@ SVGAnimationImportDialog::SVGAnimationImportDialog( .arg(scene.firstFrame) .arg(scene.lastFrame) .arg(sceneFrameCount))); - comparison->setItem(3, 0, tableItem(tr("Animation tracks"))); - comparison->setItem(3, 1, tableItem(tr("%1 supported of %2") - .arg(analysis.supportedTracks) - .arg(analysis.totalTracks))); - comparison->setItem(3, 2, tableItem(tr("Not applicable"))); - comparison->resizeRowsToContents(); + const int compactRowHeight = comparison->fontMetrics().height() + 4; + comparison->verticalHeader()->setMinimumSectionSize(compactRowHeight); + comparison->verticalHeader()->setDefaultSectionSize(compactRowHeight); + for (int row = 0; row < comparison->rowCount(); ++row) { + comparison->setRowHeight(row, compactRowHeight); + } comparison->setMinimumWidth(460); comparison->setFixedHeight(comparison->horizontalHeader()->height() + comparison->verticalHeader()->length() + 2); @@ -115,9 +114,8 @@ SVGAnimationImportDialog::SVGAnimationImportDialog( mainLayout->addWidget(warning); } - const auto scaleGroup = new QGroupBox(tr("Scale mode"), this); - const auto scaleLayout = new QVBoxLayout(scaleGroup); - mScaleMode = new QComboBox(scaleGroup); + const auto optionsLayout = new QFormLayout(); + mScaleMode = new QComboBox(this); mScaleMode->addItem(tr("Don't scale")); mScaleMode->addItem(tr("Scale proportionally (width fit)")); mScaleMode->addItem(tr("Scale proportionally (height fit)")); @@ -132,24 +130,32 @@ SVGAnimationImportDialog::SVGAnimationImportDialog( } } - scaleLayout->addWidget(mScaleMode); - mainLayout->addWidget(scaleGroup); - - const auto structureGroup = new QGroupBox(tr("Import structure"), this); - const auto structureLayout = new QVBoxLayout(structureGroup); - mNamedGroup = new QRadioButton(tr("Grouped"), structureGroup); - mDirectObjects = new QRadioButton(tr("Ungrouped"), structureGroup); - mNamedGroup->setChecked(true); - structureLayout->addWidget(mNamedGroup); - structureLayout->addWidget(mDirectObjects); - mainLayout->addWidget(structureGroup); - - mExtendSceneTime = new QCheckBox( - tr("Extend scene time if necessary"), this); - const bool extensionNeeded = analysis.duration > sceneDuration; - mExtendSceneTime->setChecked(extensionNeeded); - mExtendSceneTime->setEnabled(extensionNeeded); - mainLayout->addWidget(mExtendSceneTime); + optionsLayout->addRow(tr("Scale mode"), mScaleMode); + + mStructureMode = new QComboBox(this); + mStructureMode->addItem(tr("Grouped")); + mStructureMode->addItem(tr("Ungrouped")); + mStructureMode->setCurrentIndex( + static_cast(StructureMode::directObjects)); + optionsLayout->addRow(tr("Import structure"), mStructureMode); + + mSceneDurationMode = new QComboBox(this); + mSceneDurationMode->addItem(tr("Don't modify")); + mSceneDurationMode->addItem(tr("Extend if needed")); + mSceneDurationMode->addItem(tr("Fit to imported SVG")); + mSceneDurationMode->setCurrentIndex( + analysis.duration > sceneDuration ? + static_cast(SceneDurationMode::extendIfNeeded) : + static_cast(SceneDurationMode::dontModify)); + if (analysis.duration <= 0) { + const auto model = qobject_cast( + mSceneDurationMode->model()); + for (int i = 1; model && i < mSceneDurationMode->count(); ++i) { + model->item(i)->setEnabled(false); + } + } + optionsLayout->addRow(tr("Scene duration"), mSceneDurationMode); + mainLayout->addLayout(optionsLayout); const auto buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); @@ -164,25 +170,25 @@ SVGAnimationImportDialog::ScaleMode SVGAnimationImportDialog::scaleMode() const SVGAnimationImportDialog::StructureMode SVGAnimationImportDialog::structureMode() const { - return mDirectObjects->isChecked() ? - StructureMode::directObjects : StructureMode::namedGroup; + return static_cast(mStructureMode->currentIndex()); } -bool SVGAnimationImportDialog::extendSceneTime() const { - return mExtendSceneTime->isChecked(); +SVGAnimationImportDialog::SceneDurationMode +SVGAnimationImportDialog::sceneDurationMode() const { + return static_cast(mSceneDurationMode->currentIndex()); } bool SVGAnimationImportDialog::sExec( const QString& path, const SceneInfo& scene, QSizeF& svgSize, ScaleMode& scaleMode, StructureMode& structureMode, - bool& extendSceneTime, QWidget* const parent) { + SceneDurationMode& durationMode, QWidget* const parent) { svgSize = readSVGSize(path); const auto analysis = ImportSVGAnimation::analyzeSVGFile(path); SVGAnimationImportDialog dialog(svgSize, scene, analysis, parent); if (dialog.exec() != QDialog::Accepted) { return false; } scaleMode = dialog.scaleMode(); structureMode = dialog.structureMode(); - extendSceneTime = dialog.extendSceneTime(); + durationMode = dialog.sceneDurationMode(); return true; } diff --git a/src/app/GUI/Dialogs/svganimationimportdialog.h b/src/app/GUI/Dialogs/svganimationimportdialog.h index 107b8c449..efa66e644 100644 --- a/src/app/GUI/Dialogs/svganimationimportdialog.h +++ b/src/app/GUI/Dialogs/svganimationimportdialog.h @@ -20,8 +20,6 @@ #include "svganimationimporter.h" class QComboBox; -class QCheckBox; -class QRadioButton; class SVGAnimationImportDialog : public QDialog { public: @@ -44,27 +42,28 @@ class SVGAnimationImportDialog : public QDialog { directObjects }; + using SceneDurationMode = ImportSVGAnimation::SceneDurationMode; + SVGAnimationImportDialog(const QSizeF& svgSize, const SceneInfo& scene, const ImportSVGAnimation::Analysis& analysis, QWidget* const parent); ScaleMode scaleMode() const; StructureMode structureMode() const; - bool extendSceneTime() const; + SceneDurationMode sceneDurationMode() const; static bool sExec(const QString& path, const SceneInfo& scene, QSizeF& svgSize, ScaleMode& scaleMode, StructureMode& structureMode, - bool& extendSceneTime, + SceneDurationMode& durationMode, QWidget* const parent); private: static QSizeF readSVGSize(const QString& path); QComboBox* mScaleMode; - QRadioButton* mNamedGroup; - QRadioButton* mDirectObjects; - QCheckBox* mExtendSceneTime; + QComboBox* mStructureMode; + QComboBox* mSceneDurationMode; }; #endif // SVGANIMATIONIMPORTDIALOG_H diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index 3be9c9246..c3320ad44 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -1311,14 +1311,14 @@ void MainWindow::importSVGAnimation() QSizeF svgSize; SVGAnimationImportDialog::ScaleMode scaleMode; SVGAnimationImportDialog::StructureMode structureMode; - bool extendSceneTime = true; + SVGAnimationImportDialog::SceneDurationMode durationMode; const SVGAnimationImportDialog::SceneInfo sceneInfo{ QSize(scene->getCanvasWidth(), scene->getCanvasHeight()), scene->getMinFrame(), scene->getMaxFrame(), scene->getFps() }; if (!SVGAnimationImportDialog::sExec( path, sceneInfo, svgSize, scaleMode, structureMode, - extendSceneTime, this)) { + durationMode, this)) { return; } @@ -1337,7 +1337,7 @@ void MainWindow::importSVGAnimation() auto block = scene->blockUndoRedo(); bool technicalRoot = false; const auto imported = ImportSVGAnimation::loadSVGFile( - path, scene, extendSceneTime, &technicalRoot); + path, scene, durationMode, &technicalRoot); if (!imported) { return; } const QString importName = QFileInfo(path).completeBaseName(); block.reset(); diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index f9419c8fe..d5b0b077f 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -182,6 +182,58 @@ qreal parseClock(const QString& value, bool* ok) return parsed ? result : 0; } +qint64 greatestCommonDivisor(qint64 first, qint64 second) +{ + while (second != 0) { + const qint64 remainder = first % second; + first = second; + second = remainder; + } + return first; +} + +qreal animationDuration(const QDomDocument& document) +{ + qreal maximumEnd = 0; + qint64 commonCycleMs = 0; + constexpr qint64 maximumCommonCycleMs = 60 * 60 * 1000; + const QStringList tags{"animate", "animateTransform", "animateMotion", "set"}; + for (const QString& tag : tags) { + const QDomNodeList nodes = document.elementsByTagName(tag); + for (int i = 0; i < nodes.count(); ++i) { + const QDomElement animation = nodes.at(i).toElement(); + bool durationOk = false; + const qreal duration = parseClock(animation.attribute("dur"), + &durationOk); + bool beginOk = false; + const qreal begin = parseClock(animation.attribute("begin", "0s"), + &beginOk); + if (durationOk) { + const qreal actualBegin = beginOk ? begin : 0; + maximumEnd = qMax(maximumEnd, duration + actualBegin); + if (qFuzzyIsNull(actualBegin) && + animation.attribute("repeatCount").trimmed() == + "indefinite") { + const qint64 durationMs = qMax( + 1, qRound64(duration * 1000)); + if (commonCycleMs == 0) { + commonCycleMs = durationMs; + continue; + } + const qint64 divisor = greatestCommonDivisor( + commonCycleMs, durationMs); + const qint64 multiplier = commonCycleMs / divisor; + if (multiplier <= + maximumCommonCycleMs / durationMs) { + commonCycleMs = multiplier * durationMs; + } + } + } + } + } + return qMax(maximumEnd, commonCycleMs / 1000.); +} + QList parseNumbers(const QString& value) { QList result; @@ -771,9 +823,9 @@ AnimationTrack repeatedTrack(const AnimationTrack& source, } QList expandIndefiniteTracks( - const QList& tracks) + const QList& tracks, const qreal targetDuration) { - qreal end = 0; + qreal end = targetDuration; for (const AnimationTrack& track : tracks) { end = qMax(end, track.begin + track.duration); } @@ -782,8 +834,8 @@ QList expandIndefiniteTracks( result.reserve(tracks.size()); for (const AnimationTrack& track : tracks) { const qreal available = end - track.begin; - const int repetitions = qMax(1, qFloor( - available / track.duration + + const int repetitions = qMax(1, qCeil( + available / track.duration - 0.000001)); result.append(repeatedTrack(track, repetitions)); } @@ -1195,25 +1247,16 @@ ImportSVGAnimation::Analysis ImportSVGAnimation::analyzeSVGFile( } else { result.unsupported.append(unsupportedDescription(animation)); } - bool durationOk = false; - const qreal duration = parseClock(animation.attribute("dur"), - &durationOk); - bool beginOk = false; - const qreal begin = parseClock(animation.attribute("begin", "0s"), - &beginOk); - if (durationOk) { - result.duration = qMax(result.duration, - duration + (beginOk ? begin : 0)); - } } } + result.duration = animationDuration(document); result.unsupported.removeDuplicates(); return result; } qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, Canvas* scene, - const bool extendSceneTime, + const SceneDurationMode durationMode, bool* const technicalRoot) { if (!scene) { RuntimeThrow("SVG animation import requires an active scene"); } @@ -1225,12 +1268,14 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, if (!document.setContent(&file)) { RuntimeThrow("Cannot parse SVG animation file " + filename); } + const qreal importedDuration = animationDuration(document); const bool documentHasTechnicalRoot = hasTechnicalRoot(document); normalizePresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); const auto maskCandidates = materializeMasks(document); - const auto tracks = expandIndefiniteTracks(collectTracks(document)); + const auto tracks = expandIndefiniteTracks(collectTracks(document), + importedDuration); const auto layerCandidates = collectLayerCandidates(document); const auto dashCandidates = collectDashCandidates(document); const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; @@ -1295,14 +1340,15 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, target->prp_setName(track.targetName); } } - int lastFrame = scene->getMaxFrame(); - for (const AnimationTrack& track : tracks) { - lastFrame = qMax(lastFrame, - qCeil((track.begin + track.duration) * - scene->getFps() - 0.000001)); - } - if (extendSceneTime && lastFrame > scene->getMaxFrame()) { - scene->setFrameRange({scene->getMinFrame(), lastFrame}); + const int importedLastFrame = + scene->getMinFrame() + + qMax(1, qRound(importedDuration * scene->getFps())); + if (durationMode == SceneDurationMode::fitImportedSVG && + importedDuration > 0) { + scene->setFrameRange({scene->getMinFrame(), importedLastFrame}); + } else if (durationMode == SceneDurationMode::extendIfNeeded && + importedLastFrame > scene->getMaxFrame()) { + scene->setFrameRange({scene->getMinFrame(), importedLastFrame}); } return result; } diff --git a/src/core/svganimationimporter.h b/src/core/svganimationimporter.h index bacc98ed4..87bd127c4 100644 --- a/src/core/svganimationimporter.h +++ b/src/core/svganimationimporter.h @@ -22,6 +22,12 @@ class BoundingBox; class Canvas; namespace ImportSVGAnimation { + enum class SceneDurationMode { + dontModify, + extendIfNeeded, + fitImportedSVG + }; + struct CORE_EXPORT Analysis { int totalTracks = 0; int supportedTracks = 0; @@ -33,7 +39,8 @@ namespace ImportSVGAnimation { CORE_EXPORT qsptr loadSVGFile(const QString& filename, Canvas* scene, - bool extendSceneTime = true, + SceneDurationMode durationMode = + SceneDurationMode::extendIfNeeded, bool* technicalRoot = nullptr); } From 238aee46c38e1da1e5f981846904947aee630f4d Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Tue, 16 Jun 2026 09:22:54 +0200 Subject: [PATCH 15/16] Import SVG image elements as linked images --- src/core/svganimationimporter.cpp | 192 ++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/src/core/svganimationimporter.cpp b/src/core/svganimationimporter.cpp index d5b0b077f..db115340e 100644 --- a/src/core/svganimationimporter.cpp +++ b/src/core/svganimationimporter.cpp @@ -13,11 +13,17 @@ #include "svganimationimporter.h" +#include +#include +#include #include #include +#include +#include #include #include #include +#include #include #include "Animators/qpointfanimator.h" @@ -32,6 +38,7 @@ #include "Boxes/boxrenderdata.h" #include "Boxes/circle.h" #include "Boxes/containerbox.h" +#include "Boxes/imagebox.h" #include "Boxes/rectangle.h" #include "Boxes/smartvectorpath.h" #include "PathEffects/dashpatheffect.h" @@ -39,6 +46,8 @@ #include "exceptions.h" #include "svgimporter.h" +qsptr createImageBox(const QString& path); + namespace { struct AnimationTrack { @@ -55,6 +64,12 @@ struct AnimationTrack { bool repeatIndefinitely = false; }; +struct ImageCandidate { + QString placeholderId; + QString name; + QString path; +}; + struct LayerCandidate { QString targetId; QString targetName; @@ -428,6 +443,181 @@ QString ensureTargetId(QDomElement& target, int& nextId) return id; } +QString imageHref(const QDomElement& image) +{ + return image.attribute("href", image.attribute("xlink:href")); +} + +QString resolveImagePath(const QString& href, const QString& svgFilename) +{ + if (href.startsWith("data:", Qt::CaseInsensitive)) { + const int comma = href.indexOf(','); + if (comma < 0) { return {}; } + const QString metadata = href.mid(5, comma - 5); + const QByteArray payload = href.mid(comma + 1).toLatin1(); + const QByteArray data = metadata.contains(";base64", + Qt::CaseInsensitive) ? + QByteArray::fromBase64(payload) : + QByteArray::fromPercentEncoding(payload); + if (data.isEmpty()) { return {}; } + QBuffer buffer; + buffer.setData(data); + if (!buffer.open(QIODevice::ReadOnly)) { return {}; } + QImageReader reader(&buffer); + const QImage image = reader.read(); + if (image.isNull() || + reader.error() != QImageReader::UnknownError) { + return {}; + } + + const QFileInfo svgInfo(svgFilename); + const QString assetDirName = + svgInfo.completeBaseName() + "_svg_assets"; + QDir assetDir(svgInfo.absoluteDir().filePath(assetDirName)); + if (!assetDir.exists() && !svgInfo.absoluteDir().mkpath(assetDirName)) { + return {}; + } + const QString digest = QString::fromLatin1( + QCryptographicHash::hash(data, QCryptographicHash::Sha256) + .toHex().left(16)); + const QString path = assetDir.filePath("embedded_" + digest + ".png"); + if (!QFileInfo::exists(path) && !image.save(path, "PNG")) { + return {}; + } + return QFileInfo(path).absoluteFilePath(); + } + + const QUrl url(href); + if (url.isLocalFile()) { return QFileInfo(url.toLocalFile()).absoluteFilePath(); } + if (!url.scheme().isEmpty()) { return {}; } + return QFileInfo(QFileInfo(svgFilename).absoluteDir(), href) + .absoluteFilePath(); +} + +QString imagePlacementTransform(const QDomElement& image, + const QSize& intrinsicSize) +{ + const qreal x = image.attribute("x").toDouble(); + const qreal y = image.attribute("y").toDouble(); + const qreal width = image.attribute("width").isEmpty() ? + intrinsicSize.width() : image.attribute("width").toDouble(); + const qreal height = image.attribute("height").isEmpty() ? + intrinsicSize.height() : image.attribute("height").toDouble(); + qreal scaleX = width / intrinsicSize.width(); + qreal scaleY = height / intrinsicSize.height(); + qreal offsetX = x; + qreal offsetY = y; + + const QString aspect = image.attribute("preserveAspectRatio").simplified(); + if (!aspect.startsWith("none", Qt::CaseInsensitive)) { + const bool slice = aspect.contains("slice", Qt::CaseInsensitive); + const qreal scale = slice ? qMax(scaleX, scaleY) : qMin(scaleX, scaleY); + const qreal renderedWidth = intrinsicSize.width() * scale; + const qreal renderedHeight = intrinsicSize.height() * scale; + const qreal remainingWidth = width - renderedWidth; + const qreal remainingHeight = height - renderedHeight; + if (aspect.contains("xMax", Qt::CaseInsensitive)) { + offsetX += remainingWidth; + } else if (!aspect.contains("xMin", Qt::CaseInsensitive)) { + offsetX += remainingWidth * 0.5; + } + if (aspect.contains("YMax", Qt::CaseInsensitive)) { + offsetY += remainingHeight; + } else if (!aspect.contains("YMin", Qt::CaseInsensitive)) { + offsetY += remainingHeight * 0.5; + } + scaleX = scale; + scaleY = scale; + } + + return QString("translate(%1 %2) scale(%3 %4)") + .arg(offsetX, 0, 'g', 16) + .arg(offsetY, 0, 'g', 16) + .arg(scaleX, 0, 'g', 16) + .arg(scaleY, 0, 'g', 16); +} + +QList materializeImages(QDomDocument& document, + const QString& svgFilename) +{ + QList result; + const QDomNodeList images = document.elementsByTagName("image"); + for (int i = images.count() - 1; i >= 0; --i) { + const QDomElement image = images.at(i).toElement(); + const QString path = resolveImagePath(imageHref(image), svgFilename); + QImageReader reader(path); + const QSize intrinsicSize = reader.size(); + if (path.isEmpty() || !intrinsicSize.isValid()) { continue; } + + const QString baseId = QString("__friction_svg_image_%1").arg(i); + const QString groupId = baseId + "_group"; + const QString placeholderId = baseId + "_placeholder"; + const QString name = image.attribute( + "inkscape:label", image.attribute("id", "Image")); + QDomElement group = document.createElement("g"); + const QDomNamedNodeMap attributes = image.attributes(); + for (int attrId = 0; attrId < attributes.count(); ++attrId) { + const QDomAttr attr = attributes.item(attrId).toAttr(); + const QString attrName = attr.name(); + if (attrName == "x" || attrName == "y" || + attrName == "width" || attrName == "height" || + attrName == "href" || attrName == "xlink:href" || + attrName == "preserveAspectRatio" || + attrName == "id" || attrName == "inkscape:label") { + continue; + } + group.setAttribute(attrName, attr.value()); + } + if (!group.hasAttribute("transform")) { + group.setAttribute("transform", "translate(0 0)"); + } + group.setAttribute("id", groupId); + group.setAttribute("inkscape:label", name); + + QDomElement placeholder = document.createElement("rect"); + placeholder.setAttribute("x", "0"); + placeholder.setAttribute("y", "0"); + placeholder.setAttribute("width", intrinsicSize.width()); + placeholder.setAttribute("height", intrinsicSize.height()); + placeholder.setAttribute("fill", "none"); + placeholder.setAttribute("transform", + imagePlacementTransform(image, intrinsicSize)); + placeholder.setAttribute("id", placeholderId); + placeholder.setAttribute("inkscape:label", placeholderId); + group.appendChild(placeholder); + while (!image.firstChild().isNull()) { + group.appendChild(image.firstChild()); + } + image.parentNode().replaceChild(group, image); + result.append({placeholderId, name, path}); + } + return result; +} + +void replaceImagePlaceholders(BoundingBox* const root, + const QList& candidates) +{ + for (const ImageCandidate& candidate : candidates) { + BoundingBox* const placeholder = findBox(root, candidate.placeholderId); + const auto group = placeholder ? placeholder->getParentGroup() : nullptr; + if (!group || !placeholder) { continue; } + + qsptr placeholderRef; + for (const auto& child : group->getContained()) { + if (child.get() == placeholder) { + placeholderRef = child; + break; + } + } + if (!placeholderRef) { continue; } + + const auto image = createImageBox(candidate.path); + placeholder->copyBoundingBoxDataTo(image.get()); + image->prp_setName(candidate.name); + group->replaceContained(placeholderRef, image); + } +} + QString styleProperty(const QDomElement& element, const QString& property) { for (const QString& declaration : @@ -1270,6 +1460,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, } const qreal importedDuration = animationDuration(document); const bool documentHasTechnicalRoot = hasTechnicalRoot(document); + const auto imageCandidates = materializeImages(document, filename); normalizePresentationAttributes(document.documentElement()); normalizeStaticTransforms(document.documentElement()); @@ -1281,6 +1472,7 @@ qsptr ImportSVGAnimation::loadSVGFile(const QString& filename, const auto gradientCreator = [scene]() { return scene->createNewGradient(); }; const auto result = ImportSVG::loadSVGFile(document, gradientCreator); if (!result) { return nullptr; } + replaceImagePlaceholders(result.get(), imageCandidates); if (technicalRoot) { *technicalRoot = documentHasTechnicalRoot && enve_cast(result.get()); From 2ae775b1997acd65c15d0f2dcf837c02933cee0a Mon Sep 17 00:00:00 2001 From: Pablo Gil Date: Fri, 10 Jul 2026 10:45:41 +0200 Subject: [PATCH 16/16] Fix SVG import file dialog --- src/app/GUI/mainwindow.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/GUI/mainwindow.cpp b/src/app/GUI/mainwindow.cpp index c3320ad44..bb634f175 100644 --- a/src/app/GUI/mainwindow.cpp +++ b/src/app/GUI/mainwindow.cpp @@ -1301,8 +1301,8 @@ void MainWindow::importSVGAnimation() QDir::homePath()).toString(); const QString title = tr("Import SVG Animation", "ImportSVGAnimationDialog_Title"); - const QString path = eDialogs::openFile(title, recentDir, - tr("SVG Files (*.svg)")); + const QString path = AppSupport::getOpenFile(this, title, recentDir, + tr("SVG Files (*.svg)")); enableEventFilter(); if (path.isEmpty()) { return; }