feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,226 @@
using System;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Media;
using PCL.Core.UI.Animation;
using PCL.Core.UI.Animation.Animatable;
using PCL.Core.UI.Animation.Core;
using PCL.Core.UI.Animation.Easings;
namespace PCL.Core.UI.Controls.SvgIcon;
public class SvgIcon : FrameworkElement
{
public static readonly DependencyProperty IconProperty = DependencyProperty.Register(
nameof(Icon),
typeof(string),
typeof(SvgIcon),
new FrameworkPropertyMetadata(
string.Empty,
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender,
_OnIconChanged));
public static readonly DependencyProperty DefaultPackProperty = DependencyProperty.Register(
nameof(DefaultPack),
typeof(string),
typeof(SvgIcon),
new FrameworkPropertyMetadata(
SvgIconLoader.DefaultIconPack,
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender,
_OnIconChanged));
public static readonly DependencyProperty IconBrushProperty = DependencyProperty.Register(
nameof(IconBrush),
typeof(Brush),
typeof(SvgIcon),
new FrameworkPropertyMetadata(
SystemColors.ControlTextBrush,
FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty StrokeThicknessProperty = DependencyProperty.Register(
nameof(StrokeThickness),
typeof(double),
typeof(SvgIcon),
new FrameworkPropertyMetadata(
2D,
FrameworkPropertyMetadataOptions.AffectsRender),
value => value is double number && !double.IsNaN(number) && number >= 0D);
public static readonly DependencyProperty UseOriginalColorProperty = DependencyProperty.Register(
nameof(UseOriginalColor),
typeof(bool),
typeof(SvgIcon),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.AffectsRender));
public static readonly DependencyProperty StretchProperty = DependencyProperty.Register(
nameof(Stretch),
typeof(Stretch),
typeof(SvgIcon),
new FrameworkPropertyMetadata(
Stretch.Uniform,
FrameworkPropertyMetadataOptions.AffectsMeasure | FrameworkPropertyMetadataOptions.AffectsRender));
private SvgIconModel? _model;
private bool _modelLoaded;
public string Icon
{
get => (string)GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public string DefaultPack
{
get => (string)GetValue(DefaultPackProperty);
set => SetValue(DefaultPackProperty, value);
}
public Brush IconBrush
{
get => (Brush)GetValue(IconBrushProperty);
set => SetValue(IconBrushProperty, value);
}
public double StrokeThickness
{
get => (double)GetValue(StrokeThicknessProperty);
set => SetValue(StrokeThicknessProperty, value);
}
public bool UseOriginalColor
{
get => (bool)GetValue(UseOriginalColorProperty);
set => SetValue(UseOriginalColorProperty, value);
}
public Stretch Stretch
{
get => (Stretch)GetValue(StretchProperty);
set => SetValue(StretchProperty, value);
}
public IAnimation AnimateIconBrushTo(
NColor color,
TimeSpan? duration = null,
IEasing? easing = null,
string? animationKey = null)
{
_EnsureAnimatableIconBrush();
var animation = new NColorFromToAnimation
{
Name = animationKey ?? $"SvgIconColor {RuntimeHelpers.GetHashCode(this)}",
To = color,
Duration = duration ?? TimeSpan.FromMilliseconds(120),
Easing = easing ?? CubicEaseOut.Shared
};
return animation.RunFireAndForget(new WpfAnimatable(this, IconBrushProperty));
}
protected override Size MeasureOverride(Size availableSize)
{
var model = _GetModel();
var naturalSize = model is null
? new Size(24D, 24D)
: new Size(model.Width, model.Height);
if (double.IsInfinity(availableSize.Width) && double.IsInfinity(availableSize.Height))
return naturalSize;
if (double.IsInfinity(availableSize.Width))
return new Size(naturalSize.Width * availableSize.Height / naturalSize.Height, availableSize.Height);
if (double.IsInfinity(availableSize.Height))
return new Size(availableSize.Width, naturalSize.Height * availableSize.Width / naturalSize.Width);
return availableSize;
}
protected override void OnRender(DrawingContext drawingContext)
{
base.OnRender(drawingContext);
var model = _GetModel();
if (model is null || model.Elements.Count == 0 || RenderSize.Width <= 0D || RenderSize.Height <= 0D)
return;
var target = _CalculateTargetRect(new Size(model.Width, model.Height), RenderSize, Stretch);
if (target.Width <= 0D || target.Height <= 0D)
return;
var scaleX = target.Width / model.Width;
var scaleY = target.Height / model.Height;
drawingContext.PushTransform(new TranslateTransform(target.X, target.Y));
drawingContext.PushTransform(new ScaleTransform(scaleX, scaleY));
drawingContext.PushTransform(new TranslateTransform(-model.MinX, -model.MinY));
var options = new SvgIconPaintOptions(IconBrush, StrokeThickness, UseOriginalColor);
foreach (var element in model.Elements)
element.Draw(drawingContext, options);
drawingContext.Pop();
drawingContext.Pop();
drawingContext.Pop();
}
private SvgIconModel? _GetModel()
{
if (_modelLoaded)
return _model;
_model = SvgIconLoader.Load(Icon, DefaultPack);
_modelLoaded = true;
return _model;
}
private static void _OnIconChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args)
{
var icon = (SvgIcon)dependencyObject;
icon._model = null;
icon._modelLoaded = false;
}
private void _EnsureAnimatableIconBrush()
{
if (IconBrush is SolidColorBrush { IsFrozen: false })
return;
IconBrush = IconBrush switch
{
SolidColorBrush solidColorBrush => new SolidColorBrush(solidColorBrush.Color),
_ => new SolidColorBrush(Colors.Black)
};
}
private static Rect _CalculateTargetRect(Size sourceSize, Size renderSize, Stretch stretch)
{
if (sourceSize.Width <= 0D || sourceSize.Height <= 0D)
sourceSize = new Size(24D, 24D);
if (stretch == Stretch.None)
{
var x = (renderSize.Width - sourceSize.Width) / 2D;
var y = (renderSize.Height - sourceSize.Height) / 2D;
return new Rect(x, y, sourceSize.Width, sourceSize.Height);
}
var scaleX = renderSize.Width / sourceSize.Width;
var scaleY = renderSize.Height / sourceSize.Height;
var scale = stretch switch
{
Stretch.Fill => double.NaN,
Stretch.UniformToFill => Math.Max(scaleX, scaleY),
_ => Math.Min(scaleX, scaleY)
};
var width = stretch == Stretch.Fill ? renderSize.Width : sourceSize.Width * scale;
var height = stretch == Stretch.Fill ? renderSize.Height : sourceSize.Height * scale;
var left = (renderSize.Width - width) / 2D;
var top = (renderSize.Height - height) / 2D;
return new Rect(left, top, width, height);
}
}
@@ -0,0 +1,157 @@
using System;
using System.Windows.Media;
namespace PCL.Core.UI.Controls.SvgIcon;
internal sealed class SvgIconElement
{
public required SvgIconElementKind Kind { get; init; }
public required Geometry Geometry { get; init; }
public required SvgIconStyle Style { get; init; }
public bool PreferStrokeByDefault => Kind is SvgIconElementKind.Line or SvgIconElementKind.Polyline;
public void Draw(DrawingContext context, SvgIconPaintOptions options)
{
if (Style.Opacity <= 0D)
return;
var fill = _ResolveFill(options);
var pen = _ResolvePen(options);
if (fill is null && pen is null)
return;
context.DrawGeometry(fill, pen, Geometry);
}
private Brush? _ResolveFill(SvgIconPaintOptions options)
{
var hasFill = _HasPaint(Style.Fill);
var hasStroke = _HasPaint(Style.Stroke);
var explicitlyNoFill = _IsNone(Style.Fill);
Brush? brush;
if (!options.UseOriginalColor)
{
if (explicitlyNoFill)
return null;
if (!hasFill && (hasStroke || PreferStrokeByDefault))
return null;
brush = options.IconBrush;
}
else
{
if (explicitlyNoFill)
return null;
if (hasFill)
brush = SvgPaintParser.ParseBrush(Style.Fill, options.IconBrush);
else if (!hasStroke && !PreferStrokeByDefault)
brush = Brushes.Black;
else
return null;
}
return _ApplyOpacity(brush, Style.Opacity * Style.FillOpacity);
}
private Pen? _ResolvePen(SvgIconPaintOptions options)
{
var hasStroke = _HasPaint(Style.Stroke);
var explicitlyNoStroke = _IsNone(Style.Stroke);
Brush? brush;
if (!options.UseOriginalColor)
{
if (explicitlyNoStroke)
return null;
if (!hasStroke && !PreferStrokeByDefault)
return null;
brush = options.IconBrush;
}
else
{
if (explicitlyNoStroke)
return null;
if (hasStroke)
brush = SvgPaintParser.ParseBrush(Style.Stroke, options.IconBrush);
else if (PreferStrokeByDefault)
brush = Brushes.Black;
else
return null;
}
return _CreatePen(_ApplyOpacity(brush, Style.Opacity * Style.StrokeOpacity),
Style.StrokeWidth ?? options.StrokeThickness);
}
private Pen? _CreatePen(Brush? brush, double thickness)
{
if (brush is null || thickness <= 0D)
return null;
return new Pen(brush, thickness)
{
StartLineCap = _ParseLineCap(Style.StrokeLineCap),
EndLineCap = _ParseLineCap(Style.StrokeLineCap),
LineJoin = _ParseLineJoin(Style.StrokeLineJoin)
};
}
private static Brush? _ApplyOpacity(Brush? brush, double opacity)
{
if (brush is null)
return null;
opacity = Math.Clamp(opacity, 0D, 1D);
if (opacity <= 0D)
return null;
if (Math.Abs(opacity - 1D) < 0.0001D)
return brush;
var clone = brush.CloneCurrentValue();
clone.Opacity *= opacity;
if (clone.CanFreeze)
clone.Freeze();
return clone;
}
private static bool _HasPaint(string? value)
{
return !string.IsNullOrWhiteSpace(value) && !_IsNone(value);
}
private static bool _IsNone(string? value)
{
return string.Equals(value?.Trim(), "none", StringComparison.OrdinalIgnoreCase);
}
private static PenLineCap _ParseLineCap(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"butt" => PenLineCap.Flat,
"square" => PenLineCap.Square,
"round" => PenLineCap.Round,
_ => PenLineCap.Round
};
}
private static PenLineJoin _ParseLineJoin(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"miter" => PenLineJoin.Miter,
"bevel" => PenLineJoin.Bevel,
"round" => PenLineJoin.Round,
_ => PenLineJoin.Round
};
}
}
@@ -0,0 +1,12 @@
namespace PCL.Core.UI.Controls.SvgIcon;
internal enum SvgIconElementKind
{
Path,
Line,
Circle,
Ellipse,
Rect,
Polyline,
Polygon
}
@@ -0,0 +1,123 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Text;
using System.Windows;
using PCL.Core.Logging;
namespace PCL.Core.UI.Controls.SvgIcon;
public static class SvgIconLoader
{
public const string DefaultIconPack = "default";
private static readonly string _AssemblyName =
typeof(SvgIconLoader).Assembly.GetName().Name ?? "PCL.Core";
private static readonly ConcurrentDictionary<string, Lazy<SvgIconModel?>> _Cache =
new(StringComparer.OrdinalIgnoreCase);
internal static SvgIconModel? Load(string? icon, string? defaultPack = null)
{
var key = SvgIconKey.TryParse(icon, defaultPack ?? DefaultIconPack);
if (key is null)
{
if (!string.IsNullOrWhiteSpace(icon))
_LogDebug($"无效的 SVG 图标标识:{icon}");
return null;
}
var cacheKey = key.Value.ToString();
return _Cache.GetOrAdd(cacheKey, _ => new Lazy<SvgIconModel?>(() => _LoadCore(key.Value))).Value;
}
public static void ClearCache()
{
_Cache.Clear();
}
private static SvgIconModel? _LoadCore(SvgIconKey key)
{
try
{
var uri = new Uri(
$"pack://application:,,,/{_AssemblyName};component/UI/Assets/IconPacks/{key.Pack}/{key.Name}.svg",
UriKind.Absolute);
var info = Application.GetResourceStream(uri);
if (info is null)
{
_LogDebug($"缺少 SVG 图标资源:{key} ({uri})");
return null;
}
using var stream = info.Stream;
using var reader = new StreamReader(stream, Encoding.UTF8, true);
var svg = reader.ReadToEnd();
return SvgIconParser.Parse(svg);
}
catch (Exception ex)
{
_LogDebug($"加载 SVG 图标失败:{key}", ex);
return null;
}
}
private static void _LogDebug(string message, Exception? ex = null)
{
#if DEBUG
if (ex is null)
LogWrapper.Debug("SvgIcon", message);
else
LogWrapper.Debug(ex, "SvgIcon", message);
#endif
}
private readonly record struct SvgIconKey(string Pack, string Name)
{
public static SvgIconKey? TryParse(string? icon, string defaultPack)
{
if (string.IsNullOrWhiteSpace(icon))
return null;
var normalized = icon.Trim().Replace('\\', '/');
if (normalized.EndsWith(".svg", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^4];
normalized = normalized.Trim('/');
if (normalized.Length == 0 || normalized.Contains("..", StringComparison.Ordinal))
return null;
var parts = normalized.Split('/', 2,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var pack = parts.Length == 2 ? parts[0] : defaultPack;
var name = parts.Length == 2 ? parts[1] : parts[0];
if (!_IsSafeResourcePath(pack) || !_IsSafeResourcePath(name))
return null;
return new SvgIconKey(pack, name);
}
public override string ToString()
{
return $"{Pack}/{Name}";
}
private static bool _IsSafeResourcePath(string value)
{
if (string.IsNullOrWhiteSpace(value) || value.StartsWith('/') || value.EndsWith('/'))
return false;
return value.Split('/', StringSplitOptions.RemoveEmptyEntries).All(_IsSafeResourceSegment);
}
private static bool _IsSafeResourceSegment(string value)
{
if (string.IsNullOrWhiteSpace(value) || value is "." or "..")
return false;
return value.All(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.');
}
}
}
@@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace PCL.Core.UI.Controls.SvgIcon;
internal sealed class SvgIconModel
{
public double MinX { get; init; }
public double MinY { get; init; }
public double Width { get; init; } = 24D;
public double Height { get; init; } = 24D;
public IReadOnlyList<SvgIconElement> Elements { get; init; } = [];
}
@@ -0,0 +1,8 @@
using System.Windows.Media;
namespace PCL.Core.UI.Controls.SvgIcon;
internal readonly record struct SvgIconPaintOptions(
Brush IconBrush,
double StrokeThickness,
bool UseOriginalColor);
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Xml;
using System.Xml.Linq;
using PCL.Core.Logging;
namespace PCL.Core.UI.Controls.SvgIcon;
internal static class SvgIconParser
{
public static SvgIconModel Parse(string svg)
{
using var stringReader = new StringReader(svg);
using var xmlReader = XmlReader.Create(stringReader, new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
});
var document = XDocument.Load(xmlReader, LoadOptions.None);
var root = document.Root ?? throw new FormatException("SVG 文件缺少根节点。");
var (minX, minY, width, height) = _ReadViewBox(root);
var elements = new List<SvgIconElement>();
_ReadElements(root, elements, new SvgIconStyle());
return new SvgIconModel
{
MinX = minX,
MinY = minY,
Width = width <= 0D ? 24D : width,
Height = height <= 0D ? 24D : height,
Elements = elements
};
}
private static void _ReadElements(
XElement parent,
ICollection<SvgIconElement> result,
SvgIconStyle inheritedStyle)
{
var parentStyle = inheritedStyle.Merge(parent);
foreach (var element in parent.Elements())
{
var name = element.Name.LocalName;
if (name is "g" or "svg")
{
_ReadElements(element, result, parentStyle);
continue;
}
var style = parentStyle.Merge(element);
var iconElement = _CreateElement(name, element, style);
if (iconElement is not null)
result.Add(iconElement);
}
}
private static SvgIconElement? _CreateElement(string name, XElement element, SvgIconStyle style)
{
try
{
return name switch
{
"path" => _CreatePath(element, style),
"line" => _CreateLine(element, style),
"circle" => _CreateCircle(element, style),
"ellipse" => _CreateEllipse(element, style),
"rect" => _CreateRect(element, style),
"polyline" => _CreatePolyline(element, style),
"polygon" => _CreatePolygon(element, style),
_ => null
};
}
catch (Exception ex)
{
#if DEBUG
var descriptor = name;
var d = _Attr(element, "d");
if (!string.IsNullOrWhiteSpace(d))
descriptor += $" d=\"{(d.Length > 80 ? d[..80] + "..." : d)}\"";
LogWrapper.Debug(ex, "SvgIcon", $"跳过无法解析的 SVG 元素:{descriptor}");
#endif
// 单个图元解析失败时跳过,避免一个不兼容节点导致整个图标不可用。
return null;
}
}
private static SvgIconElement? _CreatePath(XElement element, SvgIconStyle style)
{
var d = _Attr(element, "d");
if (string.IsNullOrWhiteSpace(d))
return null;
var geometry = SvgPathGeometryParser.Parse(d);
_ApplyFillRule(geometry, style);
_TryFreeze(geometry);
return new SvgIconElement
{
Kind = SvgIconElementKind.Path,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement _CreateLine(XElement element, SvgIconStyle style)
{
var geometry = new LineGeometry(
new Point(_Number(element, "x1"), _Number(element, "y1")),
new Point(_Number(element, "x2"), _Number(element, "y2")));
_TryFreeze(geometry);
return new SvgIconElement
{
Kind = SvgIconElementKind.Line,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement _CreateCircle(XElement element, SvgIconStyle style)
{
var geometry = new EllipseGeometry(
new Point(_Number(element, "cx"), _Number(element, "cy")),
_Number(element, "r"),
_Number(element, "r"));
_TryFreeze(geometry);
return new SvgIconElement
{
Kind = SvgIconElementKind.Circle,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement _CreateEllipse(XElement element, SvgIconStyle style)
{
var geometry = new EllipseGeometry(
new Point(_Number(element, "cx"), _Number(element, "cy")),
_Number(element, "rx"),
_Number(element, "ry"));
_TryFreeze(geometry);
return new SvgIconElement
{
Kind = SvgIconElementKind.Ellipse,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement _CreateRect(XElement element, SvgIconStyle style)
{
var x = _Number(element, "x");
var y = _Number(element, "y");
var width = _Number(element, "width");
var height = _Number(element, "height");
var rx = _Number(element, "rx", _Number(element, "ry"));
var ry = _Number(element, "ry", rx);
var geometry = new RectangleGeometry(new Rect(x, y, width, height), rx, ry);
_TryFreeze(geometry);
return new SvgIconElement
{
Kind = SvgIconElementKind.Rect,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement? _CreatePolyline(XElement element, SvgIconStyle style)
{
var geometry = _CreatePointsGeometry(_Attr(element, "points"), false);
if (geometry is null)
return null;
return new SvgIconElement
{
Kind = SvgIconElementKind.Polyline,
Geometry = geometry,
Style = style
};
}
private static SvgIconElement? _CreatePolygon(XElement element, SvgIconStyle style)
{
var geometry = _CreatePointsGeometry(_Attr(element, "points"), true);
if (geometry is null)
return null;
return new SvgIconElement
{
Kind = SvgIconElementKind.Polygon,
Geometry = geometry,
Style = style
};
}
private static StreamGeometry? _CreatePointsGeometry(string? points, bool close)
{
var numbers = SvgNumberParser.ParseNumberList(points);
if (numbers.Length < 4)
return null;
var geometry = new StreamGeometry
{
FillRule = close ? FillRule.Nonzero : FillRule.EvenOdd
};
using (var context = geometry.Open())
{
context.BeginFigure(new Point(numbers[0], numbers[1]), close, close);
for (var i = 2; i + 1 < numbers.Length; i += 2)
context.LineTo(new Point(numbers[i], numbers[i + 1]), true, false);
}
_TryFreeze(geometry);
return geometry;
}
private static (double MinX, double MinY, double Width, double Height) _ReadViewBox(XElement root)
{
var viewBox = _Attr(root, "viewBox");
var numbers = SvgNumberParser.ParseNumberList(viewBox);
if (numbers.Length == 4)
return (numbers[0], numbers[1], numbers[2], numbers[3]);
var width = SvgNumberParser.TryParseNullable(_Attr(root, "width")) ?? 24D;
var height = SvgNumberParser.TryParseNullable(_Attr(root, "height")) ?? 24D;
return (0D, 0D, width, height);
}
private static double _Number(XElement element, string name, double fallback = 0D)
{
return SvgNumberParser.TryParse(_Attr(element, name), fallback);
}
private static string? _Attr(XElement element, string name)
{
return element.Attribute(name)?.Value;
}
private static void _ApplyFillRule(Geometry geometry, SvgIconStyle style)
{
var fillRule = style.FillRule?.Trim().ToLowerInvariant() switch
{
"evenodd" => FillRule.EvenOdd,
_ => FillRule.Nonzero
};
switch (geometry)
{
case StreamGeometry streamGeometry:
streamGeometry.FillRule = fillRule;
break;
case PathGeometry pathGeometry:
pathGeometry.FillRule = fillRule;
break;
}
}
private static void _TryFreeze(Freezable freezable)
{
if (freezable.CanFreeze)
freezable.Freeze();
}
}
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Xml.Linq;
namespace PCL.Core.UI.Controls.SvgIcon;
internal sealed record SvgIconStyle
{
public string? Fill { get; init; }
public string? Stroke { get; init; }
public string? StrokeLineCap { get; init; }
public string? StrokeLineJoin { get; init; }
public string? FillRule { get; init; }
public double? StrokeWidth { get; init; }
public double Opacity { get; init; } = 1D;
public double FillOpacity { get; init; } = 1D;
public double StrokeOpacity { get; init; } = 1D;
public SvgIconStyle Merge(XElement element)
{
var inlineStyle = _ParseStyle(element.Attribute("style")?.Value);
var opacity = _GetAttributeOrStyle(element, inlineStyle, "opacity");
var fillOpacity = _GetAttributeOrStyle(element, inlineStyle, "fill-opacity");
var strokeOpacity = _GetAttributeOrStyle(element, inlineStyle, "stroke-opacity");
return new SvgIconStyle
{
Fill = _GetAttributeOrStyle(element, inlineStyle, "fill") ?? Fill,
Stroke = _GetAttributeOrStyle(element, inlineStyle, "stroke") ?? Stroke,
StrokeLineCap = _GetAttributeOrStyle(element, inlineStyle, "stroke-linecap") ?? StrokeLineCap,
StrokeLineJoin = _GetAttributeOrStyle(element, inlineStyle, "stroke-linejoin") ?? StrokeLineJoin,
FillRule = _GetAttributeOrStyle(element, inlineStyle, "fill-rule") ?? FillRule,
StrokeWidth = SvgNumberParser.TryParseNullable(
_GetAttributeOrStyle(element, inlineStyle, "stroke-width")) ?? StrokeWidth,
Opacity = Opacity * SvgNumberParser.TryParse(opacity, 1D),
FillOpacity = SvgNumberParser.TryParseNullable(fillOpacity) ?? FillOpacity,
StrokeOpacity = SvgNumberParser.TryParseNullable(strokeOpacity) ?? StrokeOpacity
};
}
private static string? _GetAttributeOrStyle(
XElement element,
IReadOnlyDictionary<string, string> style,
string name)
{
var directValue = element.Attribute(name)?.Value;
if (!string.IsNullOrWhiteSpace(directValue))
return directValue.Trim();
return style.TryGetValue(name, out var styleValue) && !string.IsNullOrWhiteSpace(styleValue)
? styleValue.Trim()
: null;
}
private static Dictionary<string, string> _ParseStyle(string? value)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(value))
return result;
foreach (var part in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var pair = part.Split(':', 2, StringSplitOptions.TrimEntries);
if (pair is [{ Length: > 0 }, _])
result[pair[0]] = pair[1];
}
return result;
}
}
@@ -0,0 +1,43 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace PCL.Core.UI.Controls.SvgIcon;
internal static partial class SvgNumberParser
{
[GeneratedRegex(@"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")]
private static partial Regex _NumberRegex();
public static double TryParse(string? value, double fallback = 0D)
{
var parsed = TryParseNullable(value);
return parsed ?? fallback;
}
public static double? TryParseNullable(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var match = _NumberRegex().Match(value.Trim());
if (!match.Success)
return null;
return double.TryParse(match.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
? result
: null;
}
public static double[] ParseNumberList(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return [];
var matches = _NumberRegex().Matches(value);
var result = new double[matches.Count];
for (var i = 0; i < matches.Count; i++)
result[i] = double.Parse(matches[i].Value, NumberStyles.Float, CultureInfo.InvariantCulture);
return result;
}
}
@@ -0,0 +1,95 @@
using System;
using System.Globalization;
using System.Windows.Media;
namespace PCL.Core.UI.Controls.SvgIcon;
internal static class SvgPaintParser
{
private static readonly BrushConverter _BrushConverter = new();
public static Brush? ParseBrush(string? value, Brush currentColorBrush)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var normalized = value.Trim();
if (normalized.Equals("none", StringComparison.OrdinalIgnoreCase))
return null;
if (normalized.Equals("currentColor", StringComparison.OrdinalIgnoreCase))
return currentColorBrush;
if (_TryParseRgbFunction(normalized, out var rgbBrush))
return rgbBrush;
try
{
return _BrushConverter.ConvertFromInvariantString(normalized) as Brush;
}
catch
{
return currentColorBrush;
}
}
private static bool _TryParseRgbFunction(string value, out Brush brush)
{
brush = null!;
if (!value.StartsWith("rgb", StringComparison.OrdinalIgnoreCase))
return false;
var start = value.IndexOf('(');
var end = value.LastIndexOf(')');
if (start < 0 || end <= start)
return false;
var parts = value[(start + 1)..end]
.Split([',', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 3)
return false;
var r = _ParseColorComponent(parts[0]);
var g = _ParseColorComponent(parts[1]);
var b = _ParseColorComponent(parts[2]);
var a = parts.Length >= 4 ? _ParseAlpha(parts[3]) : (byte)255;
if (r is null || g is null || b is null)
return false;
brush = new SolidColorBrush(Color.FromArgb(a, r.Value, g.Value, b.Value));
return true;
}
private static byte? _ParseColorComponent(string value)
{
if (value.EndsWith('%'))
{
if (!double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
return null;
return (byte)Math.Clamp(Math.Round(percent / 100D * 255D), 0D, 255D);
}
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)
? (byte)Math.Clamp(Math.Round(raw), 0D, 255D)
: null;
}
private static byte _ParseAlpha(string value)
{
if (value.EndsWith('%'))
{
if (double.TryParse(value[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var percent))
return (byte)Math.Clamp(Math.Round(percent / 100D * 255D), 0D, 255D);
return 255;
}
return double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var raw)
? (byte)Math.Clamp(Math.Round(raw <= 1D ? raw * 255D : raw), 0D, 255D)
: (byte)255;
}
}
@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
namespace PCL.Core.UI.Controls.SvgIcon;
internal static class SvgPathGeometryParser
{
public static Geometry Parse(string data)
{
var parser = new Parser(data);
return parser.Parse();
}
private static List<Token> _Tokenize(string data)
{
var tokens = new List<Token>();
var index = 0;
while (index < data.Length)
{
var ch = data[index];
if (char.IsWhiteSpace(ch) || ch == ',')
{
index++;
continue;
}
if (_IsCommand(ch))
{
tokens.Add(new Token(TokenKind.Command, ch, 0D));
index++;
continue;
}
var start = index;
if (ch is '+' or '-')
index++;
var hasDigit = false;
while (index < data.Length && char.IsDigit(data[index]))
{
index++;
hasDigit = true;
}
if (index < data.Length && data[index] == '.')
{
index++;
while (index < data.Length && char.IsDigit(data[index]))
{
index++;
hasDigit = true;
}
}
if (!hasDigit)
throw new FormatException($"Invalid SVG path number near index {start}.");
if (index < data.Length && data[index] is 'e' or 'E')
{
var exponentStart = index;
index++;
if (index < data.Length && data[index] is '+' or '-')
index++;
var exponentHasDigit = false;
while (index < data.Length && char.IsDigit(data[index]))
{
index++;
exponentHasDigit = true;
}
if (!exponentHasDigit)
index = exponentStart;
}
var numberText = data[start..index];
tokens.Add(new Token(
TokenKind.Number,
'\0',
double.Parse(numberText, NumberStyles.Float, CultureInfo.InvariantCulture)));
}
return tokens;
}
private static bool _IsCommand(char ch)
{
return ch is 'M' or 'm'
or 'Z' or 'z'
or 'L' or 'l'
or 'H' or 'h'
or 'V' or 'v'
or 'C' or 'c'
or 'S' or 's'
or 'Q' or 'q'
or 'T' or 't'
or 'A' or 'a';
}
private enum TokenKind
{
Command,
Number
}
private readonly record struct Token(TokenKind Kind, char Command, double Number);
private sealed class Parser(string data)
{
private readonly PathGeometry _geometry = new();
private readonly List<Token> _tokens = _Tokenize(data);
private Point _current;
private PathFigure? _figure;
private bool _figureOpen;
private Point _figureStart;
private int _index;
private char _lastCommand;
private Point? _lastCubicControl;
private Point? _lastQuadraticControl;
public Geometry Parse()
{
var command = '\0';
while (_index < _tokens.Count)
{
if (_PeekCommand(out var nextCommand))
{
command = nextCommand;
_index++;
}
else if (command == '\0')
{
throw new FormatException("SVG path data must start with a command.");
}
_ExecuteCommand(command);
}
return _geometry;
}
private void _ExecuteCommand(char command)
{
switch (command)
{
case 'M':
case 'm':
_MoveTo(command == 'm');
break;
case 'L':
case 'l':
_LineTo(command == 'l');
break;
case 'H':
case 'h':
_HorizontalLineTo(command == 'h');
break;
case 'V':
case 'v':
_VerticalLineTo(command == 'v');
break;
case 'C':
case 'c':
_CubicBezierTo(command == 'c');
break;
case 'S':
case 's':
_SmoothCubicBezierTo(command == 's');
break;
case 'Q':
case 'q':
_QuadraticBezierTo(command == 'q');
break;
case 'T':
case 't':
_SmoothQuadraticBezierTo(command == 't');
break;
case 'A':
case 'a':
_ArcTo(command == 'a');
break;
case 'Z':
case 'z':
_CloseFigure();
break;
default:
throw new FormatException($"Unsupported SVG path command: {command}");
}
}
private void _MoveTo(bool relative)
{
if (!_HasNumber())
return;
var first = _ReadPoint(relative);
_BeginFigure(first);
_SetLastCommand('M');
// SVG 规范:M/m 后续坐标对等同 L/l。
while (_HasNumber())
_AddLine(_ReadPoint(relative));
}
private void _LineTo(bool relative)
{
while (_HasNumber())
_AddLine(_ReadPoint(relative));
_SetLastCommand('L');
}
private void _HorizontalLineTo(bool relative)
{
while (_HasNumber())
{
var x = _ReadNumber();
_AddLine(new Point(relative ? _current.X + x : x, _current.Y));
}
_SetLastCommand('H');
}
private void _VerticalLineTo(bool relative)
{
while (_HasNumber())
{
var y = _ReadNumber();
_AddLine(_current with { Y = relative ? _current.Y + y : y });
}
_SetLastCommand('V');
}
private void _CubicBezierTo(bool relative)
{
while (_HasNumber())
{
var control1 = _ReadPoint(relative);
var control2 = _ReadPoint(relative);
var end = _ReadPoint(relative);
_EnsureFigure();
_figure!.Segments.Add(new BezierSegment(control1, control2, end, true));
_current = end;
_lastCubicControl = control2;
_lastQuadraticControl = null;
_lastCommand = 'C';
}
}
private void _SmoothCubicBezierTo(bool relative)
{
while (_HasNumber())
{
var control1 = _lastCommand is 'C' or 'S' && _lastCubicControl is not null
? _Reflect(_lastCubicControl.Value, _current)
: _current;
var control2 = _ReadPoint(relative);
var end = _ReadPoint(relative);
_EnsureFigure();
_figure!.Segments.Add(new BezierSegment(control1, control2, end, true));
_current = end;
_lastCubicControl = control2;
_lastQuadraticControl = null;
_lastCommand = 'S';
}
}
private void _QuadraticBezierTo(bool relative)
{
while (_HasNumber())
{
var control = _ReadPoint(relative);
var end = _ReadPoint(relative);
_EnsureFigure();
_figure!.Segments.Add(new QuadraticBezierSegment(control, end, true));
_current = end;
_lastQuadraticControl = control;
_lastCubicControl = null;
_lastCommand = 'Q';
}
}
private void _SmoothQuadraticBezierTo(bool relative)
{
while (_HasNumber())
{
var control = _lastCommand is 'Q' or 'T' && _lastQuadraticControl is not null
? _Reflect(_lastQuadraticControl.Value, _current)
: _current;
var end = _ReadPoint(relative);
_EnsureFigure();
_figure!.Segments.Add(new QuadraticBezierSegment(control, end, true));
_current = end;
_lastQuadraticControl = control;
_lastCubicControl = null;
_lastCommand = 'T';
}
}
private void _ArcTo(bool relative)
{
while (_HasNumber())
{
var rx = Math.Abs(_ReadNumber());
var ry = Math.Abs(_ReadNumber());
var rotation = _ReadNumber();
var isLargeArc = Math.Abs(_ReadNumber()) > 0D;
var isClockwise = Math.Abs(_ReadNumber()) > 0D;
var end = _ReadPoint(relative);
_EnsureFigure();
if (rx <= 0D || ry <= 0D)
_figure!.Segments.Add(new LineSegment(end, true));
else
_figure!.Segments.Add(new ArcSegment(
end,
new Size(rx, ry),
rotation,
isLargeArc,
isClockwise ? SweepDirection.Clockwise : SweepDirection.Counterclockwise,
true));
_current = end;
_lastCubicControl = null;
_lastQuadraticControl = null;
_lastCommand = 'A';
}
}
private void _CloseFigure()
{
if (_figureOpen && _figure is not null)
{
_figure.IsClosed = true;
_current = _figureStart;
_figureOpen = false;
}
_SetLastCommand('Z');
}
private void _BeginFigure(Point point)
{
_figure = new PathFigure
{
StartPoint = point,
IsClosed = false,
IsFilled = true
};
_geometry.Figures.Add(_figure);
_current = point;
_figureStart = point;
_figureOpen = true;
_lastCubicControl = null;
_lastQuadraticControl = null;
}
private void _EnsureFigure()
{
if (_figureOpen && _figure is not null)
return;
_BeginFigure(_current);
}
private void _AddLine(Point point)
{
_EnsureFigure();
_figure!.Segments.Add(new LineSegment(point, true));
_current = point;
_lastCubicControl = null;
_lastQuadraticControl = null;
_lastCommand = 'L';
}
private Point _ReadPoint(bool relative)
{
var x = _ReadNumber();
var y = _ReadNumber();
return relative ? new Point(_current.X + x, _current.Y + y) : new Point(x, y);
}
private double _ReadNumber()
{
return _HasNumber()
? _tokens[_index++].Number
: throw new FormatException("Expected number in SVG path data.");
}
private bool _HasNumber()
{
return _index < _tokens.Count && _tokens[_index].Kind == TokenKind.Number;
}
private bool _PeekCommand(out char command)
{
if (_index < _tokens.Count && _tokens[_index].Kind == TokenKind.Command)
{
command = _tokens[_index].Command;
return true;
}
command = '\0';
return false;
}
private void _SetLastCommand(char command)
{
_lastCommand = command;
if (command is not ('C' or 'S'))
_lastCubicControl = null;
if (command is not ('Q' or 'T'))
_lastQuadraticControl = null;
}
private static Point _Reflect(Point point, Point around)
{
return new Point(around.X * 2D - point.X, around.Y * 2D - point.Y);
}
}
}