[Icons] Add SVG currentColor sentinel resolver

Qt's SVG renderer has no CSS cascade, so a `currentColor` fill has to be
substituted before the bytes reach QSvgRenderer.

- Match and replace on bytes: both the sentinel and the replacement are
  ASCII, so the document never goes through a UTF-16 round trip, and files
  that are not valid UTF-8 survive unchanged.
- Document that the match is a plain case-insensitive substring, so an
  identifier that merely starts with the sentinel gets rewritten too.
This commit is contained in:
Lukas Brübach 2026-10-20 11:24:03 +02:00
parent 9f30892dff
commit b0a642df75
5 changed files with 144 additions and 0 deletions

View file

@ -8,6 +8,7 @@ set(UTILITY_SOURCES
libcockatrice/utility/passwordhasher.cpp
libcockatrice/utility/report_categories.cpp
libcockatrice/utility/server_rate_limiter.cpp
libcockatrice/utility/svg_recolor.cpp
libcockatrice/utility/translation_loader.cpp
libcockatrice/utility/user_data_backup.cpp
libcockatrice/utility/warning_categories.cpp
@ -34,6 +35,7 @@ set(UTILITY_HEADERS
libcockatrice/utility/zone_names.h
libcockatrice/utility/days_years_between.h
libcockatrice/utility/server_rate_limiter.h
libcockatrice/utility/svg_recolor.h
libcockatrice/utility/translation_loader.h
libcockatrice/utility/user_data_backup.h
libcockatrice/utility/warning_categories.h

View file

@ -0,0 +1,44 @@
#include "svg_recolor.h"
#include <QByteArrayView>
namespace
{
constexpr QByteArrayView SENTINEL = "currentColor";
/// Case-insensitive byte search for the sentinel, starting at @p from.
/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and
/// its char overload cannot match a word.
qsizetype findSentinel(const QByteArray &bytes, qsizetype from)
{
// QByteArrayView::sliced() is a view, so the scan does not allocate a copy
// at every byte position (QByteArray::sliced() would).
const QByteArrayView view(bytes);
for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) {
if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) {
return i;
}
}
return -1;
}
} // namespace
QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color)
{
// The sentinel and the replacement are both ASCII, so stay in bytes: a
// QString round trip would decode the whole document (and mangle bytes that
// are not valid UTF-8) even on the no-op path.
qsizetype pos = findSentinel(svg, 0);
if (pos < 0) {
return svg;
}
QByteArray resolved = svg;
while (pos >= 0) {
resolved.replace(pos, SENTINEL.size(), color);
// Skip past the inserted color: a caller's color never contains the
// sentinel, and this keeps the scan linear.
pos = findSentinel(resolved, pos + color.size());
}
return resolved;
}

View file

@ -0,0 +1,31 @@
/**
* @file svg_recolor.h
* @ingroup Core
*/
#ifndef SVG_RECOLOR_H
#define SVG_RECOLOR_H
#include <QByteArray>
/**
* @brief Resolves the `currentColor` sentinel in authored SVG data.
*
* Qt's SVG renderer has no CSS cascade or ambient color context, so the
* sentinel is not resolved by Qt. Callers pass the concrete color they want
* (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes
* that are safe to hand to QSvgRenderer.
*
* Matching is a plain, case-insensitive substring search, not a CSS parse: an
* identifier that merely starts with the sentinel (for example
* `id="currentColorStop"`) is rewritten too. That is acceptable for the assets
* this project authors, and keeps the resolver dependency-free.
*
* @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved
* byte-for-byte, since the resolver never decodes the document.
* @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes.
* @return The recolored SVG source; the input is left untouched.
*/
QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color);
#endif // SVG_RECOLOR_H