diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index ae4afa320..6da572f19 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -166,6 +166,8 @@ set(SRCS
setupwizarddialog.cpp
setupwizarddialog.h
setupwizarddialog.ui
+ svgwidget.cpp
+ svgwidget.h
texturereplacementsettingsdialog.ui
themesvgiconengine.cpp
themesvgiconengine.h
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index 4e9897b4e..9048eddd4 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -55,14 +55,17 @@
+
NotUsing
-
+
+
+
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters
index 426367f23..df36ff6ad 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj.filters
+++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters
@@ -55,6 +55,7 @@
+
@@ -122,6 +123,7 @@
+
diff --git a/src/duckstation-qt/svgwidget.cpp b/src/duckstation-qt/svgwidget.cpp
new file mode 100644
index 000000000..e8d678b97
--- /dev/null
+++ b/src/duckstation-qt/svgwidget.cpp
@@ -0,0 +1,182 @@
+// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#include "svgwidget.h"
+#include "qtutils.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "moc_svgwidget.cpp"
+
+static void CleanupSurface(void* surface)
+{
+ plutovg_surface_destroy(static_cast(surface));
+}
+
+SVGWidget::SVGWidget(QWidget* parent) : QWidget(parent)
+{
+ setAttribute(Qt::WA_OpaquePaintEvent, false);
+}
+
+SVGWidget::SVGWidget(const QString& resource_path, QWidget* parent /*= nullptr*/) : SVGWidget(parent)
+{
+ setSource(resource_path);
+}
+
+SVGWidget::~SVGWidget()
+{
+ destroyDocument();
+}
+
+void SVGWidget::setColor(const QColor& color)
+{
+ m_pixmap = QPixmap();
+ rasterize();
+ update();
+}
+
+void SVGWidget::setSource(const QString& resource_path)
+{
+ destroyDocument();
+ m_resource_path = resource_path;
+ m_pixmap = {};
+ m_last_raster_size = {};
+
+ if (resource_path.isEmpty())
+ {
+ update();
+ return;
+ }
+
+ QFile file(m_resource_path);
+ if (!file.open(QFile::ReadOnly) || !QtUtils::ReadFileToByteArray(&file, m_svg_data))
+ {
+ qCritical() << "Failed to open SVG file: " << m_resource_path;
+ update();
+ return;
+ }
+
+ // plutosvg borrows the raw pointer; m_svg_data must outlive m_document.
+ m_document = plutosvg_document_load_from_data(reinterpret_cast(m_svg_data.data()),
+ static_cast(m_svg_data.size()), -1.0f, -1.0f, nullptr, nullptr);
+ if (!m_document)
+ {
+ qCritical() << "PlutoSVGWidget: failed to parse SVG" << resource_path;
+ m_svg_data.deallocate();
+ m_resource_path.clear();
+ update();
+ return;
+ }
+
+ rasterize();
+ update();
+}
+
+void SVGWidget::destroyDocument()
+{
+ if (m_document)
+ {
+ plutosvg_document_destroy(m_document);
+ m_document = nullptr;
+ }
+ m_svg_data.deallocate();
+}
+
+void SVGWidget::rasterize()
+{
+ m_pixmap = {};
+
+ if (!m_document || size().isEmpty())
+ return;
+
+ const qreal dpr = devicePixelRatioF();
+
+ // Physical pixel dimensions to render at.
+ const QSize physical = QtUtils::ApplyDevicePixelRatioToSize(size(), dpr);
+
+ // Avoid redundant re-renders when size hasn't actually changed.
+ if (physical == m_last_raster_size && !m_pixmap.isNull())
+ return;
+
+ m_last_raster_size = physical;
+
+ // Determine SVG intrinsic size and compute a uniform scale that fits inside physical,
+ // preserving aspect ratio.
+ const float svg_w = plutosvg_document_get_width(m_document);
+ const float svg_h = plutosvg_document_get_height(m_document);
+
+ int render_w = physical.width();
+ int render_h = physical.height();
+
+ if (svg_w > 0.0f && svg_h > 0.0f)
+ {
+ // Scale to fit, preserving aspect ratio (letterbox / pillarbox).
+ const float scale_x = static_cast(physical.width()) / svg_w;
+ const float scale_y = static_cast(physical.height()) / svg_h;
+ const float scale = std::min(scale_x, scale_y);
+ render_w = std::max(1, static_cast(svg_w * scale));
+ render_h = std::max(1, static_cast(svg_h * scale));
+ }
+
+ // Use white as currentColor so the SVG's own colours are preserved.
+ // Swap in a palette colour here if tinting is ever desired.
+ const plutovg_color_t current_color = {.r = 1.0f, .g = 1.0f, .b = 1.0f, .a = 1.0f};
+
+ plutovg_surface_t* const surface =
+ plutosvg_document_render_to_surface(m_document, nullptr, render_w, render_h, ¤t_color, nullptr, nullptr);
+ if (!surface)
+ {
+ qCritical() << "PlutoSVGWidget: render failed for" << m_resource_path;
+ return;
+ }
+
+ // plutovg surfaces are premultiplied ARGB (native-endian) == QImage::Format_ARGB32_Premultiplied.
+ const QImage img(plutovg_surface_get_data(surface), plutovg_surface_get_width(surface),
+ plutovg_surface_get_height(surface), plutovg_surface_get_stride(surface),
+ QImage::Format_ARGB32_Premultiplied, CleanupSurface, surface);
+
+ m_pixmap = QPixmap::fromImage(img);
+ m_pixmap.setDevicePixelRatio(dpr);
+}
+
+void SVGWidget::paintEvent(QPaintEvent* event)
+{
+ Q_UNUSED(event);
+
+ QPainter painter(this);
+
+ if (m_pixmap.isNull())
+ return;
+
+ // Center the (possibly smaller, aspect-ratio-corrected) pixmap inside the widget.
+ // m_pixmap.size() is in physical pixels; divide by DPR to get logical pixels.
+ const QSizeF logical_size = m_pixmap.size() / m_pixmap.devicePixelRatioF();
+ const QPointF top_left((width() - logical_size.width()) / 2.0, (height() - logical_size.height()) / 2.0);
+
+ painter.drawPixmap(QRectF(top_left, logical_size), m_pixmap, QRectF(QPointF(0, 0), m_pixmap.size()));
+}
+
+void SVGWidget::resizeEvent(QResizeEvent* event)
+{
+ QWidget::resizeEvent(event);
+ rasterize();
+}
+
+void SVGWidget::changeEvent(QEvent* event)
+{
+ QWidget::changeEvent(event);
+
+ // Re-rasterize when the screen changes (e.g. window moved to a display with a different DPR).
+ if (event->type() == QEvent::DevicePixelRatioChange || event->type() == QEvent::ScreenChangeInternal)
+ {
+ m_last_raster_size = {}; // force re-render
+ rasterize();
+ update();
+ }
+}
\ No newline at end of file
diff --git a/src/duckstation-qt/svgwidget.h b/src/duckstation-qt/svgwidget.h
new file mode 100644
index 000000000..074591442
--- /dev/null
+++ b/src/duckstation-qt/svgwidget.h
@@ -0,0 +1,61 @@
+// SPDX-FileCopyrightText: 2019-2026 Connor McLaughlin
+// SPDX-License-Identifier: CC-BY-NC-ND-4.0
+
+#pragma once
+
+#include "common/heap_array.h"
+#include "common/types.h"
+
+#include
+#include
+#include
+#include
+#include
+
+struct plutosvg_document;
+
+/**
+ * A widget that loads a monochrome SVG file via plutosvg and rasterizes it at the correct device
+ * pixel ratio. The SVG is re-rasterized whenever the widget is resized so the image stays crisp
+ * at any size. The rendered image is centered inside the widget; if the SVG aspect ratio differs
+ * from the widget's aspect ratio the image is letterboxed / pillarboxed with a transparent
+ * background.
+ */
+class SVGWidget final : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit SVGWidget(QWidget* parent = nullptr);
+ SVGWidget(const QString& resource_path, QWidget* parent = nullptr);
+ ~SVGWidget() override;
+
+ const QColor& color() const { return m_color; }
+ void setColor(const QColor& color);
+
+ /// Load (or reload) an SVG from the given Qt resource / file path.
+ /// Clears any previously loaded document. Triggers a repaint.
+ void setSource(const QString& resource_path);
+
+ /// Returns the path that was passed to setSource().
+ const QString& source() const { return m_resource_path; }
+
+protected:
+ void paintEvent(QPaintEvent* event) override;
+ void resizeEvent(QResizeEvent* event) override;
+ void changeEvent(QEvent* event) override;
+
+private:
+ /// (Re)render m_document at the current widget size + DPR and store the result in m_pixmap.
+ void rasterize();
+
+ /// Free the parsed document and clear associated data.
+ void destroyDocument();
+
+ QString m_resource_path;
+ DynamicHeapArray m_svg_data; ///< Raw bytes; plutosvg borrows this pointer.
+ plutosvg_document* m_document = nullptr;
+ QPixmap m_pixmap; ///< Last rasterized pixmap (physical pixels, DPR set).
+ QSize m_last_raster_size; ///< Physical size used for the last rasterize() call.
+ QColor m_color; ///< Color to use when rasterizing the SVG (overrides currentColor in the SVG).
+};
\ No newline at end of file