初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user