From 64ea100e7e47d5aa077e578e5f858862bbe82b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:55:05 +0200 Subject: [PATCH 01/21] docs: plan SvgML foreignObject controls --- ...ml-foreignobject-inline-controls-update.md | 77 +++++++++++++++++++ plan/svgml-inline-ui-controls-plan.md | 5 ++ 2 files changed, 82 insertions(+) create mode 100644 plan/svgml-foreignobject-inline-controls-update.md create mode 100644 plan/svgml-inline-ui-controls-plan.md diff --git a/plan/svgml-foreignobject-inline-controls-update.md b/plan/svgml-foreignobject-inline-controls-update.md new file mode 100644 index 0000000000..a9ca5becd2 --- /dev/null +++ b/plan/svgml-foreignobject-inline-controls-update.md @@ -0,0 +1,77 @@ +# SvgML `foreignObject` Inline Controls Update + +Update date: 2026-04-21 + +## Goal + +Use SVG `foreignObject` as the idiomatic SvgML host for native inline controls on Avalonia, Uno, and .NET MAUI. + +`InlineUIContainer` has been removed; `foreignObject` is the single native-control host element. + +## SVG Basis + +- MDN describes `foreignObject` as the SVG element for including content from another namespace, most commonly HTML in browsers: https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/foreignObject +- SVG 1.1 defines `x`, `y`, `width`, and `height` as the rectangular region into which the foreign content is placed: https://www.w3.org/TR/SVG11/extend.html#ForeignObjectElement +- SVG 1.1 also specifies that `x`/`y` default to `0`, and zero `width` or `height` disables rendering. + +SvgML maps those rules onto native UI hosting: + +- non-inline `foreignObject` uses its SVG rectangular bounds +- inline `foreignObject` reserves text-flow space from explicit `width`/`height` or the measured native control size +- missing non-inline `width`/`height` are written from the native desired size when a child control exists + +## Architecture + +### Shared model + +`foreignObject` implements the hosted-control contract: + +- `HostedControl` returns the platform-native child +- `GetHostedControlSize()` returns explicit SVG size when set, otherwise native desired size +- a generated stable mapping id is emitted when the author did not set `id` + +When a `foreignObject` is authored inside SVG text, it serializes as a `tspan` placeholder with: + +- generated or explicit id for retained-scene mapping +- invisible placeholder glyph +- `font-size` from resolved slot height +- `textLength` from resolved slot width +- `lengthAdjust="spacingAndGlyphs"` + +This lets the SVG text engine reserve inline advance while the real native control is arranged by the platform overlay. + +### Scene graph + +`SvgForeignObject` is retained as a non-rendering scene node, but the scene compiler now assigns geometry bounds from `x`, `y`, `width`, and `height`. + +This is required for non-inline hosted controls because the native child is not serialized into the SVG document and therefore does not contribute child geometry. + +### Platform overlays + +All platforms now use one hosted-control layout contract: + +- enumerate hosted controls from the SvgML source tree +- detect inline placement by walking from the hosted element to an owning `text_base` +- compute inline slot positions from the source text tree +- transform SVG picture-space bounds into platform control coordinates +- measure and arrange the native child in that slot + +Avalonia hosts controls as logical/visual children of the root `svg`. + +Uno hosts controls through retained popups positioned from the transformed SVG slot. + +MAUI hosts controls in an `AbsoluteLayout` overlay above the Skia drawing surface. + +## XML Namespaces + +SvgML assemblies expose the `SvgML` CLR namespace through `https://github.com/svgml` where the XAML stack supports public assembly-level XML namespace definitions. + +Avalonia also keeps the existing `https://github.com/avaloniaui` mapping so SvgML elements can be used unprefixed in Avalonia markup that already has Avalonia as the default namespace. + +Uno uses the WinUI/Uno-supported `using:SvgML` XAML namespace form because Uno's `XmlnsDefinitionAttribute` is not publicly usable by application/library code. + +## Samples + +The Avalonia, Uno, and MAUI demos now demonstrate inline controls using `foreignObject`. + +Uno sample markup relies on measured native size because Uno XAML does not currently convert literal `SvgUnit` values for `foreignObject` attributes. diff --git a/plan/svgml-inline-ui-controls-plan.md b/plan/svgml-inline-ui-controls-plan.md new file mode 100644 index 0000000000..5f7fbed36a --- /dev/null +++ b/plan/svgml-inline-ui-controls-plan.md @@ -0,0 +1,5 @@ +# SvgML Inline UI Controls Plan + +Superseded by `svgml-foreignobject-inline-controls-update.md`. + +The initial implementation plan used a custom `InlineUIContainer` element as the public inline native-control host. The current design removes that custom element and uses SVG `foreignObject` as the single idiomatic public API for Avalonia, Uno, and MAUI. From af997bc137ae25d7b1b3f43f253388432e7fe0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:55:23 +0200 Subject: [PATCH 02/21] feat(svgml): add foreignObject inline model --- src/Svg.SceneGraph/SvgSceneCompiler.cs | 86 +++++++++++++ src/Svg.SceneGraph/SvgSceneTextCompiler.cs | 10 ++ .../foreignObject.Properties.g.cs | 36 ++++++ .../Extensibility/foreignObject.Writer.g.cs | 20 +++ .../Extensibility/foreignObject.Writer.cs | 71 +++++++++++ .../Manual/Hosting/HostedControlElement.cs | 117 ++++++++++++++++++ src/SvgML.Avalonia/Manual/content.Writer.cs | 2 +- src/SvgML.Avalonia/Manual/content.cs | 2 +- src/SvgML.Avalonia/Manual/element.Mapping.cs | 6 + src/SvgML.Avalonia/Manual/elements.cs | 19 ++- .../Definitions/SvgTypeDefs.cs | 4 + src/SvgML.CodeGenerator/Program.cs | 4 +- .../foreignObject.Properties.g.cs | 36 ++++++ .../Extensibility/foreignObject.Writer.g.cs | 20 +++ .../foreignObject.Properties.g.cs | 52 ++++++++ .../Extensibility/foreignObject.Writer.g.cs | 20 +++ 16 files changed, 498 insertions(+), 7 deletions(-) create mode 100644 src/SvgML.Avalonia/Manual/Extensibility/foreignObject.Writer.cs create mode 100644 src/SvgML.Avalonia/Manual/Hosting/HostedControlElement.cs create mode 100644 src/SvgML.Avalonia/Manual/element.Mapping.cs diff --git a/src/Svg.SceneGraph/SvgSceneCompiler.cs b/src/Svg.SceneGraph/SvgSceneCompiler.cs index c70877feec..c3998bee40 100644 --- a/src/Svg.SceneGraph/SvgSceneCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneCompiler.cs @@ -92,6 +92,25 @@ public static bool TryCompile( out sceneDocument); } + public static bool TryMeasureTextBounds( + SvgTextBase svgTextBase, + SKRect viewport, + ISvgAssetLoader assetLoader, + out SKRect geometryBounds) + { + if (svgTextBase is null) + { + throw new ArgumentNullException(nameof(svgTextBase)); + } + + if (assetLoader is null) + { + throw new ArgumentNullException(nameof(assetLoader)); + } + + return SvgSceneTextCompiler.TryMeasureGeometryBounds(svgTextBase, viewport, assetLoader, out geometryBounds); + } + private static bool TryCompile( SvgDocument? sourceDocument, SKRect cullRect, @@ -892,6 +911,9 @@ private static void FinalizeDirectStructuralNode( case SvgSwitch svgSwitch: FinalizeDirectSwitchNode(node, svgSwitch, parentTotalTransform, ignoreAttributes); break; + case SvgForeignObject svgForeignObject: + FinalizeDirectForeignObjectNode(node, svgForeignObject, viewport, parentTotalTransform); + break; case SvgFragment svgFragment when element is not SvgDocument: FinalizeDirectFragmentNode(node, svgFragment, viewport, parentTotalTransform, ignoreAttributes); break; @@ -945,6 +967,70 @@ private static void FinalizeDirectFragmentNode( FinalizeDirectStructuralBounds(node, parentTotalTransform); } + private static void FinalizeDirectForeignObjectNode( + SvgSceneNode node, + SvgForeignObject svgForeignObject, + SKRect viewport, + SKMatrix parentTotalTransform) + { + if (!TryGetForeignObjectBounds(svgForeignObject, viewport, out var bounds)) + { + FinalizeDirectStructuralBounds(node, parentTotalTransform); + return; + } + + node.GeometryBounds = bounds; + node.TotalTransform = parentTotalTransform.PreConcat(node.Transform); + node.TransformedBounds = node.TotalTransform.MapRect(bounds); + } + + private static bool TryGetForeignObjectBounds(SvgForeignObject svgForeignObject, SKRect viewport, out SKRect bounds) + { + bounds = default; + + var x = TryGetForeignObjectUnit(svgForeignObject, "x", out var xUnit) + ? xUnit.ToDeviceValue(UnitRenderingType.Horizontal, svgForeignObject, viewport) + : 0f; + var y = TryGetForeignObjectUnit(svgForeignObject, "y", out var yUnit) + ? yUnit.ToDeviceValue(UnitRenderingType.Vertical, svgForeignObject, viewport) + : 0f; + + if (!TryGetForeignObjectUnit(svgForeignObject, "width", out var widthUnit) + || !TryGetForeignObjectUnit(svgForeignObject, "height", out var heightUnit)) + { + return false; + } + + var width = widthUnit.ToDeviceValue(UnitRenderingType.Horizontal, svgForeignObject, viewport); + var height = heightUnit.ToDeviceValue(UnitRenderingType.Vertical, svgForeignObject, viewport); + if (width <= 0f || height <= 0f) + { + return false; + } + + bounds = SKRect.Create(x, y, width, height); + return true; + } + + private static bool TryGetForeignObjectUnit(SvgForeignObject svgForeignObject, string attributeName, out SvgUnit unit) + { + unit = default; + if (!svgForeignObject.TryGetAttribute(attributeName, out var value) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + unit = SvgUnitConverter.Parse(value.AsSpan().Trim()); + return !unit.IsEmpty && !unit.IsNone; + } + catch (FormatException) + { + return false; + } + } + private static void FinalizeDirectStructuralBounds( SvgSceneNode node, SKMatrix parentTotalTransform) diff --git a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs index cbe7cc535c..d4dd2c62da 100644 --- a/src/Svg.SceneGraph/SvgSceneTextCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneTextCompiler.cs @@ -195,6 +195,16 @@ private static void BuildEffectiveValues(float[] values, int index, int count, L } } + internal static bool TryMeasureGeometryBounds( + SvgTextBase svgTextBase, + SKRect viewport, + ISvgAssetLoader assetLoader, + out SKRect geometryBounds) + { + geometryBounds = EstimateGeometryBounds(svgTextBase, viewport, assetLoader); + return !geometryBounds.IsEmpty; + } + public static bool TryCompile( SvgTextBase svgTextBase, SKRect viewport, diff --git a/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Properties.g.cs b/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Properties.g.cs index 13a482e171..ded6f223f2 100644 --- a/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Properties.g.cs +++ b/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Properties.g.cs @@ -6,4 +6,40 @@ namespace SvgML; public partial class foreignObject : visual { protected override string SvgTag => "foreignObject"; + + public static readonly Avalonia.StyledProperty xProperty = + Avalonia.AvaloniaProperty.Register("x"); + + public static readonly Avalonia.StyledProperty yProperty = + Avalonia.AvaloniaProperty.Register("y"); + + public static readonly Avalonia.StyledProperty widthProperty = + Avalonia.AvaloniaProperty.Register("width"); + + public static readonly Avalonia.StyledProperty heightProperty = + Avalonia.AvaloniaProperty.Register("height"); + + public Svg.SvgUnit? x + { + get => GetValue(xProperty); + set => SetValue(xProperty, value); + } + + public Svg.SvgUnit? y + { + get => GetValue(yProperty); + set => SetValue(yProperty, value); + } + + public Svg.SvgUnit? width + { + get => GetValue(widthProperty); + set => SetValue(widthProperty, value); + } + + public Svg.SvgUnit? height + { + get => GetValue(heightProperty); + set => SetValue(heightProperty, value); + } } diff --git a/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Writer.g.cs b/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Writer.g.cs index ce98a9db86..3a2287ad3a 100644 --- a/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Writer.g.cs +++ b/src/SvgML.Avalonia/Generated/Extensibility/foreignObject.Writer.g.cs @@ -8,5 +8,25 @@ public partial class foreignObject protected override void WriteAttributes(TextWriter writer, element parent) { base.WriteAttributes(writer, parent); + + if (x is not null) + { + writer.WriteLine($"x=\"{ToSvgString(x)}\""); + } + + if (y is not null) + { + writer.WriteLine($"y=\"{ToSvgString(y)}\""); + } + + if (width is not null) + { + writer.WriteLine($"width=\"{ToSvgString(width)}\""); + } + + if (height is not null) + { + writer.WriteLine($"height=\"{ToSvgString(height)}\""); + } } } diff --git a/src/SvgML.Avalonia/Manual/Extensibility/foreignObject.Writer.cs b/src/SvgML.Avalonia/Manual/Extensibility/foreignObject.Writer.cs new file mode 100644 index 0000000000..11ba430300 --- /dev/null +++ b/src/SvgML.Avalonia/Manual/Extensibility/foreignObject.Writer.cs @@ -0,0 +1,71 @@ +using System.Globalization; +using System.Security; + +namespace SvgML; + +public partial class foreignObject +{ + // Hangul Filler preserves a stretchable text advance without painting placeholder ink. + private const string PlaceholderGlyph = "\u3164"; + + protected override void Write(TextWriter writer, element parent) + { + if (Child is not null && IsInTextTree()) + { + WriteInlinePlaceholder(writer); + return; + } + + WriteBeginStartTag(writer, parent); + WriteXmlns(writer, parent); + WriteAttributes(writer, parent); + WriteGeneratedMappingId(writer); + WriteMeasuredSizeAttributes(writer); + WriteEndStartTag(writer, parent); + WriteContents(writer, parent); + WriteEndTag(writer, parent); + } + + private void WriteInlinePlaceholder(TextWriter writer) + { + var placeholderSize = GetHostSlotSize().OrFallback(); + var widthValue = placeholderSize.Width.ToString("G7", CultureInfo.InvariantCulture); + var heightValue = placeholderSize.Height.ToString("G7", CultureInfo.InvariantCulture); + + writer.WriteLine(""); + writer.Write(SecurityElement.Escape(PlaceholderGlyph) ?? string.Empty); + writer.WriteLine(""); + } + + private void WriteGeneratedMappingId(TextWriter writer) + { + if (Child is not null && string.IsNullOrWhiteSpace(id)) + { + writer.WriteLine($"id=\"{ToSvgString(EffectiveMappingId)}\""); + } + } + + private void WriteMeasuredSizeAttributes(TextWriter writer) + { + if (Child is null) + { + return; + } + + var size = GetHostSlotSize(); + if (!IsWidthSet() && size.Width > 0D) + { + writer.WriteLine($"width=\"{size.Width.ToString("G7", CultureInfo.InvariantCulture)}\""); + } + + if (!IsHeightSet() && size.Height > 0D) + { + writer.WriteLine($"height=\"{size.Height.ToString("G7", CultureInfo.InvariantCulture)}\""); + } + } +} diff --git a/src/SvgML.Avalonia/Manual/Hosting/HostedControlElement.cs b/src/SvgML.Avalonia/Manual/Hosting/HostedControlElement.cs new file mode 100644 index 0000000000..cd6f69de32 --- /dev/null +++ b/src/SvgML.Avalonia/Manual/Hosting/HostedControlElement.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; + +namespace SvgML; + +internal interface IHostedControlElement +{ + object? HostedControl { get; } + + HostedControlSize GetHostedControlSize(); +} + +internal readonly record struct HostedControlSize(double Width, double Height) +{ + public static HostedControlSize Empty { get; } = new(0D, 0D); + + public bool IsEmpty => Width <= 0D || Height <= 0D; + + public static HostedControlSize From(double width, double height) + { + return width > 0D && height > 0D + ? new HostedControlSize(width, height) + : Empty; + } + + public HostedControlSize OrFallback(double fallbackWidth = 16D, double fallbackHeight = 16D) + { + return IsEmpty ? new HostedControlSize(fallbackWidth, fallbackHeight) : this; + } +} + +internal static class HostedControlTree +{ + public static IEnumerable<(element Element, IHostedControlElement Host)> Enumerate(element root) + { + if (root is IHostedControlElement host && host.HostedControl is not null) + { + yield return (root, host); + } + + foreach (var child in root.Children) + { + foreach (var entry in Enumerate(child)) + { + yield return entry; + } + } + } +} + +public partial class foreignObject : IHostedControlElement +{ + private readonly string _generatedMappingId = $"svgml-foreign-object-{System.Guid.NewGuid():N}"; + + internal string EffectiveMappingId => GetSvgMappingId() ?? _generatedMappingId; + + internal override string? GetSvgMappingId() + { + return string.IsNullOrWhiteSpace(id) ? _generatedMappingId : id; + } + + object? IHostedControlElement.HostedControl => Child; + + HostedControlSize IHostedControlElement.GetHostedControlSize() + { + return GetHostSlotSize().OrFallback(); + } + + internal HostedControlSize GetHostSlotSize() + { + var measured = MeasureHostedControl(); + var widthValue = TryGetPositiveLength(width, out var explicitWidth) + ? explicitWidth + : measured.Width; + var heightValue = TryGetPositiveLength(height, out var explicitHeight) + ? explicitHeight + : measured.Height; + + return HostedControlSize.From(widthValue, heightValue); + } + + internal partial HostedControlSize MeasureHostedControl(); + + internal partial bool IsWidthSet(); + + internal partial bool IsHeightSet(); + + internal partial bool IsInTextTree(); + + private static bool TryGetPositiveLength(Svg.SvgUnit? unit, out double value) + { + value = 0D; + return unit is { } actual && TryGetPositiveLength(actual, out value); + } + + private static bool TryGetPositiveLength(Svg.SvgUnit unit, out double value) + { + value = 0D; + + if (unit.IsEmpty || unit.IsNone || unit.Value <= 0f) + { + return false; + } + + value = unit.Type switch + { + Svg.SvgUnitType.Pixel or Svg.SvgUnitType.User => unit.Value, + Svg.SvgUnitType.Inch => unit.Value * 96D, + Svg.SvgUnitType.Centimeter => unit.Value * 96D / 2.54D, + Svg.SvgUnitType.Millimeter => unit.Value * 96D / 25.4D, + Svg.SvgUnitType.Pica => unit.Value * 16D, + Svg.SvgUnitType.Point => unit.Value * 96D / 72D, + _ => 0D + }; + + return value > 0D; + } +} diff --git a/src/SvgML.Avalonia/Manual/content.Writer.cs b/src/SvgML.Avalonia/Manual/content.Writer.cs index 8d5a459144..686c37e8c5 100644 --- a/src/SvgML.Avalonia/Manual/content.Writer.cs +++ b/src/SvgML.Avalonia/Manual/content.Writer.cs @@ -1,6 +1,6 @@ namespace SvgML; -internal partial class content +public partial class content { protected override void Write(TextWriter writer, element parent) { diff --git a/src/SvgML.Avalonia/Manual/content.cs b/src/SvgML.Avalonia/Manual/content.cs index fd5dcb46e6..8c817847e5 100644 --- a/src/SvgML.Avalonia/Manual/content.cs +++ b/src/SvgML.Avalonia/Manual/content.cs @@ -1,6 +1,6 @@ namespace SvgML; -internal partial class content : element +public partial class content : element { protected override string SvgTag => ""; } diff --git a/src/SvgML.Avalonia/Manual/element.Mapping.cs b/src/SvgML.Avalonia/Manual/element.Mapping.cs new file mode 100644 index 0000000000..872e8e9b8e --- /dev/null +++ b/src/SvgML.Avalonia/Manual/element.Mapping.cs @@ -0,0 +1,6 @@ +namespace SvgML; + +public abstract partial class element +{ + internal virtual string? GetSvgMappingId() => id; +} diff --git a/src/SvgML.Avalonia/Manual/elements.cs b/src/SvgML.Avalonia/Manual/elements.cs index 8ac054842a..9a4973c29e 100644 --- a/src/SvgML.Avalonia/Manual/elements.cs +++ b/src/SvgML.Avalonia/Manual/elements.cs @@ -18,9 +18,9 @@ public override bool CanConvertFrom(ITypeDescriptorContext? context, Type source public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) { - var elements = (elements)value; - - return string.Concat(elements.OfType().Select(x => x.Content)); + return value is elements elements + ? string.Concat(elements.OfType().Select(static x => x.Content)) + : string.Empty; } public override object ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) @@ -46,4 +46,17 @@ public elements(IEnumerable items) : base(items) public elements(params element[] items) : base(items) { } + + public void Add(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + Add(new content + { + Content = text + }); + } } diff --git a/src/SvgML.CodeGenerator/Definitions/SvgTypeDefs.cs b/src/SvgML.CodeGenerator/Definitions/SvgTypeDefs.cs index d53d4dffd4..acd1d1d74e 100644 --- a/src/SvgML.CodeGenerator/Definitions/SvgTypeDefs.cs +++ b/src/SvgML.CodeGenerator/Definitions/SvgTypeDefs.cs @@ -366,6 +366,10 @@ internal static class SvgTypeDefs FilePath: "Extensibility", Properties: [ + new ("x", "Svg.SvgUnit"), + new ("y", "Svg.SvgUnit"), + new ("width", "Svg.SvgUnit"), + new ("height", "Svg.SvgUnit"), ]), #endregion diff --git a/src/SvgML.CodeGenerator/Program.cs b/src/SvgML.CodeGenerator/Program.cs index 6a06bf427e..9d1938d329 100644 --- a/src/SvgML.CodeGenerator/Program.cs +++ b/src/SvgML.CodeGenerator/Program.cs @@ -32,7 +32,7 @@ { var settings = new GeneratorSettings { - ElementBaseType = "SkiaSharp.Views.Maui.Controls.SKCanvasView", + ElementBaseType = "Microsoft.Maui.Controls.ContentView", BaseWriterType = "element", BasePath = ResolveGeneratedPath("SvgML.Maui"), TypeDefs = SvgTypeDefs.TypeDefs @@ -47,7 +47,7 @@ { var settings = new GeneratorSettings { - ElementBaseType = "Uno.WinUI.Graphics2DSK.SKCanvasElement", + ElementBaseType = "Microsoft.UI.Xaml.Controls.ContentControl", BaseWriterType = "element", BasePath = ResolveGeneratedPath("SvgML.Uno"), TypeDefs = SvgTypeDefs.TypeDefs diff --git a/src/SvgML.Maui/Generated/Extensibility/foreignObject.Properties.g.cs b/src/SvgML.Maui/Generated/Extensibility/foreignObject.Properties.g.cs index 13a482e171..d4ccb3143c 100644 --- a/src/SvgML.Maui/Generated/Extensibility/foreignObject.Properties.g.cs +++ b/src/SvgML.Maui/Generated/Extensibility/foreignObject.Properties.g.cs @@ -6,4 +6,40 @@ namespace SvgML; public partial class foreignObject : visual { protected override string SvgTag => "foreignObject"; + + public static readonly Microsoft.Maui.Controls.BindableProperty xProperty = + Microsoft.Maui.Controls.BindableProperty.Create("x", typeof(Svg.SvgUnit), typeof(foreignObject)); + + public static readonly Microsoft.Maui.Controls.BindableProperty yProperty = + Microsoft.Maui.Controls.BindableProperty.Create("y", typeof(Svg.SvgUnit), typeof(foreignObject)); + + public static readonly Microsoft.Maui.Controls.BindableProperty widthProperty = + Microsoft.Maui.Controls.BindableProperty.Create("width", typeof(Svg.SvgUnit), typeof(foreignObject)); + + public static readonly Microsoft.Maui.Controls.BindableProperty heightProperty = + Microsoft.Maui.Controls.BindableProperty.Create("height", typeof(Svg.SvgUnit), typeof(foreignObject)); + + public Svg.SvgUnit x + { + get => (Svg.SvgUnit)GetValue(xProperty); + set => SetValue(xProperty, value); + } + + public Svg.SvgUnit y + { + get => (Svg.SvgUnit)GetValue(yProperty); + set => SetValue(yProperty, value); + } + + public Svg.SvgUnit width + { + get => (Svg.SvgUnit)GetValue(widthProperty); + set => SetValue(widthProperty, value); + } + + public Svg.SvgUnit height + { + get => (Svg.SvgUnit)GetValue(heightProperty); + set => SetValue(heightProperty, value); + } } diff --git a/src/SvgML.Maui/Generated/Extensibility/foreignObject.Writer.g.cs b/src/SvgML.Maui/Generated/Extensibility/foreignObject.Writer.g.cs index ce98a9db86..005371d6ee 100644 --- a/src/SvgML.Maui/Generated/Extensibility/foreignObject.Writer.g.cs +++ b/src/SvgML.Maui/Generated/Extensibility/foreignObject.Writer.g.cs @@ -8,5 +8,25 @@ public partial class foreignObject protected override void WriteAttributes(TextWriter writer, element parent) { base.WriteAttributes(writer, parent); + + if (this.IsSet(xProperty)) + { + writer.WriteLine($"x=\"{ToSvgString(x)}\""); + } + + if (this.IsSet(yProperty)) + { + writer.WriteLine($"y=\"{ToSvgString(y)}\""); + } + + if (this.IsSet(widthProperty)) + { + writer.WriteLine($"width=\"{ToSvgString(width)}\""); + } + + if (this.IsSet(heightProperty)) + { + writer.WriteLine($"height=\"{ToSvgString(height)}\""); + } } } diff --git a/src/SvgML.Uno/Generated/Extensibility/foreignObject.Properties.g.cs b/src/SvgML.Uno/Generated/Extensibility/foreignObject.Properties.g.cs index 13a482e171..11bfe2feef 100644 --- a/src/SvgML.Uno/Generated/Extensibility/foreignObject.Properties.g.cs +++ b/src/SvgML.Uno/Generated/Extensibility/foreignObject.Properties.g.cs @@ -6,4 +6,56 @@ namespace SvgML; public partial class foreignObject : visual { protected override string SvgTag => "foreignObject"; + + public static readonly Microsoft.UI.Xaml.DependencyProperty xProperty = + Microsoft.UI.Xaml.DependencyProperty.Register( + "x", + typeof(Svg.SvgUnit), + typeof(foreignObject), + new Microsoft.UI.Xaml.PropertyMetadata(default(Svg.SvgUnit), OnSvgPropertyChanged)); + + public static readonly Microsoft.UI.Xaml.DependencyProperty yProperty = + Microsoft.UI.Xaml.DependencyProperty.Register( + "y", + typeof(Svg.SvgUnit), + typeof(foreignObject), + new Microsoft.UI.Xaml.PropertyMetadata(default(Svg.SvgUnit), OnSvgPropertyChanged)); + + public static readonly Microsoft.UI.Xaml.DependencyProperty widthProperty = + Microsoft.UI.Xaml.DependencyProperty.Register( + "width", + typeof(Svg.SvgUnit), + typeof(foreignObject), + new Microsoft.UI.Xaml.PropertyMetadata(default(Svg.SvgUnit), OnSvgPropertyChanged)); + + public static readonly Microsoft.UI.Xaml.DependencyProperty heightProperty = + Microsoft.UI.Xaml.DependencyProperty.Register( + "height", + typeof(Svg.SvgUnit), + typeof(foreignObject), + new Microsoft.UI.Xaml.PropertyMetadata(default(Svg.SvgUnit), OnSvgPropertyChanged)); + + public Svg.SvgUnit x + { + get => (Svg.SvgUnit)GetValue(xProperty); + set => SetValue(xProperty, value); + } + + public Svg.SvgUnit y + { + get => (Svg.SvgUnit)GetValue(yProperty); + set => SetValue(yProperty, value); + } + + public Svg.SvgUnit width + { + get => (Svg.SvgUnit)GetValue(widthProperty); + set => SetValue(widthProperty, value); + } + + public Svg.SvgUnit height + { + get => (Svg.SvgUnit)GetValue(heightProperty); + set => SetValue(heightProperty, value); + } } diff --git a/src/SvgML.Uno/Generated/Extensibility/foreignObject.Writer.g.cs b/src/SvgML.Uno/Generated/Extensibility/foreignObject.Writer.g.cs index ce98a9db86..caba9e4dfe 100644 --- a/src/SvgML.Uno/Generated/Extensibility/foreignObject.Writer.g.cs +++ b/src/SvgML.Uno/Generated/Extensibility/foreignObject.Writer.g.cs @@ -8,5 +8,25 @@ public partial class foreignObject protected override void WriteAttributes(TextWriter writer, element parent) { base.WriteAttributes(writer, parent); + + if (ReadLocalValue(xProperty) != Microsoft.UI.Xaml.DependencyProperty.UnsetValue) + { + writer.WriteLine($"x=\"{ToSvgString(x)}\""); + } + + if (ReadLocalValue(yProperty) != Microsoft.UI.Xaml.DependencyProperty.UnsetValue) + { + writer.WriteLine($"y=\"{ToSvgString(y)}\""); + } + + if (ReadLocalValue(widthProperty) != Microsoft.UI.Xaml.DependencyProperty.UnsetValue) + { + writer.WriteLine($"width=\"{ToSvgString(width)}\""); + } + + if (ReadLocalValue(heightProperty) != Microsoft.UI.Xaml.DependencyProperty.UnsetValue) + { + writer.WriteLine($"height=\"{ToSvgString(height)}\""); + } } } From f3c29fbbf2674a62cdb9b2745c7f478f28aa5e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:55:33 +0200 Subject: [PATCH 03/21] feat(svgml): host foreignObject controls in Avalonia --- .../Document Structure/svg.Control.cs | 4 +- .../Document Structure/svg.HostedControls.cs | 422 ++++++++++++++++++ .../Extensibility/foreignObject.Properties.cs | 52 +++ .../Avalonia/content.Properties.cs | 2 +- .../Avalonia/element.Invalidation.cs | 16 +- .../Manual/Document Structure/svg.Loading.cs | 64 ++- src/SvgML.Avalonia/Properties/AssemblyInfo.cs | 2 + 7 files changed, 549 insertions(+), 13 deletions(-) create mode 100644 src/SvgML.Avalonia/Avalonia/Document Structure/svg.HostedControls.cs create mode 100644 src/SvgML.Avalonia/Avalonia/Extensibility/foreignObject.Properties.cs diff --git a/src/SvgML.Avalonia/Avalonia/Document Structure/svg.Control.cs b/src/SvgML.Avalonia/Avalonia/Document Structure/svg.Control.cs index 529ddd9fdc..ea85dd1f02 100644 --- a/src/SvgML.Avalonia/Avalonia/Document Structure/svg.Control.cs +++ b/src/SvgML.Avalonia/Avalonia/Document Structure/svg.Control.cs @@ -64,7 +64,9 @@ protected override Size ArrangeOverride(Size finalSize) ? new Size(_picture.CullRect.Width, _picture.CullRect.Height) : default; - return Stretch.CalculateSize(finalSize, sourceSize); + var arrangedSize = Stretch.CalculateSize(finalSize, sourceSize); + ArrangeHostedControls(); + return arrangedSize; } public override void Render(DrawingContext context) diff --git a/src/SvgML.Avalonia/Avalonia/Document Structure/svg.HostedControls.cs b/src/SvgML.Avalonia/Avalonia/Document Structure/svg.HostedControls.cs new file mode 100644 index 0000000000..6b3979a0ba --- /dev/null +++ b/src/SvgML.Avalonia/Avalonia/Document Structure/svg.HostedControls.cs @@ -0,0 +1,422 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.VisualTree; +using SkiaFont = SkiaSharp.SKFont; +using SkiaTypeface = SkiaSharp.SKTypeface; + +namespace SvgML; + +public partial class svg +{ + private readonly List _hostedControlEntries = []; + private readonly HashSet _attachedHostedControls = []; + + private void SynchronizeHostedControls() + { + var desiredEntries = HostedControlTree + .Enumerate(this) + .Select(static entry => CreateHostedControlEntry(entry.Element, entry.Host)) + .Where(static entry => entry is not null) + .Select(static entry => entry!.Value) + .ToList(); + + var desiredControls = new HashSet(desiredEntries.Select(static entry => entry.Control)); + + foreach (var control in _attachedHostedControls.Where(control => !desiredControls.Contains(control)).ToList()) + { + LogicalChildren.Remove(control); + VisualChildren.Remove(control); + _attachedHostedControls.Remove(control); + } + + foreach (var control in desiredEntries.Select(static entry => entry.Control)) + { + if (_attachedHostedControls.Add(control)) + { + LogicalChildren.Add(control); + VisualChildren.Add(control); + } + } + + _hostedControlEntries.Clear(); + _hostedControlEntries.AddRange(desiredEntries); + InvalidateArrange(); + } + + private void ArrangeHostedControls() + { + foreach (var entry in _hostedControlEntries) + { + var bounds = GetHostedControlBounds(entry); + if (bounds.Width <= 0D || bounds.Height <= 0D) + { + entry.Control.Arrange(default); + continue; + } + + entry.Control.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + entry.Control.Arrange(bounds); + } + } + + private Rect GetHostedControlBounds(HostedControlEntry entry) + { + var isInline = IsInlineHostedControl(entry); + var bounds = GetControlBounds(entry.Element); + if (isInline && TryGetInlineHostedControlBounds(entry.Element, entry.Host, out var inlineBounds)) + { + return inlineBounds; + } + + if (!isInline || bounds.Width <= 0D || bounds.Height <= 0D) + { + return bounds; + } + + entry.Control.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + var desired = entry.Control.DesiredSize; + var width = desired.Width > 0D ? desired.Width : bounds.Width; + var height = desired.Height > 0D ? desired.Height : bounds.Height; + return new Rect(bounds.X, bounds.Bottom - height, width, height); + } + + private bool TryGetInlineHostedControlBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + if (!TryGetPictureToControlMatrix(out var pictureToControl) + || !TryGetInlinePictureBounds(inlineElement, host, out var pictureBounds)) + { + return false; + } + + bounds = TransformPictureBoundsToControl(pictureBounds, pictureToControl); + return bounds.Width > 0D && bounds.Height > 0D; + } + + private bool TryGetInlinePictureBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + var layoutRoot = GetInlineLayoutRoot(inlineElement); + if (layoutRoot is null) + { + return false; + } + + var currentX = 0D; + var currentY = 0D; + return TryLocateInlinePictureBounds(layoutRoot, inlineElement, host, ref currentX, ref currentY, out bounds); + } + + private static text_base? GetInlineLayoutRoot(element inlineElement) + { + text_base? result = null; + + for (var current = inlineElement.Parent as element; current is not null; current = current.Parent as element) + { + if (current is text_base textBase) + { + result = textBase; + } + } + + return result; + } + + private bool TryLocateInlinePictureBounds( + text_base container, + element target, + IHostedControlElement targetHost, + ref double currentX, + ref double currentY, + out Rect bounds) + { + ApplyTextPosition(container, ref currentX, ref currentY); + + foreach (var child in container.Children) + { + switch (child) + { + case content textContent: + currentX += MeasureTextContentWidth(textContent.Content, container); + break; + + case element hostedElement when hostedElement is IHostedControlElement hostedChild + && hostedChild.HostedControl is not null: + var size = hostedChild.GetHostedControlSize().OrFallback(); + if (ReferenceEquals(hostedElement, target) && ReferenceEquals(hostedChild, targetHost)) + { + bounds = new Rect( + currentX, + GetInlinePictureTop(container, size.Height, currentY), + size.Width, + size.Height); + return true; + } + + currentX += size.Width; + break; + + case text_base nestedText: + var childX = currentX; + var childY = currentY; + if (TryLocateInlinePictureBounds(nestedText, target, targetHost, ref childX, ref childY, out bounds)) + { + currentX = childX; + currentY = childY; + return true; + } + + currentX = childX; + currentY = childY; + break; + } + } + + bounds = default; + return false; + } + + private static double GetInlinePictureTop(element styleSource, double height, double baselineY) + { + return baselineY + GetTextCenterOffset(styleSource) - height / 2D; + } + + private static double GetTextCenterOffset(element styleSource) + { + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var font = typeface is null + ? new SkiaFont { Size = (float)ResolveFontSize(styleSource) } + : new SkiaFont(typeface, (float)ResolveFontSize(styleSource)); + font.GetFontMetrics(out var metrics); + return (metrics.Ascent + metrics.Descent) / 2D; + } + finally + { + typeface?.Dispose(); + } + } + + private static void ApplyTextPosition(text_base textBase, ref double currentX, ref double currentY) + { + if (TryParseFirstCoordinate(textBase.x, out var x)) + { + currentX = x; + } + + if (TryParseFirstCoordinate(textBase.y, out var y)) + { + currentY = y; + } + + if (TryParseFirstCoordinate(textBase.dx, out var dx)) + { + currentX += dx; + } + + if (TryParseFirstCoordinate(textBase.dy, out var dy)) + { + currentY += dy; + } + } + + private static bool TryParseFirstCoordinate(string? value, out double coordinate) + { + coordinate = 0D; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parsed = numbers.Parse(value).Number; + if (parsed is null || parsed.Count == 0) + { + return false; + } + + coordinate = parsed[0]; + return true; + } + + private static double MeasureTextContentWidth(string? text, element styleSource) + { + if (string.IsNullOrEmpty(text)) + { + return 0D; + } + + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var font = typeface is null + ? new SkiaFont { Size = (float)ResolveFontSize(styleSource) } + : new SkiaFont(typeface, (float)ResolveFontSize(styleSource)); + return font.MeasureText(text); + } + finally + { + typeface?.Dispose(); + } + } + + private static double ResolveFontSize(element element) + { + var inherited = element.Parent is element parent + ? ResolveFontSize(parent) + : 16D; + + if (element.font_size is { } fontSize) + { + return ConvertFontSizeToPixels(fontSize, inherited); + } + + return TryGetStyleDeclaration(element.style, "font-size", out var styleFontSize) + && TryParseSvgUnit(styleFontSize, out var styleFontSizeUnit) + ? ConvertFontSizeToPixels(styleFontSizeUnit, inherited) + : inherited; + } + + private static double ConvertFontSizeToPixels(Svg.SvgUnit unit, double inherited) + { + if (unit.IsEmpty || unit.IsNone || unit.Value <= 0f) + { + return inherited; + } + + return unit.Type switch + { + Svg.SvgUnitType.Pixel or Svg.SvgUnitType.User => unit.Value, + Svg.SvgUnitType.Em => inherited * unit.Value, + Svg.SvgUnitType.Ex => inherited * unit.Value * 0.5D, + Svg.SvgUnitType.Percentage => inherited * unit.Value / 100D, + Svg.SvgUnitType.Inch => unit.Value * 96D, + Svg.SvgUnitType.Centimeter => unit.Value * 96D / 2.54D, + Svg.SvgUnitType.Millimeter => unit.Value * 96D / 25.4D, + Svg.SvgUnitType.Pica => unit.Value * 16D, + Svg.SvgUnitType.Point => unit.Value * 96D / 72D, + _ => inherited + }; + } + + private static string? ResolveFontFamily(element element) + { + for (var current = element; current is not null; current = current.Parent as element) + { + var family = current.font_family; + if (string.IsNullOrWhiteSpace(family) + && TryGetStyleDeclaration(current.style, "font-family", out var styleFamily)) + { + family = styleFamily; + } + + if (string.IsNullOrWhiteSpace(family)) + { + continue; + } + + return family.Split(',')[0].Trim().Trim('\'', '"'); + } + + return null; + } + + private static bool TryGetStyleDeclaration(string? style, string propertyName, out string? value) + { + value = null; + + if (string.IsNullOrWhiteSpace(style)) + { + return false; + } + + foreach (var declaration in style.Split(';')) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var name = declaration[..separator].Trim(); + if (!string.Equals(name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + value = declaration[(separator + 1)..].Trim(); + return value.Length > 0; + } + + return false; + } + + private static bool TryParseSvgUnit(string? value, out Svg.SvgUnit unit) + { + unit = default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + if (new Svg.SvgUnitConverter().ConvertFromString(value.Trim()) is Svg.SvgUnit parsed) + { + unit = parsed; + return true; + } + } + catch (FormatException) + { + } + catch (NotSupportedException) + { + } + + return false; + } + + private static Rect TransformPictureBoundsToControl(Rect pictureBounds, Matrix pictureToControl) + { + var topLeft = pictureToControl.Transform(pictureBounds.TopLeft); + var topRight = pictureToControl.Transform(pictureBounds.TopRight); + var bottomRight = pictureToControl.Transform(pictureBounds.BottomRight); + var bottomLeft = pictureToControl.Transform(pictureBounds.BottomLeft); + + var minX = Math.Min(Math.Min(topLeft.X, topRight.X), Math.Min(bottomRight.X, bottomLeft.X)); + var minY = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomRight.Y, bottomLeft.Y)); + var maxX = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomRight.X, bottomLeft.X)); + var maxY = Math.Max(Math.Max(topLeft.Y, topRight.Y), Math.Max(bottomRight.Y, bottomLeft.Y)); + + return new Rect(new Point(minX, minY), new Point(maxX, maxY)); + } + + private static HostedControlEntry? CreateHostedControlEntry(element element, IHostedControlElement host) + { + return host.HostedControl is Control control + ? new HostedControlEntry(element, control, host) + : null; + } + + private static bool IsInlineHostedControl(HostedControlEntry entry) + { + return GetInlineLayoutRoot(entry.Element) is not null; + } + + private readonly record struct HostedControlEntry(element Element, Control Control, IHostedControlElement Host); +} diff --git a/src/SvgML.Avalonia/Avalonia/Extensibility/foreignObject.Properties.cs b/src/SvgML.Avalonia/Avalonia/Extensibility/foreignObject.Properties.cs new file mode 100644 index 0000000000..91d8893018 --- /dev/null +++ b/src/SvgML.Avalonia/Avalonia/Extensibility/foreignObject.Properties.cs @@ -0,0 +1,52 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace SvgML; + +public partial class foreignObject +{ + public static readonly StyledProperty ChildProperty = + AvaloniaProperty.Register(nameof(Child)); + + [Content] + public Control? Child + { + get => GetValue(ChildProperty); + set => SetValue(ChildProperty, value); + } + + internal partial HostedControlSize MeasureHostedControl() + { + if (Child is null) + { + return HostedControlSize.Empty; + } + + Child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return HostedControlSize.From(Child.DesiredSize.Width, Child.DesiredSize.Height); + } + + internal partial bool IsWidthSet() + { + return width is not null; + } + + internal partial bool IsHeightSet() + { + return height is not null; + } + + internal partial bool IsInTextTree() + { + for (var current = Parent as element; current is not null; current = current.Parent as element) + { + if (current is text_base) + { + return true; + } + } + + return false; + } +} diff --git a/src/SvgML.Avalonia/Avalonia/content.Properties.cs b/src/SvgML.Avalonia/Avalonia/content.Properties.cs index 39507c301e..56d97eae8d 100644 --- a/src/SvgML.Avalonia/Avalonia/content.Properties.cs +++ b/src/SvgML.Avalonia/Avalonia/content.Properties.cs @@ -3,7 +3,7 @@ namespace SvgML; -internal partial class content +public partial class content { public static readonly StyledProperty ContentProperty = AvaloniaProperty.Register("Content"); diff --git a/src/SvgML.Avalonia/Avalonia/element.Invalidation.cs b/src/SvgML.Avalonia/Avalonia/element.Invalidation.cs index e905058f08..f94c83a95f 100644 --- a/src/SvgML.Avalonia/Avalonia/element.Invalidation.cs +++ b/src/SvgML.Avalonia/Avalonia/element.Invalidation.cs @@ -11,8 +11,8 @@ protected virtual void ChildrenChanged(object? sender, NotifyCollectionChangedEv switch (e.Action) { case NotifyCollectionChangedAction.Add: - LogicalChildren.InsertRange(e.NewStartingIndex, e.NewItems!.OfType().ToList()); - VisualChildren.InsertRange(e.NewStartingIndex, e.NewItems!.OfType()); + LogicalChildren.InsertRange(e.NewStartingIndex, e.NewItems!.OfType().ToList()); + VisualChildren.InsertRange(e.NewStartingIndex, e.NewItems!.OfType()); break; case NotifyCollectionChangedAction.Move: @@ -21,8 +21,8 @@ protected virtual void ChildrenChanged(object? sender, NotifyCollectionChangedEv break; case NotifyCollectionChangedAction.Remove: - LogicalChildren.RemoveAll(e.OldItems!.OfType().ToList()); - VisualChildren.RemoveAll(e.OldItems!.OfType()); + LogicalChildren.RemoveAll(e.OldItems!.OfType().Cast().ToList()); + VisualChildren.RemoveAll(e.OldItems!.OfType().Cast()); break; case NotifyCollectionChangedAction.Replace: @@ -36,14 +36,14 @@ protected virtual void ChildrenChanged(object? sender, NotifyCollectionChangedEv break; case NotifyCollectionChangedAction.Reset: - var logicalChildren = LogicalChildren.OfType().ToList(); - var visualChildren = VisualChildren.OfType().ToList(); + var logicalChildren = LogicalChildren.OfType().Cast().ToList(); + var visualChildren = VisualChildren.OfType().Cast().ToList(); LogicalChildren.RemoveAll(logicalChildren); VisualChildren.RemoveAll(visualChildren); - LogicalChildren.InsertRange(0, Children.OfType().ToList()); - VisualChildren.InsertRange(0, Children.OfType()); + LogicalChildren.InsertRange(0, Children.OfType().Cast().ToList()); + VisualChildren.InsertRange(0, Children.OfType().Cast()); break; } diff --git a/src/SvgML.Avalonia/Manual/Document Structure/svg.Loading.cs b/src/SvgML.Avalonia/Manual/Document Structure/svg.Loading.cs index ff8b255ed0..f686088e66 100644 --- a/src/SvgML.Avalonia/Manual/Document Structure/svg.Loading.cs +++ b/src/SvgML.Avalonia/Manual/Document Structure/svg.Loading.cs @@ -115,11 +115,13 @@ private void UpdateElementMappings(SKSvg? skSvg, SvgDocument? document) if (document is null || skSvg is null || !skSvg.TryEnsureRetainedSceneGraph(out var sceneDocument) || sceneDocument is null) { + SynchronizeHostedControls(); return; } var metrics = BuildSceneNodeMetrics(sceneDocument); MapElementRecursive(this, document, metrics); + SynchronizeHostedControls(); } private static Dictionary BuildSceneNodeMetrics(SvgSceneDocument sceneDocument) @@ -156,7 +158,7 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction { _elementBySvgElement[svgElement] = control; - if (metrics.TryGetValue(svgElement, out var metric)) + if (TryGetSceneNodeMetrics(svgElement, metrics, out var metric)) { control.UpdateSvgData( svgElement, @@ -212,9 +214,10 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return null; } - if (!string.IsNullOrEmpty(control.id)) + var mappingId = control.GetSvgMappingId(); + if (!string.IsNullOrEmpty(mappingId)) { - var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, control.id, StringComparison.Ordinal)); + var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, mappingId, StringComparison.Ordinal)); if (byId is not null) { svgChildren.Remove(byId); @@ -244,6 +247,61 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return attribute?.ElementName ?? element.ID; } + private static bool TryGetSceneNodeMetrics(SvgElement svgElement, Dictionary metrics, out SceneNodeMetrics metric) + { + var aggregate = CreateAggregateSceneNodeMetrics(svgElement, metrics); + if (aggregate is null) + { + metric = null!; + return false; + } + + metric = aggregate; + return true; + } + + private static SceneNodeMetrics? CreateAggregateSceneNodeMetrics(SvgElement svgElement, Dictionary metrics) + { + SceneNodeMetrics? aggregate = metrics.TryGetValue(svgElement, out var direct) + ? CloneSceneNodeMetrics(direct) + : null; + + foreach (var child in svgElement.Children.OfType()) + { + var childMetrics = CreateAggregateSceneNodeMetrics(child, metrics); + if (childMetrics is null) + { + continue; + } + + if (aggregate is null) + { + aggregate = childMetrics; + continue; + } + + aggregate.Geometry = UnionRect(aggregate.Geometry, childMetrics.Geometry); + aggregate.Transformed = UnionRect(aggregate.Transformed, childMetrics.Transformed); + aggregate.SceneNodes.AddRange(childMetrics.SceneNodes); + } + + return aggregate; + } + + private static SceneNodeMetrics CloneSceneNodeMetrics(SceneNodeMetrics source) + { + var clone = new SceneNodeMetrics + { + Geometry = source.Geometry, + Transformed = source.Transformed, + Transform = source.Transform, + TotalTransform = source.TotalTransform + }; + + clone.SceneNodes.AddRange(source.SceneNodes); + return clone; + } + private void ClearElementData(element control) { control.ClearSvgData(); diff --git a/src/SvgML.Avalonia/Properties/AssemblyInfo.cs b/src/SvgML.Avalonia/Properties/AssemblyInfo.cs index b1d7bd8fcf..31fab08bec 100644 --- a/src/SvgML.Avalonia/Properties/AssemblyInfo.cs +++ b/src/SvgML.Avalonia/Properties/AssemblyInfo.cs @@ -1,3 +1,5 @@ using Avalonia.Metadata; [assembly: XmlnsDefinition("https://github.com/avaloniaui", "SvgML")] +[assembly: XmlnsDefinition("https://github.com/svgml", "SvgML")] +[assembly: XmlnsPrefix("https://github.com/svgml", "svgml")] From bfb70d7e3377e233ee64633f7db04c7566af6166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:55:46 +0200 Subject: [PATCH 04/21] feat(svgml): host foreignObject controls in Uno --- .../Generated/element.Properties.g.cs | 2 +- .../Uno/Document Structure/svg.Control.cs | 156 ++++-- .../Document Structure/svg.HostedControls.cs | 529 ++++++++++++++++++ .../Extensibility/foreignObject.Properties.cs | 56 ++ src/SvgML.Uno/Uno/content.Properties.cs | 6 +- src/SvgML.Uno/Uno/element.Properties.cs | 9 +- 6 files changed, 706 insertions(+), 52 deletions(-) create mode 100644 src/SvgML.Uno/Uno/Document Structure/svg.HostedControls.cs create mode 100644 src/SvgML.Uno/Uno/Extensibility/foreignObject.Properties.cs diff --git a/src/SvgML.Uno/Generated/element.Properties.g.cs b/src/SvgML.Uno/Generated/element.Properties.g.cs index 33c4132072..55776955e2 100644 --- a/src/SvgML.Uno/Generated/element.Properties.g.cs +++ b/src/SvgML.Uno/Generated/element.Properties.g.cs @@ -3,7 +3,7 @@ namespace SvgML; -public abstract partial class element : Uno.WinUI.Graphics2DSK.SKCanvasElement +public abstract partial class element : Microsoft.UI.Xaml.Controls.ContentControl { protected abstract string SvgTag { get; } diff --git a/src/SvgML.Uno/Uno/Document Structure/svg.Control.cs b/src/SvgML.Uno/Uno/Document Structure/svg.Control.cs index 0a1f894dd1..cea03f05e3 100644 --- a/src/SvgML.Uno/Uno/Document Structure/svg.Control.cs +++ b/src/SvgML.Uno/Uno/Document Structure/svg.Control.cs @@ -33,6 +33,7 @@ public partial class svg private TimeSpan _lastAnimationPlaybackTimestamp; private readonly Dictionary _elementBySvgElement = new(); private readonly Dictionary _elementBySceneNode = new(); + private Size _arrangedSvgSize; static svg() { @@ -41,6 +42,7 @@ static svg() public svg() { + InitializeHostedControls(); AttachToTree(parent: null, root: this); Loaded += OnLoaded; Unloaded += OnUnloaded; @@ -289,6 +291,8 @@ protected override Size MeasureOverride(Size availableSize) return default; } + _layoutRoot.Measure(availableSize); + var size = SvgRenderLayout.CalculateSize( new SvgSize(availableSize.Width, availableSize.Height), new SvgSize(picture.CullRect.Width, picture.CullRect.Height), @@ -303,6 +307,8 @@ protected override Size ArrangeOverride(Size finalSize) var picture = _picture; if (picture is null) { + _arrangedSvgSize = default; + _layoutRoot.Arrange(new Rect(0D, 0D, 0D, 0D)); return default; } @@ -312,39 +318,12 @@ protected override Size ArrangeOverride(Size finalSize) Stretch, StretchDirection); + _arrangedSvgSize = new Size(size.Width, size.Height); + _layoutRoot.Arrange(new Rect(0D, 0D, size.Width, size.Height)); + ArrangeHostedControls(); return new Size(size.Width, size.Height); } - protected override void RenderOverride(SkiaCanvas canvas, Size area) - { - var picture = _picture; - if (picture is null) - { - return; - } - - if (!SvgRenderLayout.TryCreateRenderInfo( - new SvgSize(area.Width, area.Height), - new SvgRect( - picture.CullRect.Left, - picture.CullRect.Top, - picture.CullRect.Width, - picture.CullRect.Height), - Stretch, - StretchDirection, - out var renderInfo)) - { - return; - } - - canvas.Save(); - canvas.ClipRect(ToSKRect(renderInfo.DestinationRect)); - var matrix = ToSKMatrix(renderInfo.Matrix); - canvas.Concat(in matrix); - canvas.DrawPicture(picture); - canvas.Restore(); - } - private void QueueReload() { if (_reloadQueued) @@ -374,7 +353,7 @@ private void ReloadAndInvalidate() ReloadFromInlineTree(); InvalidateMeasure(); InvalidateArrange(); - Invalidate(); + InvalidateDrawingSurface(); } private void UpdateAnimationPlayback() @@ -465,7 +444,7 @@ void Refresh() _picture = skSvg.Picture; InvalidateMeasure(); InvalidateArrange(); - Invalidate(); + InvalidateDrawingSurface(); } if (DispatcherQueue is { HasThreadAccess: false } dispatcherQueue) @@ -572,8 +551,15 @@ private bool TryGetRenderInfo(out SvgRenderInfo renderInfo) return false; } + var width = ActualWidth > 0D ? ActualWidth : _arrangedSvgSize.Width; + var height = ActualHeight > 0D ? ActualHeight : _arrangedSvgSize.Height; + if (width <= 0D || height <= 0D) + { + return false; + } + return SvgRenderLayout.TryCreateRenderInfo( - new SvgSize(ActualWidth, ActualHeight), + new SvgSize(width, height), new SvgRect(picture.CullRect.Left, picture.CullRect.Top, picture.CullRect.Width, picture.CullRect.Height), Stretch, StretchDirection, @@ -588,11 +574,14 @@ private void UpdateElementMappings(SKSvg? skSvg, SvgDocument? document) if (document is null || skSvg is null || !skSvg.TryEnsureRetainedSceneGraph(out var sceneDocument) || sceneDocument is null) { + SynchronizeHostedControls(); return; } var metrics = BuildSceneNodeMetrics(sceneDocument); - MapElementRecursive(this, document, metrics); + var textBoundsFallback = new TextBoundsFallbackContext(skSvg.AssetLoader, sceneDocument.CullRect); + MapElementRecursive(this, document, metrics, textBoundsFallback); + SynchronizeHostedControls(); } private static Dictionary BuildSceneNodeMetrics(SvgSceneDocument sceneDocument) @@ -627,11 +616,15 @@ private static Dictionary BuildSceneNodeMetrics(Sv return metrics; } - private void MapElementRecursive(element control, SvgElement svgElement, Dictionary metrics) + private void MapElementRecursive( + element control, + SvgElement svgElement, + Dictionary metrics, + TextBoundsFallbackContext textBoundsFallback) { _elementBySvgElement[svgElement] = control; - if (metrics.TryGetValue(svgElement, out var metric)) + if (TryGetSceneNodeMetrics(svgElement, metrics, textBoundsFallback, out var metric)) { control.UpdateSvgData( svgElement, @@ -673,7 +666,7 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction continue; } - MapElementRecursive(childControl, match, metrics); + MapElementRecursive(childControl, match, metrics, textBoundsFallback); } } @@ -686,9 +679,10 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return null; } - if (!string.IsNullOrEmpty(control.id)) + var mappingId = control.GetSvgMappingId(); + if (!string.IsNullOrEmpty(mappingId)) { - var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, control.id, StringComparison.Ordinal)); + var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, mappingId, StringComparison.Ordinal)); if (byId is not null) { svgChildren.Remove(byId); @@ -718,6 +712,85 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return attribute?.ElementName ?? element.ID; } + private static bool TryGetSceneNodeMetrics( + SvgElement svgElement, + Dictionary metrics, + TextBoundsFallbackContext textBoundsFallback, + out SceneNodeMetrics metric) + { + var aggregate = CreateAggregateSceneNodeMetrics(svgElement, metrics, textBoundsFallback); + if (aggregate is null) + { + metric = null!; + return false; + } + + metric = aggregate; + return true; + } + + private static SceneNodeMetrics? CreateAggregateSceneNodeMetrics( + SvgElement svgElement, + Dictionary metrics, + TextBoundsFallbackContext textBoundsFallback) + { + SceneNodeMetrics? aggregate = metrics.TryGetValue(svgElement, out var direct) + ? CloneSceneNodeMetrics(direct) + : null; + + foreach (var child in svgElement.Children.OfType()) + { + var childMetrics = CreateAggregateSceneNodeMetrics(child, metrics, textBoundsFallback); + if (childMetrics is null) + { + continue; + } + + if (aggregate is null) + { + aggregate = childMetrics; + continue; + } + + aggregate.Geometry = UnionRect(aggregate.Geometry, childMetrics.Geometry); + aggregate.Transformed = UnionRect(aggregate.Transformed, childMetrics.Transformed); + aggregate.SceneNodes.AddRange(childMetrics.SceneNodes); + } + + if ((aggregate is null || aggregate.Transformed.IsEmpty) + && svgElement is SvgTextBase svgTextBase + && SvgSceneCompiler.TryMeasureTextBounds( + svgTextBase, + textBoundsFallback.Viewport, + textBoundsFallback.AssetLoader, + out var measuredBounds)) + { + aggregate = new SceneNodeMetrics + { + Geometry = measuredBounds, + Transformed = measuredBounds, + Transform = Matrix3x2.Identity, + TotalTransform = Matrix3x2.Identity + }; + } + + return aggregate; + } + + private static SceneNodeMetrics CloneSceneNodeMetrics(SceneNodeMetrics source) + { + var clone = new SceneNodeMetrics + { + Geometry = source.Geometry, + Transformed = source.Transformed, + Transform = source.Transform, + TotalTransform = source.TotalTransform + }; + + clone.SceneNodes.AddRange(source.SceneNodes); + return clone; + } + private static void ClearElementData(element control) { control.ClearSvgData(); @@ -769,6 +842,8 @@ private static Matrix3x2 ToMatrix3x2(ShimMatrix matrix) matrix.TransY); } + private readonly record struct TextBoundsFallbackContext(ISvgAssetLoader AssetLoader, ShimRect Viewport); + private sealed class SceneNodeMetrics { public ShimRect Geometry { get; set; } @@ -809,6 +884,7 @@ private void OnUnloaded(object sender, RoutedEventArgs e) { _reloadQueued = false; StopAnimationPlayback(); + CloseHostedControlPresenters(); } private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) @@ -821,6 +897,6 @@ private static void OnLayoutPropertyChanged(DependencyObject d, DependencyProper var control = (svg)d; control.InvalidateMeasure(); control.InvalidateArrange(); - control.Invalidate(); + control.InvalidateDrawingSurface(); } } diff --git a/src/SvgML.Uno/Uno/Document Structure/svg.HostedControls.cs b/src/SvgML.Uno/Uno/Document Structure/svg.HostedControls.cs new file mode 100644 index 0000000000..2d9ad4402b --- /dev/null +++ b/src/SvgML.Uno/Uno/Document Structure/svg.HostedControls.cs @@ -0,0 +1,529 @@ +using System.Numerics; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Uno.WinUI.Graphics2DSK; +using Windows.Foundation; +using SkiaCanvas = SkiaSharp.SKCanvas; +using SkiaFont = SkiaSharp.SKFont; +using SkiaTypeface = SkiaSharp.SKTypeface; + +namespace SvgML; + +public partial class svg +{ + private readonly Grid _layoutRoot = new(); + private SvgDrawingSurface? _drawingSurface; + private readonly List _hostedControlEntries = []; + private readonly Dictionary _hostedControlPresenters = []; + + private void InitializeHostedControls() + { + _drawingSurface = new SvgDrawingSurface(this); + _drawingSurface.IsHitTestVisible = false; + _layoutRoot.Children.Add(_drawingSurface); + Content = _layoutRoot; + } + + private void SynchronizeHostedControls() + { + var desiredEntries = HostedControlTree + .Enumerate(this) + .Select(static entry => CreateHostedControlEntry(entry.Element, entry.Host)) + .Where(static entry => entry is not null) + .Select(static entry => entry!.Value) + .ToList(); + + var desiredControls = new HashSet(desiredEntries.Select(static entry => entry.Control)); + + foreach (var control in _hostedControlPresenters.Keys.Where(control => !desiredControls.Contains(control)).ToList()) + { + DisposeHostedControlPresenter(control); + } + + foreach (var control in desiredControls) + { + _ = GetOrCreateHostedControlPresenter(control); + } + + _hostedControlEntries.Clear(); + _hostedControlEntries.AddRange(desiredEntries); + InvalidateArrange(); + } + + private void ArrangeHostedControls() + { + foreach (var entry in _hostedControlEntries) + { + var bounds = GetHostedControlBounds(entry); + var presenter = GetOrCreateHostedControlPresenter(entry.Control); + if (bounds.Width <= 0D || bounds.Height <= 0D || !IsLoaded) + { + HideHostedControlPresenter(presenter); + continue; + } + + presenter.Visibility = Visibility.Visible; + presenter.HorizontalAlignment = HorizontalAlignment.Left; + presenter.VerticalAlignment = VerticalAlignment.Top; + presenter.Margin = new Thickness(0D); + presenter.Width = bounds.Width; + presenter.Height = bounds.Height; + presenter.Measure(new Size(bounds.Width, bounds.Height)); + presenter.Arrange(new Rect(bounds.X, bounds.Y, bounds.Width, bounds.Height)); + } + } + + private Rect GetHostedControlBounds(HostedControlEntry entry) + { + var isInline = IsInlineHostedControl(entry); + var bounds = GetControlBounds(entry.Element); + if (isInline && TryGetInlineHostedControlBounds(entry.Element, entry.Host, out var inlineBounds)) + { + bounds = inlineBounds; + } + + if (!isInline || bounds.Width <= 0D || bounds.Height <= 0D) + { + return bounds; + } + + entry.Control.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + var desired = entry.Control.DesiredSize; + var width = desired.Width > 0D ? desired.Width : bounds.Width; + var height = desired.Height > 0D ? desired.Height : bounds.Height; + return new Rect(bounds.X, bounds.Bottom - height, width, height); + } + + private bool TryGetInlineHostedControlBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + if (!TryGetRenderInfo(out var renderInfo)) + { + return false; + } + + if (!TryGetInlinePictureBounds(inlineElement, host, out var pictureBounds)) + { + return false; + } + + bounds = TransformPictureBoundsToControl(pictureBounds, renderInfo.Matrix); + return bounds.Width > 0D && bounds.Height > 0D; + } + + private bool TryGetInlinePictureBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + var layoutRoot = GetInlineLayoutRoot(inlineElement); + if (layoutRoot is null) + { + return false; + } + + var currentX = 0D; + var currentY = 0D; + return TryLocateInlinePictureBounds(layoutRoot, inlineElement, host, ref currentX, ref currentY, out bounds); + } + + private static text_base? GetInlineLayoutRoot(element inlineElement) + { + text_base? result = null; + + for (var current = inlineElement.ParentElement; current is not null; current = current.ParentElement) + { + if (current is text_base textBase) + { + result = textBase; + } + } + + return result; + } + + private bool TryLocateInlinePictureBounds( + text_base container, + element target, + IHostedControlElement targetHost, + ref double currentX, + ref double currentY, + out Rect bounds) + { + ApplyTextPosition(container, ref currentX, ref currentY); + + foreach (var child in container.Children) + { + switch (child) + { + case content textContent: + currentX += MeasureTextContentWidth(textContent.Content, container); + break; + + case element hostedElement when hostedElement is IHostedControlElement hostedChild + && hostedChild.HostedControl is not null: + var size = hostedChild.GetHostedControlSize().OrFallback(); + if (ReferenceEquals(hostedElement, target) && ReferenceEquals(hostedChild, targetHost)) + { + bounds = new Rect( + currentX, + GetInlinePictureTop(container, size.Height, currentY), + size.Width, + size.Height); + return true; + } + + currentX += size.Width; + break; + + case text_base nestedText: + var childX = currentX; + var childY = currentY; + if (TryLocateInlinePictureBounds(nestedText, target, targetHost, ref childX, ref childY, out bounds)) + { + currentX = childX; + currentY = childY; + return true; + } + + currentX = childX; + currentY = childY; + break; + } + } + + bounds = default; + return false; + } + + private static double GetInlinePictureTop(element styleSource, double height, double baselineY) + { + return baselineY + GetTextCenterOffset(styleSource) - height / 2D; + } + + private static double GetTextCenterOffset(element styleSource) + { + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var font = typeface is null + ? new SkiaFont { Size = (float)ResolveFontSize(styleSource) } + : new SkiaFont(typeface, (float)ResolveFontSize(styleSource)); + font.GetFontMetrics(out var metrics); + return (metrics.Ascent + metrics.Descent) / 2D; + } + finally + { + typeface?.Dispose(); + } + } + + private static void ApplyTextPosition(text_base textBase, ref double currentX, ref double currentY) + { + if (TryParseFirstCoordinate(textBase.x, out var x)) + { + currentX = x; + } + + if (TryParseFirstCoordinate(textBase.y, out var y)) + { + currentY = y; + } + + if (TryParseFirstCoordinate(textBase.dx, out var dx)) + { + currentX += dx; + } + + if (TryParseFirstCoordinate(textBase.dy, out var dy)) + { + currentY += dy; + } + } + + private static bool TryParseFirstCoordinate(string? value, out double coordinate) + { + coordinate = 0D; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parsed = numbers.Parse(value).Number; + if (parsed is null || parsed.Count == 0) + { + return false; + } + + coordinate = parsed[0]; + return true; + } + + private static double MeasureTextContentWidth(string? text, element styleSource) + { + if (string.IsNullOrEmpty(text)) + { + return 0D; + } + + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var font = typeface is null + ? new SkiaFont { Size = (float)ResolveFontSize(styleSource) } + : new SkiaFont(typeface, (float)ResolveFontSize(styleSource)); + return font.MeasureText(text); + } + finally + { + typeface?.Dispose(); + } + } + + private static double ResolveFontSize(element element) + { + var inherited = element.ParentElement is { } parent + ? ResolveFontSize(parent) + : 16D; + + if (element.ReadLocalValue(element.font_sizeProperty) != DependencyProperty.UnsetValue) + { + return ConvertFontSizeToPixels(element.font_size, inherited); + } + + return TryGetStyleDeclaration(element.style, "font-size", out var styleFontSize) + && TryParseSvgUnit(styleFontSize, out var styleFontSizeUnit) + ? ConvertFontSizeToPixels(styleFontSizeUnit, inherited) + : inherited; + } + + private static double ConvertFontSizeToPixels(Svg.SvgUnit unit, double inherited) + { + if (unit.IsEmpty || unit.IsNone || unit.Value <= 0f) + { + return inherited; + } + + return unit.Type switch + { + Svg.SvgUnitType.Pixel or Svg.SvgUnitType.User => unit.Value, + Svg.SvgUnitType.Em => inherited * unit.Value, + Svg.SvgUnitType.Ex => inherited * unit.Value * 0.5D, + Svg.SvgUnitType.Percentage => inherited * unit.Value / 100D, + Svg.SvgUnitType.Inch => unit.Value * 96D, + Svg.SvgUnitType.Centimeter => unit.Value * 96D / 2.54D, + Svg.SvgUnitType.Millimeter => unit.Value * 96D / 25.4D, + Svg.SvgUnitType.Pica => unit.Value * 16D, + Svg.SvgUnitType.Point => unit.Value * 96D / 72D, + _ => inherited + }; + } + + private static string? ResolveFontFamily(element element) + { + for (var current = element; current is not null; current = current.ParentElement) + { + var family = current.ReadLocalValue(element.font_familyProperty) != DependencyProperty.UnsetValue + ? current.font_family + : null; + if (string.IsNullOrWhiteSpace(family) + && TryGetStyleDeclaration(current.style, "font-family", out var styleFamily)) + { + family = styleFamily; + } + + if (string.IsNullOrWhiteSpace(family)) + { + continue; + } + + return family.Split(',')[0].Trim().Trim('\'', '"'); + } + + return null; + } + + private static bool TryGetStyleDeclaration(string? style, string propertyName, out string? value) + { + value = null; + + if (string.IsNullOrWhiteSpace(style)) + { + return false; + } + + foreach (var declaration in style.Split(';')) + { + var separator = declaration.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + var name = declaration[..separator].Trim(); + if (!string.Equals(name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + value = declaration[(separator + 1)..].Trim(); + return value.Length > 0; + } + + return false; + } + + private static bool TryParseSvgUnit(string? value, out Svg.SvgUnit unit) + { + unit = default; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + try + { + if (new Svg.SvgUnitConverter().ConvertFromString(value.Trim()) is Svg.SvgUnit parsed) + { + unit = parsed; + return true; + } + } + catch (FormatException) + { + } + catch (NotSupportedException) + { + } + + return false; + } + + private static Rect TransformPictureBoundsToControl(Rect pictureBounds, Matrix3x2 pictureToControl) + { + var topLeft = Vector2.Transform(new Vector2((float)pictureBounds.Left, (float)pictureBounds.Top), pictureToControl); + var topRight = Vector2.Transform(new Vector2((float)pictureBounds.Right, (float)pictureBounds.Top), pictureToControl); + var bottomRight = Vector2.Transform(new Vector2((float)pictureBounds.Right, (float)pictureBounds.Bottom), pictureToControl); + var bottomLeft = Vector2.Transform(new Vector2((float)pictureBounds.Left, (float)pictureBounds.Bottom), pictureToControl); + + var minX = Math.Min(Math.Min(topLeft.X, topRight.X), Math.Min(bottomRight.X, bottomLeft.X)); + var minY = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomRight.Y, bottomLeft.Y)); + var maxX = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomRight.X, bottomLeft.X)); + var maxY = Math.Max(Math.Max(topLeft.Y, topRight.Y), Math.Max(bottomRight.Y, bottomLeft.Y)); + + return new Rect(minX, minY, maxX - minX, maxY - minY); + } + + private void InvalidateDrawingSurface() + { + _drawingSurface?.Invalidate(); + } + + private void RenderPicture(SkiaCanvas canvas, Size area) + { + var picture = _picture; + if (picture is null) + { + return; + } + + if (!SvgRenderLayout.TryCreateRenderInfo( + new SvgSize(area.Width, area.Height), + new SvgRect( + picture.CullRect.Left, + picture.CullRect.Top, + picture.CullRect.Width, + picture.CullRect.Height), + Stretch, + StretchDirection, + out var renderInfo)) + { + return; + } + + canvas.Save(); + canvas.ClipRect(ToSKRect(renderInfo.DestinationRect)); + var matrix = ToSKMatrix(renderInfo.Matrix); + canvas.Concat(in matrix); + canvas.DrawPicture(picture); + canvas.Restore(); + } + + private static HostedControlEntry? CreateHostedControlEntry(element element, IHostedControlElement host) + { + return host.HostedControl is UIElement control + ? new HostedControlEntry(element, control, host) + : null; + } + + private static bool IsInlineHostedControl(HostedControlEntry entry) + { + return GetInlineLayoutRoot(entry.Element) is not null; + } + + private Border GetOrCreateHostedControlPresenter(UIElement control) + { + if (_hostedControlPresenters.TryGetValue(control, out var presenter)) + { + return presenter; + } + + presenter = new Border + { + Child = control, + HorizontalAlignment = HorizontalAlignment.Left, + VerticalAlignment = VerticalAlignment.Top + }; + + Canvas.SetZIndex(presenter, 1); + _hostedControlPresenters.Add(control, presenter); + _layoutRoot.Children.Add(presenter); + return presenter; + } + + private static void HideHostedControlPresenter(Border presenter) + { + presenter.Visibility = Visibility.Collapsed; + } + + private void CloseHostedControlPresenters() + { + foreach (var presenter in _hostedControlPresenters.Values) + { + presenter.Visibility = Visibility.Collapsed; + } + } + + private void DisposeHostedControlPresenter(UIElement control) + { + if (!_hostedControlPresenters.Remove(control, out var presenter)) + { + return; + } + + presenter.Child = null; + _layoutRoot.Children.Remove(presenter); + } + + private sealed class SvgDrawingSurface(svg owner) : SKCanvasElement + { + protected override void RenderOverride(SkiaCanvas canvas, Size area) + { + owner.RenderPicture(canvas, area); + } + } + + private readonly record struct HostedControlEntry(element Element, UIElement Control, IHostedControlElement Host); +} diff --git a/src/SvgML.Uno/Uno/Extensibility/foreignObject.Properties.cs b/src/SvgML.Uno/Uno/Extensibility/foreignObject.Properties.cs new file mode 100644 index 0000000000..283f21b3c4 --- /dev/null +++ b/src/SvgML.Uno/Uno/Extensibility/foreignObject.Properties.cs @@ -0,0 +1,56 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Markup; +using Windows.Foundation; + +namespace SvgML; + +[ContentProperty(Name = nameof(Child))] +public partial class foreignObject +{ + public static readonly DependencyProperty ChildProperty = + DependencyProperty.Register( + nameof(Child), + typeof(UIElement), + typeof(foreignObject), + new PropertyMetadata(null, OnSvgPropertyChanged)); + + public UIElement? Child + { + get => (UIElement?)GetValue(ChildProperty); + set => SetValue(ChildProperty, value); + } + + internal partial HostedControlSize MeasureHostedControl() + { + if (Child is null) + { + return HostedControlSize.Empty; + } + + Child.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity)); + return HostedControlSize.From(Child.DesiredSize.Width, Child.DesiredSize.Height); + } + + internal partial bool IsWidthSet() + { + return ReadLocalValue(widthProperty) != DependencyProperty.UnsetValue; + } + + internal partial bool IsHeightSet() + { + return ReadLocalValue(heightProperty) != DependencyProperty.UnsetValue; + } + + internal partial bool IsInTextTree() + { + for (var current = ParentElement; current is not null; current = current.ParentElement) + { + if (current is text_base) + { + return true; + } + } + + return false; + } +} diff --git a/src/SvgML.Uno/Uno/content.Properties.cs b/src/SvgML.Uno/Uno/content.Properties.cs index ac619ffe9c..7f30c630c2 100644 --- a/src/SvgML.Uno/Uno/content.Properties.cs +++ b/src/SvgML.Uno/Uno/content.Properties.cs @@ -4,16 +4,16 @@ namespace SvgML; [ContentProperty(Name = nameof(Content))] -internal partial class content +public partial class content { - public static readonly DependencyProperty ContentProperty = + public new static readonly DependencyProperty ContentProperty = DependencyProperty.Register( nameof(Content), typeof(string), typeof(content), new PropertyMetadata(string.Empty, OnSvgPropertyChanged)); - public string? Content + public new string? Content { get => (string?)GetValue(ContentProperty); set => SetValue(ContentProperty, value); diff --git a/src/SvgML.Uno/Uno/element.Properties.cs b/src/SvgML.Uno/Uno/element.Properties.cs index b1fa6a392b..dfef961e4e 100644 --- a/src/SvgML.Uno/Uno/element.Properties.cs +++ b/src/SvgML.Uno/Uno/element.Properties.cs @@ -1,13 +1,10 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Markup; -using SkiaSharp; -using Uno.WinUI.Graphics2DSK; -using Windows.Foundation; namespace SvgML; [ContentProperty(Name = nameof(Children))] -public abstract partial class element : SKCanvasElement +public abstract partial class element { private readonly List _attachedChildren = []; private element? _parentElement; @@ -24,10 +21,6 @@ protected element() internal svg? RootSvg => _rootSvg; - protected override void RenderOverride(SKCanvas canvas, Size area) - { - } - protected static void OnSvgPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { if (d is element element) From 87a044fb32949e29feb6a22cef95d25cebfcef70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:55:55 +0200 Subject: [PATCH 05/21] feat(svgml): host foreignObject controls in MAUI --- .../Generated/element.Properties.g.cs | 2 +- .../Maui/AppHostBuilderExtensions.cs | 3 + .../Maui/Document Structure/svg.Control.cs | 16 +- .../Document Structure/svg.HostedControls.cs | 392 ++++++++++++++++++ .../Maui/Document Structure/svg.Loading.cs | 64 ++- .../Extensibility/foreignObject.Properties.cs | 48 +++ src/SvgML.Maui/Maui/content.Properties.cs | 6 +- src/SvgML.Maui/Maui/element.Invalidation.cs | 2 +- src/SvgML.Maui/Maui/element.Properties.cs | 2 +- src/SvgML.Maui/Properties/AssemblyInfo.cs | 2 + 10 files changed, 518 insertions(+), 19 deletions(-) create mode 100644 src/SvgML.Maui/Maui/Document Structure/svg.HostedControls.cs create mode 100644 src/SvgML.Maui/Maui/Extensibility/foreignObject.Properties.cs diff --git a/src/SvgML.Maui/Generated/element.Properties.g.cs b/src/SvgML.Maui/Generated/element.Properties.g.cs index 0fec8f3a3c..149d46d455 100644 --- a/src/SvgML.Maui/Generated/element.Properties.g.cs +++ b/src/SvgML.Maui/Generated/element.Properties.g.cs @@ -3,7 +3,7 @@ namespace SvgML; -public abstract partial class element : SkiaSharp.Views.Maui.Controls.SKCanvasView +public abstract partial class element : Microsoft.Maui.Controls.ContentView { protected abstract string SvgTag { get; } diff --git a/src/SvgML.Maui/Maui/AppHostBuilderExtensions.cs b/src/SvgML.Maui/Maui/AppHostBuilderExtensions.cs index 396cf973c3..68c22e47ff 100644 --- a/src/SvgML.Maui/Maui/AppHostBuilderExtensions.cs +++ b/src/SvgML.Maui/Maui/AppHostBuilderExtensions.cs @@ -13,6 +13,9 @@ public static MauiAppBuilder UseSvgML(this MauiAppBuilder builder) GC.KeepAlive(typeof(stop)); GC.KeepAlive(typeof(rect)); GC.KeepAlive(typeof(circle)); + GC.KeepAlive(typeof(text)); + GC.KeepAlive(typeof(tspan)); + GC.KeepAlive(typeof(foreignObject)); // TODO: Add all types from SvgML.Maui into UseSvgML() extension method diff --git a/src/SvgML.Maui/Maui/Document Structure/svg.Control.cs b/src/SvgML.Maui/Maui/Document Structure/svg.Control.cs index 56eeb97d5c..93b9fd8cb8 100644 --- a/src/SvgML.Maui/Maui/Document Structure/svg.Control.cs +++ b/src/SvgML.Maui/Maui/Document Structure/svg.Control.cs @@ -2,7 +2,6 @@ using System.Numerics; using Microsoft.Maui.Graphics; using SkiaSharp; -using SkiaSharp.Views.Maui; using Svg; using Svg.Model; using Svg.Skia; @@ -24,6 +23,7 @@ static svg() public svg() { + InitializeHostedControls(); AttachToTree(parent: null, root: this); Loaded += OnLoaded; } @@ -261,6 +261,7 @@ protected override Size MeasureOverride(double widthConstraint, double heightCon var picture = _picture; if (picture is null) { + _layoutRoot.Measure(0D, 0D); return new Size(); } @@ -270,15 +271,10 @@ protected override Size MeasureOverride(double widthConstraint, double heightCon Stretch, StretchDirection); + _layoutRoot.Measure(size.Width, size.Height); return new Size(size.Width, size.Height); } - protected override void OnPaintSurface(SKPaintSurfaceEventArgs e) - { - base.OnPaintSurface(e); - Render(e.Surface.Canvas, e.Info.Width, e.Info.Height); - } - private void Render(SKCanvas canvas, int width, int height) { var picture = _picture; @@ -305,7 +301,7 @@ private void Render(SKCanvas canvas, int width, int height) canvas.Restore(); } - protected override void OnPropertyChanged(string propertyName = null) + protected override void OnPropertyChanged(string? propertyName = null) { base.OnPropertyChanged(propertyName); @@ -324,7 +320,7 @@ protected override void OnPropertyChanged(string propertyName = null) if (propertyName == nameof(Stretch) || propertyName == nameof(StretchDirection)) { InvalidateMeasure(); - InvalidateSurface(); + InvalidateDrawingSurface(); return; } @@ -343,7 +339,7 @@ private void ReloadAndInvalidate() { OnSourceChanged(this); InvalidateMeasure(); - InvalidateSurface(); + InvalidateDrawingSurface(); } private void OnSourceChanged(svg? source) diff --git a/src/SvgML.Maui/Maui/Document Structure/svg.HostedControls.cs b/src/SvgML.Maui/Maui/Document Structure/svg.HostedControls.cs new file mode 100644 index 0000000000..03b4fdf1fe --- /dev/null +++ b/src/SvgML.Maui/Maui/Document Structure/svg.HostedControls.cs @@ -0,0 +1,392 @@ +using System.Numerics; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Graphics; +using SkiaSharp.Views.Maui.Controls; +using SkiaFont = SkiaSharp.SKFont; +using SkiaPaint = SkiaSharp.SKPaint; +using SkiaTypeface = SkiaSharp.SKTypeface; + +namespace SvgML; + +public partial class svg +{ + private readonly AbsoluteLayout _layoutRoot = new(); + private readonly SKCanvasView _drawingSurface = new(); + private readonly List _hostedControlEntries = []; + private readonly HashSet _attachedHostedControls = []; + + private void InitializeHostedControls() + { + _drawingSurface.PaintSurface += OnDrawingSurfacePaintSurface; + AbsoluteLayout.SetLayoutFlags(_drawingSurface, Microsoft.Maui.Layouts.AbsoluteLayoutFlags.All); + AbsoluteLayout.SetLayoutBounds(_drawingSurface, new Rect(0D, 0D, 1D, 1D)); + _layoutRoot.Children.Add(_drawingSurface); + Content = _layoutRoot; + } + + protected override Size ArrangeOverride(Rect bounds) + { + var arranged = base.ArrangeOverride(bounds); + ArrangeHostedControls(); + return arranged; + } + + private void SynchronizeHostedControls() + { + var desiredEntries = HostedControlTree + .Enumerate(this) + .Select(static entry => CreateHostedControlEntry(entry.Element, entry.Host)) + .Where(static entry => entry is not null) + .Select(static entry => entry!.Value) + .ToList(); + + var desiredViews = new HashSet(desiredEntries.Select(static entry => entry.View)); + + foreach (var view in _attachedHostedControls.Where(view => !desiredViews.Contains(view)).ToList()) + { + _layoutRoot.Children.Remove(view); + _attachedHostedControls.Remove(view); + } + + foreach (var view in desiredEntries.Select(static entry => entry.View)) + { + if (_attachedHostedControls.Add(view)) + { + _layoutRoot.Children.Add(view); + } + } + + _hostedControlEntries.Clear(); + _hostedControlEntries.AddRange(desiredEntries); + InvalidateMeasure(); + } + + private void ArrangeHostedControls() + { + foreach (var entry in _hostedControlEntries) + { + var bounds = GetHostedControlBounds(entry); + if (bounds.Width <= 0D || bounds.Height <= 0D) + { + entry.View.Arrange(default); + continue; + } + + AbsoluteLayout.SetLayoutFlags(entry.View, Microsoft.Maui.Layouts.AbsoluteLayoutFlags.None); + AbsoluteLayout.SetLayoutBounds(entry.View, bounds); + entry.View.Arrange(bounds); + } + } + + private Rect GetHostedControlBounds(HostedControlEntry entry) + { + var isInline = IsInlineHostedControl(entry); + var bounds = GetControlBounds(entry.Element); + if (isInline && TryGetInlineHostedControlBounds(entry.Element, entry.Host, out var inlineBounds)) + { + bounds = inlineBounds; + } + + if (!isInline || bounds.Width <= 0D || bounds.Height <= 0D) + { + return bounds; + } + + var desired = entry.View.Measure(double.PositiveInfinity, double.PositiveInfinity); + var width = desired.Width > 0D ? desired.Width : bounds.Width; + var height = desired.Height > 0D ? desired.Height : bounds.Height; + return new Rect(bounds.X, bounds.Bottom - height, width, height); + } + + private bool TryGetInlineHostedControlBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + if (!TryGetRenderInfo(out var renderInfo)) + { + return false; + } + + if (!TryGetInlinePictureBounds(inlineElement, host, out var pictureBounds)) + { + return false; + } + + bounds = TransformPictureBoundsToControl(pictureBounds, renderInfo.Matrix); + return bounds.Width > 0D && bounds.Height > 0D; + } + + private bool TryGetInlinePictureBounds(element inlineElement, IHostedControlElement host, out Rect bounds) + { + bounds = default; + + var layoutRoot = GetInlineLayoutRoot(inlineElement); + if (layoutRoot is null) + { + return false; + } + + var currentX = 0D; + var currentY = 0D; + return TryLocateInlinePictureBounds(layoutRoot, inlineElement, host, ref currentX, ref currentY, out bounds); + } + + private static text_base? GetInlineLayoutRoot(element inlineElement) + { + text_base? result = null; + + for (var current = inlineElement.ParentElement; current is not null; current = current.ParentElement) + { + if (current is text_base textBase) + { + result = textBase; + } + } + + return result; + } + + private bool TryLocateInlinePictureBounds( + text_base container, + element target, + IHostedControlElement targetHost, + ref double currentX, + ref double currentY, + out Rect bounds) + { + ApplyTextPosition(container, ref currentX, ref currentY); + + foreach (var child in container.Children) + { + switch (child) + { + case content textContent: + currentX += MeasureTextContentWidth(textContent.Content, container); + break; + + case element hostedElement when hostedElement is IHostedControlElement hostedChild + && hostedChild.HostedControl is not null: + var size = hostedChild.GetHostedControlSize().OrFallback(); + if (ReferenceEquals(hostedElement, target) && ReferenceEquals(hostedChild, targetHost)) + { + bounds = new Rect( + currentX, + GetInlinePictureTop(container, size.Height, currentY), + size.Width, + size.Height); + return true; + } + + currentX += size.Width; + break; + + case text_base nestedText: + var childX = currentX; + var childY = currentY; + if (TryLocateInlinePictureBounds(nestedText, target, targetHost, ref childX, ref childY, out bounds)) + { + currentX = childX; + currentY = childY; + return true; + } + + currentX = childX; + currentY = childY; + break; + } + } + + bounds = default; + return false; + } + + private static double GetInlinePictureTop(element styleSource, double height, double baselineY) + { + return baselineY + GetTextCenterOffset(styleSource) - height / 2D; + } + + private static double GetTextCenterOffset(element styleSource) + { + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var font = typeface is null + ? new SkiaFont { Size = (float)ResolveFontSize(styleSource) } + : new SkiaFont(typeface, (float)ResolveFontSize(styleSource)); + font.GetFontMetrics(out var metrics); + return (metrics.Ascent + metrics.Descent) / 2D; + } + finally + { + typeface?.Dispose(); + } + } + + private static void ApplyTextPosition(text_base textBase, ref double currentX, ref double currentY) + { + if (TryParseFirstCoordinate(textBase.x, out var x)) + { + currentX = x; + } + + if (TryParseFirstCoordinate(textBase.y, out var y)) + { + currentY = y; + } + + if (TryParseFirstCoordinate(textBase.dx, out var dx)) + { + currentX += dx; + } + + if (TryParseFirstCoordinate(textBase.dy, out var dy)) + { + currentY += dy; + } + } + + private static bool TryParseFirstCoordinate(string? value, out double coordinate) + { + coordinate = 0D; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var parsed = numbers.Parse(value).Number; + if (parsed is null || parsed.Count == 0) + { + return false; + } + + coordinate = parsed[0]; + return true; + } + + private static double MeasureTextContentWidth(string? text, element styleSource) + { + if (string.IsNullOrEmpty(text)) + { + return 0D; + } + + SkiaTypeface? typeface = null; + if (ResolveFontFamily(styleSource) is { Length: > 0 } familyName) + { + typeface = SkiaTypeface.FromFamilyName(familyName); + } + + try + { + using var paint = new SkiaPaint + { + Typeface = typeface, + TextSize = (float)ResolveFontSize(styleSource) + }; + return paint.MeasureText(text); + } + finally + { + typeface?.Dispose(); + } + } + + private static double ResolveFontSize(element element) + { + var inherited = element.ParentElement is { } parent + ? ResolveFontSize(parent) + : 16D; + + return element.IsSet(element.font_sizeProperty) + ? ConvertFontSizeToPixels(element.font_size, inherited) + : inherited; + } + + private static double ConvertFontSizeToPixels(Svg.SvgUnit unit, double inherited) + { + if (unit.IsEmpty || unit.IsNone || unit.Value <= 0f) + { + return inherited; + } + + return unit.Type switch + { + Svg.SvgUnitType.Pixel or Svg.SvgUnitType.User => unit.Value, + Svg.SvgUnitType.Em => inherited * unit.Value, + Svg.SvgUnitType.Ex => inherited * unit.Value * 0.5D, + Svg.SvgUnitType.Percentage => inherited * unit.Value / 100D, + Svg.SvgUnitType.Inch => unit.Value * 96D, + Svg.SvgUnitType.Centimeter => unit.Value * 96D / 2.54D, + Svg.SvgUnitType.Millimeter => unit.Value * 96D / 25.4D, + Svg.SvgUnitType.Pica => unit.Value * 16D, + Svg.SvgUnitType.Point => unit.Value * 96D / 72D, + _ => inherited + }; + } + + private static string? ResolveFontFamily(element element) + { + for (var current = element; current is not null; current = current.ParentElement) + { + if (!current.IsSet(element.font_familyProperty)) + { + continue; + } + + var family = current.font_family; + if (string.IsNullOrWhiteSpace(family)) + { + continue; + } + + return family.Split(',')[0].Trim().Trim('\'', '"'); + } + + return null; + } + + private static Rect TransformPictureBoundsToControl(Rect pictureBounds, Matrix3x2 pictureToControl) + { + var topLeft = Vector2.Transform(new Vector2((float)pictureBounds.Left, (float)pictureBounds.Top), pictureToControl); + var topRight = Vector2.Transform(new Vector2((float)pictureBounds.Right, (float)pictureBounds.Top), pictureToControl); + var bottomRight = Vector2.Transform(new Vector2((float)pictureBounds.Right, (float)pictureBounds.Bottom), pictureToControl); + var bottomLeft = Vector2.Transform(new Vector2((float)pictureBounds.Left, (float)pictureBounds.Bottom), pictureToControl); + + var minX = Math.Min(Math.Min(topLeft.X, topRight.X), Math.Min(bottomRight.X, bottomLeft.X)); + var minY = Math.Min(Math.Min(topLeft.Y, topRight.Y), Math.Min(bottomRight.Y, bottomLeft.Y)); + var maxX = Math.Max(Math.Max(topLeft.X, topRight.X), Math.Max(bottomRight.X, bottomLeft.X)); + var maxY = Math.Max(Math.Max(topLeft.Y, topRight.Y), Math.Max(bottomRight.Y, bottomLeft.Y)); + + return new Rect(minX, minY, maxX - minX, maxY - minY); + } + + private void InvalidateDrawingSurface() + { + _drawingSurface.InvalidateSurface(); + } + + private void OnDrawingSurfacePaintSurface(object? sender, SkiaSharp.Views.Maui.SKPaintSurfaceEventArgs e) + { + Render(e.Surface.Canvas, e.Info.Width, e.Info.Height); + } + + private static HostedControlEntry? CreateHostedControlEntry(element element, IHostedControlElement host) + { + return host.HostedControl is View view + ? new HostedControlEntry(element, view, host) + : null; + } + + private static bool IsInlineHostedControl(HostedControlEntry entry) + { + return GetInlineLayoutRoot(entry.Element) is not null; + } + + private readonly record struct HostedControlEntry(element Element, View View, IHostedControlElement Host); +} diff --git a/src/SvgML.Maui/Maui/Document Structure/svg.Loading.cs b/src/SvgML.Maui/Maui/Document Structure/svg.Loading.cs index d5f03b0f44..d69513521c 100644 --- a/src/SvgML.Maui/Maui/Document Structure/svg.Loading.cs +++ b/src/SvgML.Maui/Maui/Document Structure/svg.Loading.cs @@ -128,11 +128,13 @@ private void UpdateElementMappings(SKSvg? skSvg, SvgDocument? document) if (document is null || skSvg is null || !skSvg.TryEnsureRetainedSceneGraph(out var sceneDocument) || sceneDocument is null) { + SynchronizeHostedControls(); return; } var metrics = BuildSceneNodeMetrics(sceneDocument); MapElementRecursive(this, document, metrics); + SynchronizeHostedControls(); } private static Dictionary BuildSceneNodeMetrics(SvgSceneDocument sceneDocument) @@ -171,7 +173,7 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction { _elementBySvgElement[svgElement] = control; - if (metrics.TryGetValue(svgElement, out var metric)) + if (TryGetSceneNodeMetrics(svgElement, metrics, out var metric)) { control.UpdateSvgData( svgElement, @@ -226,9 +228,10 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return null; } - if (!string.IsNullOrEmpty(control.id)) + var mappingId = control.GetSvgMappingId(); + if (!string.IsNullOrEmpty(mappingId)) { - var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, control.id, StringComparison.Ordinal)); + var byId = svgChildren.FirstOrDefault(e => string.Equals(e.ID, mappingId, StringComparison.Ordinal)); if (byId is not null) { svgChildren.Remove(byId); @@ -258,6 +261,61 @@ private void MapElementRecursive(element control, SvgElement svgElement, Diction return attribute?.ElementName ?? element.ID; } + private static bool TryGetSceneNodeMetrics(SvgElement svgElement, Dictionary metrics, out SceneNodeMetrics metric) + { + var aggregate = CreateAggregateSceneNodeMetrics(svgElement, metrics); + if (aggregate is null) + { + metric = null!; + return false; + } + + metric = aggregate; + return true; + } + + private static SceneNodeMetrics? CreateAggregateSceneNodeMetrics(SvgElement svgElement, Dictionary metrics) + { + SceneNodeMetrics? aggregate = metrics.TryGetValue(svgElement, out var direct) + ? CloneSceneNodeMetrics(direct) + : null; + + foreach (var child in svgElement.Children.OfType()) + { + var childMetrics = CreateAggregateSceneNodeMetrics(child, metrics); + if (childMetrics is null) + { + continue; + } + + if (aggregate is null) + { + aggregate = childMetrics; + continue; + } + + aggregate.Geometry = UnionRect(aggregate.Geometry, childMetrics.Geometry); + aggregate.Transformed = UnionRect(aggregate.Transformed, childMetrics.Transformed); + aggregate.SceneNodes.AddRange(childMetrics.SceneNodes); + } + + return aggregate; + } + + private static SceneNodeMetrics CloneSceneNodeMetrics(SceneNodeMetrics source) + { + var clone = new SceneNodeMetrics + { + Geometry = source.Geometry, + Transformed = source.Transformed, + Transform = source.Transform, + TotalTransform = source.TotalTransform + }; + + clone.SceneNodes.AddRange(source.SceneNodes); + return clone; + } + private static void ClearElementData(element control) { control.ClearSvgData(); diff --git a/src/SvgML.Maui/Maui/Extensibility/foreignObject.Properties.cs b/src/SvgML.Maui/Maui/Extensibility/foreignObject.Properties.cs new file mode 100644 index 0000000000..c66ed9a66b --- /dev/null +++ b/src/SvgML.Maui/Maui/Extensibility/foreignObject.Properties.cs @@ -0,0 +1,48 @@ +namespace SvgML; + +[ContentProperty(nameof(Child))] +public partial class foreignObject +{ + public static readonly Microsoft.Maui.Controls.BindableProperty ChildProperty = + Microsoft.Maui.Controls.BindableProperty.Create(nameof(Child), typeof(Microsoft.Maui.Controls.View), typeof(foreignObject)); + + public Microsoft.Maui.Controls.View? Child + { + get => (Microsoft.Maui.Controls.View?)GetValue(ChildProperty); + set => SetValue(ChildProperty, value); + } + + internal partial HostedControlSize MeasureHostedControl() + { + if (Child is null) + { + return HostedControlSize.Empty; + } + + var size = Child.Measure(double.PositiveInfinity, double.PositiveInfinity); + return HostedControlSize.From(size.Width, size.Height); + } + + internal partial bool IsWidthSet() + { + return this.IsSet(widthProperty); + } + + internal partial bool IsHeightSet() + { + return this.IsSet(heightProperty); + } + + internal partial bool IsInTextTree() + { + for (var current = ParentElement; current is not null; current = current.ParentElement) + { + if (current is text_base) + { + return true; + } + } + + return false; + } +} diff --git a/src/SvgML.Maui/Maui/content.Properties.cs b/src/SvgML.Maui/Maui/content.Properties.cs index 73686239e5..2b22674d82 100644 --- a/src/SvgML.Maui/Maui/content.Properties.cs +++ b/src/SvgML.Maui/Maui/content.Properties.cs @@ -1,12 +1,12 @@ namespace SvgML; [ContentProperty("Content")] -internal partial class content +public partial class content { - public static readonly Microsoft.Maui.Controls.BindableProperty ContentProperty = + public new static readonly Microsoft.Maui.Controls.BindableProperty ContentProperty = Microsoft.Maui.Controls.BindableProperty.Create("Content", typeof(string), typeof(content)); - public string? Content + public new string? Content { get => (string?)GetValue(ContentProperty); set => SetValue(ContentProperty, value); diff --git a/src/SvgML.Maui/Maui/element.Invalidation.cs b/src/SvgML.Maui/Maui/element.Invalidation.cs index 4aa8699f9e..5f220cc3a5 100644 --- a/src/SvgML.Maui/Maui/element.Invalidation.cs +++ b/src/SvgML.Maui/Maui/element.Invalidation.cs @@ -33,7 +33,7 @@ protected virtual void ChildrenChanged(object? sender, NotifyCollectionChangedEv OnSvgChanged(); } - protected override void OnPropertyChanged(string propertyName = null) + protected override void OnPropertyChanged(string? propertyName = null) { base.OnPropertyChanged(propertyName); diff --git a/src/SvgML.Maui/Maui/element.Properties.cs b/src/SvgML.Maui/Maui/element.Properties.cs index d2abc4ae78..890052acc0 100644 --- a/src/SvgML.Maui/Maui/element.Properties.cs +++ b/src/SvgML.Maui/Maui/element.Properties.cs @@ -12,7 +12,7 @@ public element() Children.CollectionChanged += ChildrenChanged; } - public elements Children { get; } = new elements(); + public new elements Children { get; } = new elements(); internal element? ParentElement => _parentElement; diff --git a/src/SvgML.Maui/Properties/AssemblyInfo.cs b/src/SvgML.Maui/Properties/AssemblyInfo.cs index 9bf75b54ae..a019869199 100644 --- a/src/SvgML.Maui/Properties/AssemblyInfo.cs +++ b/src/SvgML.Maui/Properties/AssemblyInfo.cs @@ -3,3 +3,5 @@ using Microsoft.Maui.Controls.Internals; [assembly: Preserve] +[assembly: Microsoft.Maui.Controls.XmlnsDefinition("https://github.com/svgml", "SvgML")] +[assembly: Microsoft.Maui.Controls.XmlnsPrefix("https://github.com/svgml", "svgml")] From 7dc59d9c2489d3350e59fcc37748f35527a9a3e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 21 Apr 2026 13:56:15 +0200 Subject: [PATCH 06/21] samples: demonstrate SvgML foreignObject controls --- samples/SvgML.Avalonia.Demo/MainWindow.axaml | 100 ++++++++-- samples/SvgML.Maui.Demo/App.xaml.cs | 13 +- samples/SvgML.Maui.Demo/AppShell.xaml | 14 +- samples/SvgML.Maui.Demo/AppShell.xaml.cs | 8 +- .../SvgML.Maui.Demo/InlineControlsPage.xaml | 88 +++++++++ .../InlineControlsPage.xaml.cs | 9 + samples/SvgML.Maui.Demo/MainPage.xaml | 178 +++++------------- samples/SvgML.Maui.Demo/MainPage.xaml.cs | 74 +------- samples/SvgML.Maui.Demo/MauiProgram.cs | 32 ++-- .../Platforms/Android/MainApplication.cs | 10 +- .../Platforms/MacCatalyst/AppDelegate.cs | 2 +- .../Platforms/MacCatalyst/Program.cs | 14 +- .../Platforms/iOS/AppDelegate.cs | 2 +- .../SvgML.Maui.Demo/Platforms/iOS/Program.cs | 14 +- samples/SvgML.Uno.Demo/MainPage.xaml | 73 ++++++- samples/SvgML.Uno.Demo/MainPage.xaml.cs | 13 ++ 16 files changed, 380 insertions(+), 264 deletions(-) create mode 100644 samples/SvgML.Maui.Demo/InlineControlsPage.xaml create mode 100644 samples/SvgML.Maui.Demo/InlineControlsPage.xaml.cs diff --git a/samples/SvgML.Avalonia.Demo/MainWindow.axaml b/samples/SvgML.Avalonia.Demo/MainWindow.axaml index da18c1ce4b..0a3c32e66c 100644 --- a/samples/SvgML.Avalonia.Demo/MainWindow.axaml +++ b/samples/SvgML.Avalonia.Demo/MainWindow.axaml @@ -2,9 +2,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500" + mc:Ignorable="d" d:DesignWidth="960" d:DesignHeight="680" x:Class="SvgML.Avalonia.Demo.MainWindow" - Width="800" Height="500" + Width="960" Height="680" Title="SvgML.Avalonia Demo"> @@ -52,16 +52,90 @@ - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Release review + + Approve + +