diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 3970ae6a1..9335d30d9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -45,11 +45,23 @@ jobs:
- name: Verify SDK Installation
run: dotnet --info
+ - name: Select build target
+ id: build-target
+ run: |
+ # HtmlRenderer.WinUI/HtmlRenderer.Demo.WinUI (Windows App SDK/Win2D, MSIX PRI generation via
+ # native makepri.exe/makeappx.exe) can only build on Windows - unlike the WinForms/WPF adapters,
+ # which cross-compile cleanly everywhere via EnableWindowsTargeting's reference-assembly story,
+ # there is no non-Windows equivalent for those native tools at all. Non-Windows runners build a
+ # solution filter excluding just those two projects; everything else still builds and tests
+ # identically on every OS.
+ $target = $IsWindows ? "Source/HtmlRenderer.sln" : "Source/HtmlRenderer.CrossPlatform.slnf"
+ echo "path=$target" >> $env:GITHUB_OUTPUT
+
- name: Restore Dependencies
- run: dotnet restore Source/HtmlRenderer.sln
+ run: dotnet restore ${{ steps.build-target.outputs.path }}
- name: Build
- run: dotnet build Source/HtmlRenderer.sln --configuration Release --no-restore
+ run: dotnet build ${{ steps.build-target.outputs.path }} --configuration Release --no-restore
- name: Run Tests
if: runner.os == 'Windows'
diff --git a/Source/Demo/WinUI/App.xaml b/Source/Demo/WinUI/App.xaml
new file mode 100644
index 000000000..d8ca34c0e
--- /dev/null
+++ b/Source/Demo/WinUI/App.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Demo/WinUI/App.xaml.cs b/Source/Demo/WinUI/App.xaml.cs
new file mode 100644
index 000000000..cad246ffb
--- /dev/null
+++ b/Source/Demo/WinUI/App.xaml.cs
@@ -0,0 +1,37 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.UI.Xaml;
+
+namespace TheArtOfDev.HtmlRenderer.Demo.WinUI
+{
+ ///
+ /// Minimal application entry point - hosts one with a single
+ /// rendering a hardcoded sample document, just
+ /// enough to prove the WinUI 3 control renders real HTML end to end.
+ ///
+ public partial class App : Application
+ {
+ private Window _window;
+
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnLaunched(LaunchActivatedEventArgs args)
+ {
+ _window = new MainWindow();
+ _window.Activate();
+ }
+ }
+}
diff --git a/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj
new file mode 100644
index 000000000..b2eab369f
--- /dev/null
+++ b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj
@@ -0,0 +1,33 @@
+
+
+ net8.0-windows10.0.19041.0
+ 10.0.17763.0
+ WinExe
+ TheArtOfDev.HtmlRenderer.Demo.WinUI
+ HtmlRendererWinUIDemo
+ true
+ true
+ true
+
+ None
+ true
+ true
+ win-x64
+ x64
+
+
+ app.manifest
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Demo/WinUI/MainWindow.xaml b/Source/Demo/WinUI/MainWindow.xaml
new file mode 100644
index 000000000..44b3b4aea
--- /dev/null
+++ b/Source/Demo/WinUI/MainWindow.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Demo/WinUI/MainWindow.xaml.cs b/Source/Demo/WinUI/MainWindow.xaml.cs
new file mode 100644
index 000000000..038e186b2
--- /dev/null
+++ b/Source/Demo/WinUI/MainWindow.xaml.cs
@@ -0,0 +1,179 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using TheArtOfDev.HtmlRenderer.Core.Entities;
+using TheArtOfDev.HtmlRenderer.Demo.Common;
+using Windows.Storage.Streams;
+
+namespace TheArtOfDev.HtmlRenderer.Demo.WinUI
+{
+ ///
+ /// Hosts a of the same showcase/test/performance samples the WPF and WinForms
+ /// demos expose (via the shared, UI-framework-agnostic /
+ /// from HtmlRenderer.Demo.Common) next to a
+ /// that renders whichever sample is selected.
+ ///
+ ///
+ /// Node content is a plain (the sample/category name), matching Microsoft's own
+ /// documented RootNodes pattern exactly (see
+ /// https://learn.microsoft.com/en-us/windows/apps/develop/ui/controls/tree-view) - no
+ /// is needed for that case, since a string displays directly. An
+ /// earlier version wrapped each node's content in a custom object and bound it via
+ /// TreeView.ItemTemplate's {Binding Name}, which rendered no text and never expanded -
+ /// switched to this simpler, documented shape instead of chasing the exact binding-context rules for
+ /// that pattern. maps each leaf node back to its
+ /// since the node's own displayed Content is just its name.
+ ///
+ public sealed partial class MainWindow : Window
+ {
+ private readonly Dictionary _nodeToSample = new Dictionary();
+
+ public MainWindow()
+ {
+ InitializeComponent();
+
+ Title = "HTML Renderer - WinUI 3 Demo";
+
+ SamplesLoader.Init("WinUI", typeof(TheArtOfDev.HtmlRenderer.WinUI.HtmlRender).Assembly.GetName().Version.ToString());
+
+ // Without this, showcase samples' never resolves, so
+ // e.g. Intro.htm's `.whitehole { background-color:white; border-radius:10px }` class is simply
+ // never applied at all - not a paint bug, the rule itself never loads. DemoUtils.OnStylesheetLoad
+ // is the same platform-agnostic handler the WPF/WinForms demos use.
+ RendererPanel.StylesheetLoad += DemoUtils.OnStylesheetLoad;
+
+ // Same idea as StylesheetLoad above: samples reference icons by logical key (e.g.
+ // src="HtmlIcon") that DemoUtils.GetImageStream resolves to an embedded resource - without
+ // this, every such falls through to the built-in "failed to load" icon instead.
+ RendererPanel.ImageLoad += OnImageLoad;
+
+ LoadSamples();
+ }
+
+ private void LoadSamples()
+ {
+ var showcaseRoot = new TreeViewNode { Content = "HTML Renderer" };
+ SamplesTreeView.RootNodes.Add(showcaseRoot);
+ foreach (var sample in SamplesLoader.ShowcaseSamples)
+ AddSampleNode(showcaseRoot, sample);
+
+ var testSamplesRoot = new TreeViewNode { Content = "Test Samples" };
+ SamplesTreeView.RootNodes.Add(testSamplesRoot);
+ foreach (var sample in SamplesLoader.TestSamples)
+ AddSampleNode(testSamplesRoot, sample);
+
+ if (SamplesLoader.PerformanceSamples.Count > 0)
+ {
+ var perfSamplesRoot = new TreeViewNode { Content = "Performance Samples" };
+ SamplesTreeView.RootNodes.Add(perfSamplesRoot);
+ foreach (var sample in SamplesLoader.PerformanceSamples)
+ AddSampleNode(perfSamplesRoot, sample);
+ }
+
+ showcaseRoot.IsExpanded = true;
+ if (showcaseRoot.Children.Count > 0)
+ {
+ var firstNode = showcaseRoot.Children[0];
+ SamplesTreeView.SelectedNode = firstNode;
+ ShowSample(_nodeToSample[firstNode]);
+ }
+ }
+
+ private void AddSampleNode(TreeViewNode root, HtmlSample sample)
+ {
+ var node = new TreeViewNode { Content = sample.Name };
+ _nodeToSample[node] = sample;
+ root.Children.Add(node);
+ }
+
+ private void OnSamplesTreeViewSelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args)
+ {
+ foreach (var node in sender.SelectedNodes)
+ {
+ if (_nodeToSample.TryGetValue(node, out var sample))
+ {
+ ShowSample(sample);
+ break;
+ }
+ }
+ }
+
+ private void ShowSample(HtmlSample sample)
+ {
+ try
+ {
+ RendererPanel.AvoidImagesLateLoading = !sample.FullName.Contains("Many images");
+ RendererPanel.Text = sample.Html;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex);
+ }
+ }
+
+ ///
+ /// Resolves the demo's logical icon keys (e.g. src="HtmlIcon") via
+ /// - real URLs/paths DemoUtils doesn't recognize fall
+ /// through untouched ( left false) so the default
+ /// loader still handles them.
+ ///
+ private void OnImageLoad(object sender, HtmlImageLoadEventArgs e)
+ {
+ var stream = DemoUtils.GetImageStream(e.Src);
+ if (stream == null)
+ return;
+
+ e.Handled = true;
+ _ = LoadIconAsync(e, stream);
+ }
+
+ private static async Task LoadIconAsync(HtmlImageLoadEventArgs e, Stream stream)
+ {
+ try
+ {
+ byte[] bytes;
+ using (stream)
+ using (var memory = new MemoryStream())
+ {
+ await stream.CopyToAsync(memory).ConfigureAwait(false);
+ bytes = memory.ToArray();
+ }
+
+ using var randomAccessStream = new InMemoryRandomAccessStream();
+ using (var writer = new DataWriter(randomAccessStream.GetOutputStreamAt(0)))
+ {
+ writer.WriteBytes(bytes);
+ await writer.StoreAsync();
+ await writer.FlushAsync();
+ writer.DetachStream();
+ }
+ randomAccessStream.Seek(0);
+
+ var bitmap = await CanvasBitmap.LoadAsync(CanvasDevice.GetSharedDevice(), randomAccessStream);
+ e.Callback(bitmap);
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine(ex);
+ e.Callback();
+ }
+ }
+ }
+}
diff --git a/Source/Demo/WinUI/app.manifest b/Source/Demo/WinUI/app.manifest
new file mode 100644
index 000000000..e35e3f281
--- /dev/null
+++ b/Source/Demo/WinUI/app.manifest
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2
+ true
+
+
+
diff --git a/Source/HtmlRenderer.CrossPlatform.slnf b/Source/HtmlRenderer.CrossPlatform.slnf
new file mode 100644
index 000000000..7b78f8ba8
--- /dev/null
+++ b/Source/HtmlRenderer.CrossPlatform.slnf
@@ -0,0 +1,17 @@
+{
+ "solution": {
+ "path": "HtmlRenderer.sln",
+ "projects": [
+ "Demo\\Common\\HtmlRenderer.Demo.Common.csproj",
+ "Demo\\WinForms\\HtmlRenderer.Demo.WinForms.csproj",
+ "Demo\\WPF\\HtmlRenderer.Demo.WPF.csproj",
+ "HtmlRenderer\\HtmlRenderer.csproj",
+ "HtmlRenderer.PdfSharp\\HtmlRenderer.PdfSharp.csproj",
+ "HtmlRenderer.WinForms\\HtmlRenderer.WinForms.csproj",
+ "HtmlRenderer.WPF\\HtmlRenderer.WPF.csproj",
+ "Test\\HtmlRenderer.IntegrationTest\\HtmlRenderer.IntegrationTest.csproj",
+ "Test\\HtmlRenderer.PdfSharp.Test\\HtmlRenderer.PdfSharp.Test.csproj",
+ "Test\\HtmlRenderer.Test\\HtmlRenderer.Test.csproj"
+ ]
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs
new file mode 100644
index 000000000..c000fd9ad
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs
@@ -0,0 +1,71 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Graphics.Canvas.Brushes;
+using TheArtOfDev.HtmlRenderer.Adapters;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D brushes.
+ ///
+ internal sealed class BrushAdapter : RBrush
+ {
+ ///
+ /// The actual Win2D brush instance.
+ ///
+ private readonly ICanvasBrush _brush;
+
+ ///
+ /// Init.
+ ///
+ public BrushAdapter(ICanvasBrush brush)
+ {
+ _brush = brush;
+ }
+
+ ///
+ /// The actual Win2D brush instance.
+ ///
+ public ICanvasBrush Brush
+ {
+ get { return _brush; }
+ }
+
+ ///
+ /// No-op, matching HtmlRenderer.WPF's own BrushAdapter.Dispose exactly.
+ ///
+ ///
+ /// calls brush.Dispose() unconditionally
+ /// after every background fill, including for solid-color brushes obtained from
+ /// - which is a
+ /// process-wide singleton CACHE (see that method's own doc comment: "cached... instance"),
+ /// reused for the lifetime of the adapter, not a fresh per-call resource. WPF's Brush
+ /// needs no real disposal, so that unconditional call is harmless there - it's the reason this
+ /// pattern was never caught before. Win2D's is a real unmanaged/GPU
+ /// resource: actually disposing it here permanently kills the shared cached brush for every
+ /// color after its first use, so the SECOND time any element needs (for example) a white
+ /// background, the disposed brush silently paints nothing - confirmed by reproducing exactly
+ /// this symptom (background-color:white showing the page's own gradient background through
+ /// instead) via two consecutive real calls against the
+ /// same document. Not disposing here leaks gradient/texture brushes (the one kind of
+ /// that genuinely is fresh-per-call, from
+ /// /)
+ /// - accepted deliberately, mirroring WPF's identical no-op for its own gradient/texture
+ /// Brush objects, since the alternative (silently breaking every cached solid-color fill
+ /// after its first paint) is far worse than a small, bounded resource lifetime extension.
+ ///
+ public override void Dispose()
+ {
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/ContextMenuAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/ContextMenuAdapter.cs
new file mode 100644
index 000000000..9c86807b4
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/ContextMenuAdapter.cs
@@ -0,0 +1,86 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for WinUI context menu for core.
+ ///
+ internal sealed class ContextMenuAdapter : RContextMenu
+ {
+ #region Fields and Consts
+
+ ///
+ /// the underline WinUI context menu
+ ///
+ private readonly MenuFlyout _contextMenu;
+
+ #endregion
+
+ ///
+ /// Init.
+ ///
+ public ContextMenuAdapter()
+ {
+ _contextMenu = new MenuFlyout();
+ }
+
+ public override int ItemsCount
+ {
+ get { return _contextMenu.Items.Count; }
+ }
+
+ public override void AddDivider()
+ {
+ _contextMenu.Items.Add(new MenuFlyoutSeparator());
+ }
+
+ public override void AddItem(string text, bool enabled, EventHandler onClick)
+ {
+ ArgChecker.AssertArgNotNullOrEmpty(text, "text");
+ ArgChecker.AssertArgNotNull(onClick, "onClick");
+
+ var item = new MenuFlyoutItem { Text = text, IsEnabled = enabled };
+ item.Click += (sender, args) => onClick(sender, EventArgs.Empty);
+ _contextMenu.Items.Add(item);
+ }
+
+ public override void RemoveLastDivider()
+ {
+ if (_contextMenu.Items.Count > 0 && _contextMenu.Items[_contextMenu.Items.Count - 1] is MenuFlyoutSeparator)
+ _contextMenu.Items.RemoveAt(_contextMenu.Items.Count - 1);
+ }
+
+ public override void Show(RControl parent, RPoint location)
+ {
+ var control = ((ControlAdapter)parent).Control;
+ _contextMenu.ShowAt(control, new FlyoutShowOptions
+ {
+ Position = new Windows.Foundation.Point(location.X, location.Y),
+ ShowMode = FlyoutShowMode.Standard,
+ });
+ }
+
+ public override void Dispose()
+ {
+ _contextMenu.Hide();
+ _contextMenu.Items.Clear();
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs
new file mode 100644
index 000000000..517c8f671
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs
@@ -0,0 +1,111 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.UI.Xaml.Controls;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for WinUI Control for core.
+ ///
+ ///
+ /// Unlike WPF's static queries, WinUI 3 has no queryable
+ /// global mouse-button state - caches the last-known pressed/position state
+ /// from its own PointerPressed/PointerReleased/PointerMoved handlers, and this
+ /// adapter (constructed fresh per call, mirroring HtmlRenderer.WPF's own ControlAdapter
+ /// usage pattern) just reads those cached fields back off it.
+ ///
+ internal sealed class ControlAdapter : RControl
+ {
+ ///
+ /// the underline WinUI control.
+ ///
+ private readonly Control _control;
+
+ ///
+ /// Init.
+ ///
+ public ControlAdapter(Control control)
+ : base(WinUIAdapter.Instance)
+ {
+ ArgChecker.AssertArgNotNull(control, "control");
+
+ _control = control;
+ }
+
+ ///
+ /// Get the underline WinUI control
+ ///
+ public Control Control
+ {
+ get { return _control; }
+ }
+
+ public override RPoint MouseLocation
+ {
+ get { return (_control as HtmlControl)?.LastPointerLocation ?? RPoint.Empty; }
+ }
+
+ public override bool LeftMouseButton
+ {
+ get { return (_control as HtmlControl)?.IsLeftButtonPressed ?? false; }
+ }
+
+ public override bool RightMouseButton
+ {
+ get { return (_control as HtmlControl)?.IsRightButtonPressed ?? false; }
+ }
+
+ public override void SetCursorDefault()
+ {
+ (_control as HtmlControl)?.SetCursorShape(HtmlControl.CursorShape.Default);
+ }
+
+ public override void SetCursorHand()
+ {
+ (_control as HtmlControl)?.SetCursorShape(HtmlControl.CursorShape.Hand);
+ }
+
+ public override void SetCursorIBeam()
+ {
+ (_control as HtmlControl)?.SetCursorShape(HtmlControl.CursorShape.IBeam);
+ }
+
+ public override void DoDragDropCopy(object dragDropData)
+ {
+ // WinUI 3 has no synchronous "start a drag now" API analogous to WPF's
+ // System.Windows.DragDrop.DoDragDrop - a drag operation there is always initiated by the
+ // framework via CanDrag/DragStarting on the source element in response to a user gesture, not
+ // called imperatively from application code mid-selection. Wiring that up is a real, separate
+ // piece of work (hooking DragStarting on the CanvasControl/UserControl and populating its
+ // DataPackage from dragDropData) that is out of scope for this foundation skeleton - see the
+ // implementation report's deviations section. Copy-to-clipboard (Ctrl+C) is unaffected, since
+ // it goes through RAdapter.SetToClipboard, not this method.
+ }
+
+ public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth)
+ {
+ using (var g = new GraphicsAdapter(WinUIAdapter.Instance.Device))
+ {
+ g.MeasureString(str, font, maxWidth, out charFit, out charFitWidth);
+ }
+ }
+
+ public override void Invalidate()
+ {
+ (_control as HtmlControl)?.InvalidateCanvas();
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs
new file mode 100644
index 000000000..609638968
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs
@@ -0,0 +1,157 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.Text;
+using Microsoft.UI.Text;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D font.
+ ///
+ ///
+ /// Win2D/DirectWrite exposes no low-level per-glyph metrics table the way WPF's
+ /// does (see the plan's Step 1 spike risk #2), so
+ /// / can't be read directly off the font the way
+ /// HtmlRenderer.WPF's own FontAdapter does (FontFamily.LineSpacing/
+ /// Typeface.UnderlinePosition). Instead they're derived by laying out a representative probe
+ /// string once at construction time and reading -
+ /// gives the line height directly, but there is no underline-
+ /// position metric at all on Win2D's public surface, so is a documented
+ /// heuristic (see below), not a measured value.
+ ///
+ internal sealed class FontAdapter : RFont
+ {
+ #region Fields and Consts
+
+ ///
+ /// A representative probe string used to derive line metrics - includes an ascender-tall and a
+ /// descender-tall glyph so the measured line height reflects a normal line of text, not just
+ /// x-height characters.
+ ///
+ private const string ProbeString = "Ág";
+
+ ///
+ /// the underline Win2D text format.
+ ///
+ private readonly CanvasTextFormat _textFormat;
+
+ ///
+ /// the size of the font, in points (unscaled) - matches the unit
+ /// receives it in, mirroring HtmlRenderer.WPF's own FontAdapter.Size.
+ ///
+ private readonly double _size;
+
+ ///
+ /// Cached font height, in device-independent pixels (Win2D/WinUI's coordinate unit, same as WPF's).
+ ///
+ private readonly double _height;
+
+ ///
+ /// the vertical offset of the font underline location from the top of the font.
+ ///
+ private readonly double _underlineOffset;
+
+ ///
+ /// Cached font whitespace width.
+ ///
+ private double _whitespaceWidth = -1;
+
+ #endregion
+
+ ///
+ /// Init.
+ ///
+ /// the device to build the probe layout with
+ /// the font family name (system-installed or an @font-face family registered with a live )
+ /// the font size, in points
+ /// the font style
+ public FontAdapter(CanvasDevice device, string fontFamily, double size, RFontStyle style)
+ {
+ _size = size;
+
+ _textFormat = new CanvasTextFormat
+ {
+ FontFamily = fontFamily,
+ // 96/72 converts points -> device-independent pixels, matching WPF's own FontAdapter,
+ // whose Height/UnderlineOffset bake in the same conversion factor and whose DrawString/
+ // MeasureString multiply the raw font size by it at every use.
+ FontSize = (float)(96d / 72d * size),
+ FontWeight = (style & RFontStyle.Bold) == RFontStyle.Bold ? FontWeights.Bold : FontWeights.Normal,
+ FontStyle = (style & RFontStyle.Italic) == RFontStyle.Italic ? Windows.UI.Text.FontStyle.Italic : Windows.UI.Text.FontStyle.Normal,
+ // The core engine performs its own line-breaking/word-wrap over runs it measures one at a
+ // time (see RGraphics.MeasureString) - every layout built from this format, for measuring
+ // or for drawing, is expected to be a single unwrapped line.
+ WordWrapping = CanvasWordWrapping.NoWrap,
+ };
+
+ using (var layout = new CanvasTextLayout(device, ProbeString, _textFormat, float.MaxValue, float.MaxValue))
+ {
+ var lineMetrics = layout.LineMetrics;
+ if (lineMetrics.Length > 0)
+ {
+ _height = lineMetrics[0].Height;
+ var baseline = lineMetrics[0].Baseline;
+ // No underline-position metric is exposed by Win2D's public surface - approximate it
+ // as a small step below the baseline, proportional to the line's descent region. This
+ // is a heuristic, not a measured value; see this class's own remarks.
+ _underlineOffset = baseline + (_height - baseline) * 0.15;
+ }
+ else
+ {
+ _height = layout.LayoutBounds.Height;
+ _underlineOffset = _height * 0.85;
+ }
+ }
+ }
+
+ ///
+ /// the underline Win2D text format.
+ ///
+ public CanvasTextFormat TextFormat
+ {
+ get { return _textFormat; }
+ }
+
+ public override double Size
+ {
+ get { return _size; }
+ }
+
+ public override double UnderlineOffset
+ {
+ get { return _underlineOffset; }
+ }
+
+ public override double Height
+ {
+ get { return _height; }
+ }
+
+ public override double LeftPadding
+ {
+ get { return _height / 6f; }
+ }
+
+ public override double GetWhitespaceWidth(RGraphics graphics)
+ {
+ if (_whitespaceWidth < 0)
+ {
+ _whitespaceWidth = graphics.MeasureString(" ", this).Width;
+ }
+ return _whitespaceWidth;
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs
new file mode 100644
index 000000000..797997a36
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs
@@ -0,0 +1,65 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Graphics.Canvas.Text;
+using TheArtOfDev.HtmlRenderer.Adapters;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D/DirectWrite font family object for core.
+ ///
+ ///
+ /// Unlike WPF's , which is itself the object that must
+ /// stay alive to keep a loaded font usable, Win2D/DirectWrite resolves a
+ /// string by name at layout-construction time. For an @font-face family that name only resolves
+ /// while the backing that registered it is still alive - so this adapter
+ /// holds a strong reference to that font set (null for ordinary system-installed families) purely to
+ /// keep it from being garbage collected for as long as this handle is
+ /// referenced (by 's own cache).
+ ///
+ internal sealed class FontFamilyAdapter : RFontFamily
+ {
+ ///
+ /// the family name, as resolved by DirectWrite at text-layout time.
+ ///
+ private readonly string _name;
+
+ ///
+ /// Strong reference to the backing font set for an @font-face family, keeping it alive for
+ /// as long as this handle is referenced. Null for ordinary system-installed families.
+ ///
+ private readonly CanvasFontSet _fontSet;
+
+ ///
+ /// Init.
+ ///
+ public FontFamilyAdapter(string name, CanvasFontSet fontSet = null)
+ {
+ _name = name;
+ _fontSet = fontSet;
+ }
+
+ ///
+ /// the backing font set for an @font-face family, or null for an ordinary system family.
+ ///
+ public CanvasFontSet FontSet
+ {
+ get { return _fontSet; }
+ }
+
+ public override string Name
+ {
+ get { return _name; }
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs
new file mode 100644
index 000000000..3565d96a4
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs
@@ -0,0 +1,354 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.Brushes;
+using Microsoft.Graphics.Canvas.Geometry;
+using Microsoft.Graphics.Canvas.Text;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using TheArtOfDev.HtmlRenderer.WinUI.Utilities;
+using Windows.Foundation;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D Graphics.
+ ///
+ internal sealed class GraphicsAdapter : RGraphics
+ {
+ #region Fields and Consts
+
+ ///
+ /// The wrapped Win2D drawing session - null for a measure-only instance (see the parameterless/
+ /// device-only constructors), matching HtmlRenderer.WPF's own GraphicsAdapter's
+ /// null-DrawingContext measure-only mode.
+ ///
+ private readonly CanvasDrawingSession _g;
+
+ ///
+ /// The device used to build measurement-only /
+ /// instances - Win2D's text/geometry APIs need a live device even when there's no drawing session
+ /// (unlike WPF's , which needs none).
+ ///
+ private readonly CanvasDevice _device;
+
+ ///
+ /// if to release the graphics object on dispose
+ ///
+ private readonly bool _releaseGraphics;
+
+ ///
+ /// The stack of active Win2D clip layers, one pushed per /
+ /// call - Win2D layers are and must be disposed in LIFO order, unlike
+ /// WPF's , which needs no such bookkeeping.
+ ///
+ private readonly Stack _layerStack = new Stack();
+
+ #endregion
+
+ ///
+ /// Init.
+ ///
+ /// the Win2D drawing session to use
+ /// the initial clip of the graphics
+ /// the device the session was opened on
+ /// optional: if to release the graphics object on dispose (default - false)
+ public GraphicsAdapter(CanvasDrawingSession g, RRect initialClip, CanvasDevice device, bool releaseGraphics = false)
+ : base(WinUIAdapter.Instance, initialClip)
+ {
+ ArgChecker.AssertArgNotNull(g, "g");
+ ArgChecker.AssertArgNotNull(device, "device");
+
+ _g = g;
+ _device = device;
+ _releaseGraphics = releaseGraphics;
+ }
+
+ ///
+ /// Init a measurement-only instance with no live drawing session.
+ ///
+ public GraphicsAdapter(CanvasDevice device)
+ : base(WinUIAdapter.Instance, RRect.Empty)
+ {
+ ArgChecker.AssertArgNotNull(device, "device");
+
+ _g = null;
+ _device = device;
+ _releaseGraphics = false;
+ }
+
+ ///
+ /// Init a measurement-only instance against the adapter's own shared device.
+ ///
+ public GraphicsAdapter()
+ : this(WinUIAdapter.Instance.Device)
+ {
+ }
+
+ public override void PopClip()
+ {
+ _layerStack.Pop().Dispose();
+ _clipStack.Pop();
+ }
+
+ public override void PushClip(RRect rect)
+ {
+ _clipStack.Push(rect);
+ _layerStack.Push(_g.CreateLayer(1f, Utils.Convert(rect)));
+ }
+
+ public override void PushClipExclude(RRect rect)
+ {
+ var full = CanvasGeometry.CreateRectangle(_device, Utils.Convert(_clipStack.Peek()));
+ var excluded = CanvasGeometry.CreateRectangle(_device, Utils.Convert(rect));
+ var combined = full.CombineWith(excluded, Matrix3x2.Identity, CanvasGeometryCombine.Exclude);
+
+ _clipStack.Push(_clipStack.Peek());
+ _layerStack.Push(_g.CreateLayer(1f, combined));
+ }
+
+ public override object SetAntiAliasSmoothingMode()
+ {
+ if (_g == null)
+ return null;
+
+ var prev = _g.Antialiasing;
+ _g.Antialiasing = CanvasAntialiasing.Antialiased;
+ return prev;
+ }
+
+ public override void ReturnPreviousSmoothingMode(object prevMode)
+ {
+ if (_g != null && prevMode is CanvasAntialiasing prev)
+ _g.Antialiasing = prev;
+ }
+
+ public override RSize MeasureString(string str, RFont font)
+ {
+ var fontAdapter = (FontAdapter)font;
+ using (var layout = new CanvasTextLayout(_device, str, fontAdapter.TextFormat, float.MaxValue, float.MaxValue))
+ {
+ var width = layout.LayoutBounds.Width;
+ if (width <= 0 && str.Length > 0)
+ {
+ // CanvasTextLayout.LayoutBounds is an ink-bounds rectangle that excludes trailing
+ // whitespace advance entirely - a string made up only of whitespace (notably the single
+ // space GetWhitespaceWidth measures, and cached forever once wrong) always comes back
+ // as zero-width. Recover the true advance width via an append-a-sentinel-glyph trick:
+ // lay out str+sentinel and sentinel alone: the difference is str's own advance width.
+ width = MeasureAdvanceWidthViaSentinel(str, fontAdapter);
+ }
+ return new RSize(width, font.Height);
+ }
+ }
+
+ private double MeasureAdvanceWidthViaSentinel(string str, FontAdapter fontAdapter)
+ {
+ const string sentinel = "|";
+ using (var combined = new CanvasTextLayout(_device, str + sentinel, fontAdapter.TextFormat, float.MaxValue, float.MaxValue))
+ using (var sentinelOnly = new CanvasTextLayout(_device, sentinel, fontAdapter.TextFormat, float.MaxValue, float.MaxValue))
+ {
+ var width = combined.LayoutBounds.Width - sentinelOnly.LayoutBounds.Width;
+ return width > 0 ? width : 0;
+ }
+ }
+
+ public override void MeasureString(string str, RFont font, double maxWidth, out int charFit, out double charFitWidth)
+ {
+ var fontAdapter = (FontAdapter)font;
+ using (var layout = new CanvasTextLayout(_device, str, fontAdapter.TextFormat, float.MaxValue, float.MaxValue))
+ {
+ if (maxWidth <= 0)
+ {
+ charFit = 0;
+ charFitWidth = 0;
+ return;
+ }
+
+ if (layout.LayoutBounds.Width <= maxWidth)
+ {
+ charFit = str.Length;
+ charFitWidth = layout.LayoutBounds.Width;
+ return;
+ }
+
+ bool isInside = layout.HitTest((float)maxWidth, 0f, out CanvasTextLayoutRegion region);
+ if (isInside)
+ {
+ charFit = region.CharacterIndex;
+ charFitWidth = region.LayoutBounds.X;
+ }
+ else
+ {
+ // Beyond the text's own extent but MeasureString(str,font) said it doesn't fit maxWidth -
+ // shouldn't normally happen, but fail safe to "whole string fits".
+ charFit = str.Length;
+ charFitWidth = layout.LayoutBounds.Width;
+ }
+ }
+ }
+
+ public override void DrawString(string str, RFont font, RColor color, RPoint point, RSize size, bool rtl)
+ {
+ var fontAdapter = (FontAdapter)font;
+ var format = fontAdapter.TextFormat;
+ CanvasTextFormat rtlFormat = null;
+
+ if (rtl)
+ {
+ rtlFormat = new CanvasTextFormat
+ {
+ FontFamily = format.FontFamily,
+ FontSize = format.FontSize,
+ FontWeight = format.FontWeight,
+ FontStyle = format.FontStyle,
+ WordWrapping = CanvasWordWrapping.NoWrap,
+ Direction = CanvasTextDirection.RightToLeftThenTopToBottom,
+ };
+ format = rtlFormat;
+ }
+
+ try
+ {
+ using (var layout = new CanvasTextLayout(_device, str, format, float.MaxValue, float.MaxValue))
+ {
+ var x = point.X;
+ if (rtl)
+ x += layout.LayoutBounds.Width;
+
+ _g.DrawTextLayout(layout, new Vector2((float)x, (float)point.Y), Utils.Convert(color));
+ }
+ }
+ finally
+ {
+ rtlFormat?.Dispose();
+ }
+ }
+
+ public override RBrush GetTextureBrush(RImage image, RRect dstRect, RPoint translateTransformLocation)
+ {
+ var brush = new CanvasImageBrush(_device)
+ {
+ Image = ((ImageAdapter)image).Bitmap,
+ ExtendX = CanvasEdgeBehavior.Wrap,
+ ExtendY = CanvasEdgeBehavior.Wrap,
+ SourceRectangle = Utils.Convert(dstRect),
+ Transform = Matrix3x2.CreateTranslation((float)translateTransformLocation.X, (float)translateTransformLocation.Y),
+ };
+ return new BrushAdapter(brush);
+ }
+
+ public override RGraphicsPath GetGraphicsPath()
+ {
+ return new GraphicsPathAdapter(_device);
+ }
+
+ public override void Dispose()
+ {
+ while (_layerStack.Count > 0)
+ _layerStack.Pop().Dispose();
+
+ if (_releaseGraphics)
+ _g.Dispose();
+ }
+
+ #region Delegate graphics methods
+
+ public override void DrawLine(RPen pen, double x1, double y1, double x2, double y2)
+ {
+ x1 = (int)x1;
+ x2 = (int)x2;
+ y1 = (int)y1;
+ y2 = (int)y2;
+
+ var adj = pen.Width;
+ if (Math.Abs(x1 - x2) < .1 && Math.Abs(adj % 2 - 1) < .1)
+ {
+ x1 += .5;
+ x2 += .5;
+ }
+ if (Math.Abs(y1 - y2) < .1 && Math.Abs(adj % 2 - 1) < .1)
+ {
+ y1 += .5;
+ y2 += .5;
+ }
+
+ var penAdapter = (PenAdapter)pen;
+ _g.DrawLine((float)x1, (float)y1, (float)x2, (float)y2, penAdapter.Brush, (float)pen.Width, penAdapter.StrokeStyle);
+ }
+
+ public override void DrawRectangle(RPen pen, double x, double y, double width, double height)
+ {
+ var adj = pen.Width;
+ if (Math.Abs(adj % 2 - 1) < .1)
+ {
+ x += .5;
+ y += .5;
+ }
+
+ var penAdapter = (PenAdapter)pen;
+ _g.DrawRectangle(new Rect(x, y, width, height), penAdapter.Brush, (float)pen.Width, penAdapter.StrokeStyle);
+ }
+
+ public override void DrawRectangle(RBrush brush, double x, double y, double width, double height)
+ {
+ _g.FillRectangle(new Rect(x, y, width, height), ((BrushAdapter)brush).Brush);
+ }
+
+ public override void DrawImage(RImage image, RRect destRect, RRect srcRect)
+ {
+ _g.DrawImage(((ImageAdapter)image).Bitmap, Utils.ConvertRound(destRect), Utils.Convert(srcRect));
+ }
+
+ public override void DrawImage(RImage image, RRect destRect)
+ {
+ _g.DrawImage(((ImageAdapter)image).Bitmap, Utils.ConvertRound(destRect));
+ }
+
+ public override void DrawPath(RPen pen, RGraphicsPath path)
+ {
+ var penAdapter = (PenAdapter)pen;
+ _g.DrawGeometry(((GraphicsPathAdapter)path).GetClosedGeometry(), penAdapter.Brush, (float)pen.Width, penAdapter.StrokeStyle);
+ }
+
+ public override void DrawPath(RBrush brush, RGraphicsPath path)
+ {
+ _g.FillGeometry(((GraphicsPathAdapter)path).GetClosedGeometry(), ((BrushAdapter)brush).Brush);
+ }
+
+ public override void DrawPolygon(RBrush brush, RPoint[] points)
+ {
+ if (points != null && points.Length > 0)
+ {
+ using (var pathBuilder = new CanvasPathBuilder(_device))
+ {
+ pathBuilder.BeginFigure((float)points[0].X, (float)points[0].Y);
+ for (int i = 1; i < points.Length; i++)
+ pathBuilder.AddLine((float)points[i].X, (float)points[i].Y);
+ pathBuilder.EndFigure(CanvasFigureLoop.Closed);
+
+ using (var geometry = CanvasGeometry.CreatePath(pathBuilder))
+ {
+ _g.FillGeometry(geometry, ((BrushAdapter)brush).Brush);
+ }
+ }
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/GraphicsPathAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/GraphicsPathAdapter.cs
new file mode 100644
index 000000000..50c44a09f
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/GraphicsPathAdapter.cs
@@ -0,0 +1,84 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System.Numerics;
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.Geometry;
+using TheArtOfDev.HtmlRenderer.Adapters;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D graphics path object for core.
+ ///
+ internal sealed class GraphicsPathAdapter : RGraphicsPath
+ {
+ ///
+ /// The Win2D path builder used to accumulate the figure.
+ ///
+ private readonly CanvasPathBuilder _pathBuilder;
+
+ ///
+ /// The closed geometry, built lazily on first call.
+ ///
+ private CanvasGeometry _geometry;
+
+ ///
+ /// whether has been called (a figure is open).
+ ///
+ private bool _figureOpen;
+
+ public GraphicsPathAdapter(ICanvasResourceCreator resourceCreator)
+ {
+ _pathBuilder = new CanvasPathBuilder(resourceCreator);
+ }
+
+ public override void Start(double x, double y)
+ {
+ _pathBuilder.BeginFigure((float)x, (float)y);
+ _figureOpen = true;
+ }
+
+ public override void LineTo(double x, double y)
+ {
+ _pathBuilder.AddLine((float)x, (float)y);
+ }
+
+ public override void ArcTo(double x, double y, double radiusX, double radiusY, Corner corner)
+ {
+ _pathBuilder.AddArc(new Vector2((float)x, (float)y), (float)radiusX, (float)radiusY, 0f, CanvasSweepDirection.Clockwise, CanvasArcSize.Small);
+ }
+
+ ///
+ /// Close the geometry so no more path adding is allowed and return the instance so it can be rendered.
+ ///
+ public CanvasGeometry GetClosedGeometry()
+ {
+ if (_geometry == null)
+ {
+ if (_figureOpen)
+ {
+ _pathBuilder.EndFigure(CanvasFigureLoop.Closed);
+ _figureOpen = false;
+ }
+ _geometry = CanvasGeometry.CreatePath(_pathBuilder);
+ }
+ return _geometry;
+ }
+
+ public override void Dispose()
+ {
+ _geometry?.Dispose();
+ _pathBuilder.Dispose();
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs
new file mode 100644
index 000000000..3d1e4b929
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs
@@ -0,0 +1,61 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Graphics.Canvas;
+using TheArtOfDev.HtmlRenderer.Adapters;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D image object for core.
+ ///
+ internal sealed class ImageAdapter : RImage
+ {
+ ///
+ /// the underline Win2D bitmap.
+ ///
+ private readonly CanvasBitmap _bitmap;
+
+ ///
+ /// Init.
+ ///
+ public ImageAdapter(CanvasBitmap bitmap)
+ {
+ _bitmap = bitmap;
+ }
+
+ ///
+ /// the underline Win2D bitmap.
+ ///
+ public CanvasBitmap Bitmap
+ {
+ get { return _bitmap; }
+ }
+
+ // Device pixels, matching WPF's own choice (PixelWidth/PixelHeight) rather than DIPs
+ // (CanvasBitmap.Size), so image layout matches native resolution the same way across both backends.
+ public override double Width
+ {
+ get { return _bitmap.SizeInPixels.Width; }
+ }
+
+ public override double Height
+ {
+ get { return _bitmap.SizeInPixels.Height; }
+ }
+
+ public override void Dispose()
+ {
+ _bitmap.Dispose();
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs
new file mode 100644
index 000000000..52f8ec7c4
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs
@@ -0,0 +1,106 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Graphics.Canvas.Brushes;
+using Microsoft.Graphics.Canvas.Geometry;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for Win2D pen (stroke) objects for core.
+ ///
+ ///
+ /// Win2D has no standalone "pen" object the way GDI+/WPF do - stroking is expressed as a
+ /// (brush, width, ) triple passed to each
+ /// CanvasDrawingSession.Draw* call. This adapter lazily materializes that triple, mirroring
+ /// HtmlRenderer.WPF's own PenAdapter's lazy CreatePen() pattern.
+ ///
+ internal sealed class PenAdapter : RPen
+ {
+ ///
+ /// The actual Win2D brush instance.
+ ///
+ private readonly ICanvasBrush _brush;
+
+ ///
+ /// the width of the pen
+ ///
+ private double _width;
+
+ ///
+ /// the dash/stroke style of the pen
+ ///
+ private CanvasStrokeStyle _strokeStyle = new CanvasStrokeStyle();
+
+ ///
+ /// Init.
+ ///
+ public PenAdapter(ICanvasBrush brush)
+ {
+ _brush = brush;
+ }
+
+ public override double Width
+ {
+ get { return _width; }
+ set { _width = value; }
+ }
+
+ public override RDashStyle DashStyle
+ {
+ set
+ {
+ var style = new CanvasStrokeStyle();
+ switch (value)
+ {
+ case RDashStyle.Solid:
+ style.DashStyle = CanvasDashStyle.Solid;
+ break;
+ case RDashStyle.Dash:
+ style.DashStyle = CanvasDashStyle.Dash;
+ break;
+ case RDashStyle.Dot:
+ style.DashStyle = CanvasDashStyle.Dot;
+ break;
+ case RDashStyle.DashDot:
+ style.DashStyle = CanvasDashStyle.DashDot;
+ break;
+ case RDashStyle.DashDotDot:
+ style.DashStyle = CanvasDashStyle.DashDotDot;
+ break;
+ default:
+ style.DashStyle = CanvasDashStyle.Solid;
+ break;
+ }
+ _strokeStyle = style;
+ }
+ }
+
+ ///
+ /// The actual Win2D brush instance to stroke with.
+ ///
+ public ICanvasBrush Brush
+ {
+ get { return _brush; }
+ }
+
+ ///
+ /// The stroke style (dash pattern) to stroke with.
+ ///
+ public CanvasStrokeStyle StrokeStyle
+ {
+ get { return _strokeStyle; }
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs b/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs
new file mode 100644
index 000000000..806ad3259
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs
@@ -0,0 +1,412 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.Brushes;
+using Microsoft.Graphics.Canvas.Text;
+using Microsoft.UI;
+using Microsoft.UI.Xaml.Controls;
+using TheArtOfDev.HtmlRenderer.Adapters;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Network;
+using TheArtOfDev.HtmlRenderer.WinUI.Utilities;
+using Windows.ApplicationModel.DataTransfer;
+using Windows.Storage.Pickers;
+using Windows.Storage.Streams;
+using Windows.UI.ViewManagement;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Adapters
+{
+ ///
+ /// Adapter for WinUI 3 / Win2D platform.
+ ///
+ internal sealed class WinUIAdapter : RAdapter
+ {
+ #region Fields and Consts
+
+ // One HttpClient shared for the adapter's (process) lifetime - see WpfAdapter's own field for why
+ // this must be declared before _instance (static field initializer ordering).
+ private static readonly HttpClient _sharedHttpClient = new HttpClient();
+
+ ///
+ /// Singleton instance of global adapter.
+ ///
+ private static readonly WinUIAdapter _instance = new WinUIAdapter();
+
+ ///
+ /// List of valid predefined color names in lower-case
+ ///
+ private static readonly List ValidColorNamesLc;
+
+ ///
+ /// The shared Win2D device this whole adapter (and every control it services) draws with -
+ /// created once via before any
+ /// exists, confirmed usable that way by the plan's Step 1 spike.
+ ///
+ private readonly CanvasDevice _device;
+
+ // Backs LoadFontFaceFontInt's temp-file registration - CanvasFontSet's documented in-memory story
+ // is a file Uri, mirroring WPF's own Fonts.GetFontFamilies(Uri) constraint (see that class's own
+ // remarks) - one directory per process, cleaned up by the OS's normal temp-file housekeeping.
+ private static readonly string _fontFaceTempDirectory = CreateFontFaceTempDirectory();
+
+ private static string CreateFontFaceTempDirectory()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), "HtmlRenderer.FontFace." + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ #endregion
+
+ static WinUIAdapter()
+ {
+ ValidColorNamesLc = new List();
+ foreach (var colorProp in typeof(Colors).GetProperties(BindingFlags.Public | BindingFlags.Static))
+ {
+ ValidColorNamesLc.Add(colorProp.Name.ToLower());
+ }
+ }
+
+ ///
+ /// Init installed font families and set default font families mapping.
+ ///
+ private WinUIAdapter()
+ {
+ _device = CanvasDevice.GetSharedDevice();
+
+ // Interactive UI backend - fetching a real http(s): image or stylesheet out of the box is the
+ // expected behavior, same reasoning as WpfAdapter's own constructor.
+ NetworkLoader = new HttpClientNetworkLoader(_sharedHttpClient, (Uri)null);
+
+ AddFontFamilyMapping("monospace", "Courier New");
+ AddFontFamilyMapping("Helvetica", "Arial");
+
+ try
+ {
+ var systemFonts = CanvasFontSet.GetSystemFontSet();
+ var addedFamilies = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var font in systemFonts.Fonts)
+ {
+ var name = GetPreferredFamilyName(font.FamilyNames);
+ if (name != null && addedFamilies.Add(name))
+ {
+ try
+ {
+ AddFontFamily(new FontFamilyAdapter(name));
+ }
+ catch
+ {
+ }
+ }
+ }
+ }
+ catch
+ {
+ // System font enumeration is a nice-to-have (helps IsFontExists recognize installed
+ // families upfront); its failure shouldn't prevent the adapter itself from initializing.
+ }
+
+ var uiSettings = new UISettings();
+ uiSettings.ColorValuesChanged += (sender, e) =>
+ {
+ var previous = _colorScheme;
+ _colorScheme = null;
+ if (previous.HasValue && previous.Value != SystemColorScheme)
+ OnColorSchemeChanged();
+ };
+ }
+
+ ///
+ /// Singleton instance of global adapter.
+ ///
+ public static WinUIAdapter Instance
+ {
+ get { return _instance; }
+ }
+
+ ///
+ /// The shared Win2D device this adapter and every control it services draws with.
+ ///
+ public CanvasDevice Device
+ {
+ get { return _device; }
+ }
+
+ ///
+ /// Rendering onto a Windows surface, so the document should follow the user's app theme.
+ /// Cached and invalidated on a system preference change rather than read per query.
+ ///
+ public override RColorScheme SystemColorScheme
+ {
+ get
+ {
+ if (SystemColorSchemeOverride.HasValue)
+ return SystemColorSchemeOverride.Value;
+ if (!_colorScheme.HasValue)
+ _colorScheme = WindowsTheme.GetAppsColorScheme();
+ return _colorScheme.Value;
+ }
+ }
+
+ ///
+ /// Cached app theme; null when it needs to be re-read.
+ ///
+ private RColorScheme? _colorScheme;
+
+ protected override RColor GetColorInt(string colorName)
+ {
+ if (!ValidColorNamesLc.Contains(colorName.ToLower()))
+ return RColor.Empty;
+
+ var prop = typeof(Colors).GetProperty(colorName, BindingFlags.Public | BindingFlags.Static | BindingFlags.IgnoreCase);
+ if (prop == null)
+ return RColor.Empty;
+
+ var color = (Windows.UI.Color)prop.GetValue(null);
+ return Utilities.Utils.Convert(color);
+ }
+
+ protected override RPen CreatePen(RColor color)
+ {
+ return new PenAdapter(GetSolidColorBrush(color));
+ }
+
+ protected override RBrush CreateSolidBrush(RColor color)
+ {
+ return new BrushAdapter(GetSolidColorBrush(color));
+ }
+
+ protected override RBrush CreateLinearGradientBrush(RPoint p1, RPoint p2, (RColor Color, double Position)[] stops)
+ {
+ var gradientStops = new CanvasGradientStop[stops.Length];
+ for (int i = 0; i < stops.Length; i++)
+ {
+ gradientStops[i] = new CanvasGradientStop
+ {
+ Color = Utilities.Utils.Convert(stops[i].Color),
+ Position = (float)stops[i].Position,
+ };
+ }
+
+ var brush = new CanvasLinearGradientBrush(_device, gradientStops);
+ brush.StartPoint = new System.Numerics.Vector2((float)p1.X, (float)p1.Y);
+ brush.EndPoint = new System.Numerics.Vector2((float)p2.X, (float)p2.Y);
+ return new BrushAdapter(brush);
+ }
+
+ protected override RImage ConvertImageInt(object image)
+ {
+ return image is CanvasBitmap bitmap ? new ImageAdapter(bitmap) : null;
+ }
+
+ protected override RImage ImageFromStreamInt(Stream memoryStream)
+ {
+ var randomAccessStream = ToRandomAccessStreamAsync(memoryStream).GetAwaiter().GetResult();
+ var bitmap = CanvasBitmap.LoadAsync(_device, randomAccessStream).AsTask().GetAwaiter().GetResult();
+ return new ImageAdapter(bitmap);
+ }
+
+ protected override RFont CreateFontInt(string family, double size, RFontStyle style)
+ {
+ return new FontAdapter(_device, family, size, style);
+ }
+
+ protected override RFont CreateFontInt(RFontFamily family, double size, RFontStyle style)
+ {
+ return new FontAdapter(_device, ((FontFamilyAdapter)family).Name, size, style);
+ }
+
+ ///
+ /// Loads one @font-face face's bytes as a Win2D via a temp file,
+ /// mirroring HtmlRenderer.WPF's own WpfAdapter.LoadFontFaceFontInt (same reasoning:
+ /// there is no supported in-memory-only load path). Confirmed by the plan's Step 1 spike:
+ /// 's constructor takes a single , not an array.
+ ///
+ protected override RFontFamily LoadFontFaceFontInt(byte[] fontBytes, string filePath)
+ {
+ var faceDirectory = Path.Combine(_fontFaceTempDirectory, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(faceDirectory);
+ var tempFilePath = Path.Combine(faceDirectory, "face.ttf");
+ File.WriteAllBytes(tempFilePath, fontBytes);
+
+ var fontSet = new CanvasFontSet(new Uri(tempFilePath));
+ if (fontSet.Fonts.Count == 0)
+ return null;
+
+ var name = GetPreferredFamilyName(fontSet.Fonts[0].FamilyNames);
+ return name != null ? new FontFamilyAdapter(name, fontSet) : null;
+ }
+
+ protected override object GetClipboardDataObjectInt(string html, string plainText)
+ {
+ return ClipboardHelper.CreateDataObject(html, plainText);
+ }
+
+ protected override void SetToClipboardInt(string text)
+ {
+ ClipboardHelper.CopyToClipboard(text);
+ }
+
+ protected override void SetToClipboardInt(string html, string plainText)
+ {
+ ClipboardHelper.CopyToClipboard(html, plainText);
+ }
+
+ protected override void SetToClipboardInt(RImage image)
+ {
+ var bitmap = ((ImageAdapter)image).Bitmap;
+ var stream = new InMemoryRandomAccessStream();
+ bitmap.SaveAsync(stream, CanvasBitmapFileFormat.Png).AsTask().GetAwaiter().GetResult();
+
+ var dataPackage = new DataPackage();
+ dataPackage.SetBitmap(RandomAccessStreamReference.CreateFromStream(stream));
+ Clipboard.SetContent(dataPackage);
+ }
+
+ protected override RContextMenu CreateContextMenuInt()
+ {
+ return new ContextMenuAdapter();
+ }
+
+ ///
+ /// Show a save-file dialog and encode the image to the chosen path.
+ ///
+ ///
+ /// An unpackaged WinUI 3 app's needs an owner HWND handed to it via
+ /// 's runtime counterpart - resolved here
+ /// from the passed-in control's XamlRoot via ,
+ /// since this adapter (a process-wide singleton) has no Window reference of its own to fall
+ /// back on. If no control is given, or its window can't be resolved, the save is silently skipped -
+ /// this is a real, narrow gap versus WPF's parameterless SaveFileDialog.ShowDialog(); see the
+ /// implementation report.
+ ///
+ protected override void SaveToFileInt(RImage image, string name, string extension, RControl control = null)
+ {
+ var element = (control as ControlAdapter)?.Control;
+ if (element?.XamlRoot == null)
+ return;
+
+ var hwnd = Win32Interop.GetWindowFromWindowId(element.XamlRoot.ContentIslandEnvironment.AppWindowId);
+ if (hwnd == IntPtr.Zero)
+ return;
+
+ var picker = new FileSavePicker
+ {
+ SuggestedFileName = name,
+ DefaultFileExtension = extension,
+ };
+ picker.FileTypeChoices.Add("Image", new List { ".png", ".bmp", ".jpg", ".tif", ".gif" });
+ WinRT.Interop.InitializeWithWindow.Initialize(picker, hwnd);
+
+ var file = picker.PickSaveFileAsync().AsTask().GetAwaiter().GetResult();
+ if (file == null)
+ return;
+
+ var format = GetBitmapFileFormat(Path.GetExtension(file.Path));
+ var bitmap = ((ImageAdapter)image).Bitmap;
+ using (var stream = file.OpenAsync(Windows.Storage.FileAccessMode.ReadWrite).AsTask().GetAwaiter().GetResult())
+ {
+ bitmap.SaveAsync(stream, format).AsTask().GetAwaiter().GetResult();
+ }
+ }
+
+ #region Private/Protected methods
+
+ ///
+ /// Get solid color brush for the given color.
+ ///
+ private CanvasSolidColorBrush GetSolidColorBrush(RColor color)
+ {
+ return new CanvasSolidColorBrush(_device, Utilities.Utils.Convert(color));
+ }
+
+ ///
+ /// Pick a family name from a font's family-names table, preferring the "en-us" entry (matching
+ /// WPF's own XmlLanguage-keyed preference in its FontFamilyAdapter) and falling back
+ /// to whatever entry exists first.
+ ///
+ private static string GetPreferredFamilyName(IReadOnlyDictionary familyNames)
+ {
+ if (familyNames == null || familyNames.Count == 0)
+ return null;
+
+ if (familyNames.TryGetValue("en-us", out var name))
+ return name;
+
+ foreach (var kvp in familyNames)
+ return kvp.Value;
+
+ return null;
+ }
+
+ ///
+ /// Get the Win2D bitmap encoding format to use for the given file extension. Default is PNG.
+ ///
+ private static CanvasBitmapFileFormat GetBitmapFileFormat(string ext)
+ {
+ switch (ext.ToLower())
+ {
+ case ".jpg":
+ case ".jpeg":
+ return CanvasBitmapFileFormat.Jpeg;
+ case ".bmp":
+ return CanvasBitmapFileFormat.Bmp;
+ case ".tif":
+ case ".tiff":
+ return CanvasBitmapFileFormat.Tiff;
+ case ".gif":
+ return CanvasBitmapFileFormat.Gif;
+ default:
+ return CanvasBitmapFileFormat.Png;
+ }
+ }
+
+ ///
+ /// Copy a .NET into an in-memory WinRT -
+ /// the shape Win2D's
+ /// needs, since 's seam only ever gives this a plain
+ /// .NET stream.
+ ///
+ private static async Task ToRandomAccessStreamAsync(Stream stream)
+ {
+ byte[] bytes;
+ using (var memory = new MemoryStream())
+ {
+ await stream.CopyToAsync(memory).ConfigureAwait(false);
+ bytes = memory.ToArray();
+ }
+
+ var randomAccessStream = new InMemoryRandomAccessStream();
+ using (var writer = new DataWriter(randomAccessStream.GetOutputStreamAt(0)))
+ {
+ writer.WriteBytes(bytes);
+ await writer.StoreAsync();
+ await writer.FlushAsync();
+ writer.DetachStream();
+ }
+
+ randomAccessStream.Seek(0);
+ return randomAccessStream;
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlContainer.cs b/Source/HtmlRenderer.WinUI/HtmlContainer.cs
new file mode 100644
index 000000000..29e90c6eb
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlContainer.cs
@@ -0,0 +1,424 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using TheArtOfDev.HtmlRenderer.WinUI.Adapters;
+using TheArtOfDev.HtmlRenderer.WinUI.Utilities;
+using Windows.Foundation;
+using Windows.System;
+using Windows.UI.Core;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI
+{
+ ///
+ /// Low level handling of Html Renderer logic, this class is used by ,
+ /// , and .
+ ///
+ ///
+ public sealed class HtmlContainer : IDisposable
+ {
+ #region Fields and Consts
+
+ ///
+ /// The internal core html container
+ ///
+ private readonly HtmlContainerInt _htmlContainerInt;
+
+ #endregion
+
+ ///
+ /// Init.
+ ///
+ public HtmlContainer()
+ {
+ _htmlContainerInt = new HtmlContainerInt(WinUIAdapter.Instance);
+ _htmlContainerInt.PageSize = new RSize(99999, 99999);
+ }
+
+ ///
+ /// Raised when the set html document has been fully loaded.
+ /// Allows manipulation of the html dom, scroll position, etc.
+ ///
+ public event EventHandler LoadComplete
+ {
+ add { _htmlContainerInt.LoadComplete += value; }
+ remove { _htmlContainerInt.LoadComplete -= value; }
+ }
+
+ ///
+ /// Raised when the user clicks on a link in the html.
+ /// Allows canceling the execution of the link.
+ ///
+ public event EventHandler LinkClicked
+ {
+ add { _htmlContainerInt.LinkClicked += value; }
+ remove { _htmlContainerInt.LinkClicked -= value; }
+ }
+
+ ///
+ /// Raised when html renderer requires refresh of the control hosting (invalidation and re-layout).
+ ///
+ ///
+ /// There is no guarantee that the event will be raised on the main thread, it can be raised on thread-pool thread.
+ ///
+ public event EventHandler Refresh
+ {
+ add { _htmlContainerInt.Refresh += value; }
+ remove { _htmlContainerInt.Refresh -= value; }
+ }
+
+ ///
+ /// Raised when Html Renderer request scroll to specific location.
+ /// This can occur on document anchor click.
+ ///
+ public event EventHandler ScrollChange
+ {
+ add { _htmlContainerInt.ScrollChange += value; }
+ remove { _htmlContainerInt.ScrollChange -= value; }
+ }
+
+ ///
+ /// Raised when an error occurred during html rendering.
+ ///
+ ///
+ /// There is no guarantee that the event will be raised on the main thread, it can be raised on thread-pool thread.
+ ///
+ public event EventHandler RenderError
+ {
+ add { _htmlContainerInt.RenderError += value; }
+ remove { _htmlContainerInt.RenderError -= value; }
+ }
+
+ ///
+ /// Raised when a stylesheet is about to be loaded by file path or URI by link element.
+ /// This event allows to provide the stylesheet manually or provide new source (file or Uri) to load from.
+ /// If no alternative data is provided the original source will be used.
+ ///
+ public event EventHandler StylesheetLoad
+ {
+ add { _htmlContainerInt.StylesheetLoad += value; }
+ remove { _htmlContainerInt.StylesheetLoad -= value; }
+ }
+
+ ///
+ /// Raised when an image is about to be loaded by file path or URI.
+ /// This event allows to provide the image manually, if not handled the image will be loaded from file or download from URI.
+ ///
+ public event EventHandler ImageLoad
+ {
+ add { _htmlContainerInt.ImageLoad += value; }
+ remove { _htmlContainerInt.ImageLoad -= value; }
+ }
+
+ ///
+ /// The internal core html container
+ ///
+ internal HtmlContainerInt HtmlContainerInt
+ {
+ get { return _htmlContainerInt; }
+ }
+
+ ///
+ /// the parsed stylesheet data used for handling the html
+ ///
+ public CssData CssData
+ {
+ get { return _htmlContainerInt.CssData; }
+ }
+
+ ///
+ /// Gets or sets a value indicating if image asynchronous loading should be avoided (default - false).
+ ///
+ public bool AvoidAsyncImagesLoading
+ {
+ get { return _htmlContainerInt.AvoidAsyncImagesLoading; }
+ set { _htmlContainerInt.AvoidAsyncImagesLoading = value; }
+ }
+
+ ///
+ /// Gets or sets a value indicating if image loading only when visible should be avoided (default - false).
+ ///
+ public bool AvoidImagesLateLoading
+ {
+ get { return _htmlContainerInt.AvoidImagesLateLoading; }
+ set { _htmlContainerInt.AvoidImagesLateLoading = value; }
+ }
+
+ ///
+ /// Is content selection is enabled for the rendered html (default - true).
+ ///
+ public bool IsSelectionEnabled
+ {
+ get { return _htmlContainerInt.IsSelectionEnabled; }
+ set { _htmlContainerInt.IsSelectionEnabled = value; }
+ }
+
+ ///
+ /// Is the build-in context menu enabled and will be shown on mouse right click (default - true)
+ ///
+ public bool IsContextMenuEnabled
+ {
+ get { return _htmlContainerInt.IsContextMenuEnabled; }
+ set { _htmlContainerInt.IsContextMenuEnabled = value; }
+ }
+
+ ///
+ /// The scroll offset of the html.
+ ///
+ public Point ScrollOffset
+ {
+ get { return Utils.Convert(_htmlContainerInt.ScrollOffset); }
+ set { _htmlContainerInt.ScrollOffset = Utils.Convert(value); }
+ }
+
+ ///
+ /// The top-left most location of the rendered html.
+ ///
+ public Point Location
+ {
+ get { return Utils.Convert(_htmlContainerInt.Location); }
+ set { _htmlContainerInt.Location = Utils.Convert(value); }
+ }
+
+ ///
+ /// The max width and height of the rendered html.
+ ///
+ public Size MaxSize
+ {
+ get { return Utils.Convert(_htmlContainerInt.MaxSize); }
+ set { _htmlContainerInt.MaxSize = Utils.Convert(value); }
+ }
+
+ ///
+ /// The actual size of the rendered html (after layout)
+ ///
+ public Size ActualSize
+ {
+ get { return Utils.Convert(_htmlContainerInt.ActualSize); }
+ internal set { _htmlContainerInt.ActualSize = Utils.Convert(value); }
+ }
+
+ ///
+ /// Get the currently selected text segment in the html.
+ ///
+ public string SelectedText
+ {
+ get { return _htmlContainerInt.SelectedText; }
+ }
+
+ ///
+ /// Copy the currently selected html segment with style.
+ ///
+ public string SelectedHtml
+ {
+ get { return _htmlContainerInt.SelectedHtml; }
+ }
+
+ ///
+ /// Clear the current selection.
+ ///
+ public void ClearSelection()
+ {
+ HtmlContainerInt.ClearSelection();
+ }
+
+ ///
+ /// Init with optional document and stylesheet.
+ ///
+ public Task SetHtml(string htmlSource, CssData baseCssData = null)
+ {
+ return _htmlContainerInt.SetHtml(htmlSource, baseCssData);
+ }
+
+ ///
+ /// Clear the content of the HTML container releasing any resources used to render previously existing content.
+ ///
+ public void Clear()
+ {
+ _htmlContainerInt.Clear();
+ }
+
+ ///
+ /// Get html from the current DOM tree with style if requested.
+ ///
+ public string GetHtml(HtmlGenerationStyle styleGen = HtmlGenerationStyle.Inline)
+ {
+ return _htmlContainerInt.GetHtml(styleGen);
+ }
+
+ ///
+ /// Get attribute value of element at the given x,y location by given key.
+ ///
+ public string GetAttributeAt(Point location, string attribute)
+ {
+ return _htmlContainerInt.GetAttributeAt(Utils.Convert(location), attribute);
+ }
+
+ ///
+ /// Get all the links in the HTML with the element Rect and href data.
+ ///
+ public List> GetLinks()
+ {
+ var linkElements = new List>();
+ foreach (var link in HtmlContainerInt.GetLinks())
+ {
+ linkElements.Add(new LinkElementData(link.Id, link.Href, Utils.Convert(link.Rectangle)));
+ }
+ return linkElements;
+ }
+
+ ///
+ /// Get css link href at the given x,y location.
+ ///
+ public string GetLinkAt(Point location)
+ {
+ return _htmlContainerInt.GetLinkAt(Utils.Convert(location));
+ }
+
+ ///
+ /// Get the Rect of html element as calculated by html layout.
+ ///
+ public Rect? GetElementRectangle(string elementId)
+ {
+ var r = _htmlContainerInt.GetElementRectangle(elementId);
+ return r.HasValue ? Utils.Convert(r.Value) : (Rect?)null;
+ }
+
+ ///
+ /// Measures the bounds of box and children, recursively.
+ ///
+ public void PerformLayout()
+ {
+ using (var ig = new GraphicsAdapter())
+ {
+ _htmlContainerInt.PerformLayout(ig);
+ }
+ }
+
+ ///
+ /// Render the html using the given device.
+ ///
+ /// the device to use to render
+ /// the clip rectangle of the html container
+ public void PerformPaint(CanvasDrawingSession g, Rect clip)
+ {
+ ArgChecker.AssertArgNotNull(g, "g");
+
+ using (var ig = new GraphicsAdapter(g, Utils.Convert(clip), g.Device))
+ {
+ _htmlContainerInt.PerformPaint(ig);
+ }
+ }
+
+ ///
+ /// Handle mouse down to handle selection.
+ ///
+ public void HandleMouseDown(Control parent, PointerRoutedEventArgs e)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+ ArgChecker.AssertArgNotNull(e, "e");
+
+ var point = e.GetCurrentPoint(parent);
+ _htmlContainerInt.HandleMouseDown(new ControlAdapter(parent), Utils.Convert(point.Position));
+ }
+
+ ///
+ /// Handle mouse up to handle selection and link click.
+ ///
+ public void HandleMouseUp(Control parent, PointerRoutedEventArgs e)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+ ArgChecker.AssertArgNotNull(e, "e");
+
+ var point = e.GetCurrentPoint(parent);
+ var isLeft = point.Properties.PointerUpdateKind == Microsoft.UI.Input.PointerUpdateKind.LeftButtonReleased
+ || point.Properties.PointerUpdateKind == Microsoft.UI.Input.PointerUpdateKind.Other;
+ var mouseEvent = new RMouseEvent(isLeft);
+ _htmlContainerInt.HandleMouseUp(new ControlAdapter(parent), Utils.Convert(point.Position), mouseEvent);
+ }
+
+ ///
+ /// Handle mouse double click to select word under the mouse.
+ ///
+ public void HandleMouseDoubleClick(Control parent, PointerRoutedEventArgs e)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+ ArgChecker.AssertArgNotNull(e, "e");
+
+ var point = e.GetCurrentPoint(parent);
+ _htmlContainerInt.HandleMouseDoubleClick(new ControlAdapter(parent), Utils.Convert(point.Position));
+ }
+
+ ///
+ /// Handle mouse move to handle hover cursor and text selection.
+ ///
+ public void HandleMouseMove(Control parent, Point mousePos)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+
+ _htmlContainerInt.HandleMouseMove(new ControlAdapter(parent), Utils.Convert(mousePos));
+ }
+
+ ///
+ /// Handle mouse leave to handle hover cursor.
+ ///
+ public void HandleMouseLeave(Control parent)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+
+ _htmlContainerInt.HandleMouseLeave(new ControlAdapter(parent));
+ }
+
+ ///
+ /// Handle key down event for selection and copy.
+ ///
+ public void HandleKeyDown(Control parent, KeyRoutedEventArgs e)
+ {
+ ArgChecker.AssertArgNotNull(parent, "parent");
+ ArgChecker.AssertArgNotNull(e, "e");
+
+ _htmlContainerInt.HandleKeyDown(new ControlAdapter(parent), CreateKeyEvent(e));
+ }
+
+ ///
+ /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
+ ///
+ public void Dispose()
+ {
+ _htmlContainerInt.Dispose();
+ }
+
+ #region Private methods
+
+ ///
+ /// Create HtmlRenderer key event from WinUI key event.
+ ///
+ private static RKeyEvent CreateKeyEvent(KeyRoutedEventArgs e)
+ {
+ var controlState = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control);
+ var control = (controlState & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down;
+ return new RKeyEvent(control, e.Key == VirtualKey.A, e.Key == VirtualKey.C);
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlControl.cs b/Source/HtmlRenderer.WinUI/HtmlControl.cs
new file mode 100644
index 000000000..3f596537a
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlControl.cs
@@ -0,0 +1,668 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.UI.Xaml;
+using Microsoft.UI.Dispatching;
+using Microsoft.UI.Input;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Entities;
+using TheArtOfDev.HtmlRenderer.WinUI.Utilities;
+using Windows.Foundation;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI
+{
+ ///
+ /// Provides HTML rendering using the text property.
+ /// WinUI 3 control that will render html content in it's client rectangle.
+ /// The control will handle pointer and keyboard events on it to support html text selection,
+ /// copy-paste and mouse clicks.
+ ///
+ /// The major differential to use HtmlPanel or HtmlLabel is size and scrollbars.
+ /// If the size of the control depends on the html content the HtmlLabel should be used.
+ /// If the size is set by some kind of layout then HtmlPanel is more suitable, also shows scrollbars if the html contents is larger than the control client rectangle.
+ ///
+ ///
+ ///
+ /// WinUI 3's has no OnRender(DrawingContext)
+ /// override point the way WPF's does, so a composed child replaces
+ /// inheritance-based rendering - see . WinUI 3 also has no
+ /// EventManager.RegisterRoutedEvent equivalent for user-defined bubbling events, so unlike
+ /// HtmlRenderer.WPF's HtmlControl, whose events are custom s
+ /// wrapped in a generic RoutedEventArgs<T>, this control's events are plain
+ /// (that wrapper type is dropped entirely, not ported).
+ ///
+ public class HtmlControl : UserControl
+ {
+ ///
+ /// The cursor shapes this control can be asked to show over the rendered html.
+ ///
+ internal enum CursorShape
+ {
+ Default,
+ Hand,
+ IBeam,
+ }
+
+ #region Fields and Consts
+
+ ///
+ /// How close together (in milliseconds) two pointer presses must land to be treated as a double-click.
+ ///
+ private const double DoubleClickThresholdMs = 500;
+
+ ///
+ /// How close together (in DIPs) two pointer presses must land to be treated as a double-click.
+ ///
+ private const double DoubleClickThresholdPixels = 4;
+
+ ///
+ /// Underline html container instance.
+ ///
+ protected readonly HtmlContainer _htmlContainer;
+
+ ///
+ /// The composed canvas child that all drawing happens on.
+ ///
+ protected readonly CanvasControl _canvas;
+
+ ///
+ /// The root panel hosting (and, for , its scrollbars).
+ ///
+ ///
+ /// WPF's own HtmlPanel adds its scrollbars as raw visual children via
+ /// FrameworkElement.AddVisualChild/GetVisualChild, a WPF-only low-level visual-tree
+ /// API with no WinUI 3 equivalent - has no public/protected
+ /// extensibility point for adding a child that isn't part of an actual XAML content tree. A real
+ /// panel is used here instead: still a mechanical, idiomatic-WinUI substitute for
+ /// the same "extra chrome children alongside the canvas" need, not a behavioral rewrite.
+ ///
+ protected readonly Grid _rootPanel;
+
+ ///
+ /// the base stylesheet data used in the control
+ ///
+ protected CssData _baseCssData;
+
+ ///
+ /// The last position of the scrollbars to know if it has changed to update mouse
+ ///
+ protected Point _lastScrollOffset;
+
+ ///
+ /// The dispatcher queue captured at construction time, used to marshal
+ /// events (which can fire on a thread-pool thread - see 's async
+ /// operations) back onto the UI thread, mirroring WPF's CheckAccess()/Dispatcher.Invoke
+ /// double-dispatch pattern.
+ ///
+ private readonly DispatcherQueue _dispatcherQueue;
+
+ ///
+ /// Tracks the in-flight call, if any, so a newer call can supersede an
+ /// older one still awaiting .
+ ///
+ private CancellationTokenSource _pendingLoad;
+
+ private RPoint _lastPointerLocation;
+ private bool _isLeftButtonPressed;
+ private bool _isRightButtonPressed;
+
+ private DateTime _lastPointerPressedTime = DateTime.MinValue;
+ private Point _lastPointerPressedPosition;
+
+ #endregion
+
+ #region Dependency properties
+
+ public static readonly DependencyProperty AvoidImagesLateLoadingProperty = DependencyProperty.Register(nameof(AvoidImagesLateLoading), typeof(bool), typeof(HtmlControl), new PropertyMetadata(false, OnDependencyProperty_valueChanged));
+ public static readonly DependencyProperty IsSelectionEnabledProperty = DependencyProperty.Register(nameof(IsSelectionEnabled), typeof(bool), typeof(HtmlControl), new PropertyMetadata(true, OnDependencyProperty_valueChanged));
+ public static readonly DependencyProperty IsContextMenuEnabledProperty = DependencyProperty.Register(nameof(IsContextMenuEnabled), typeof(bool), typeof(HtmlControl), new PropertyMetadata(true, OnDependencyProperty_valueChanged));
+ public static readonly DependencyProperty BaseStylesheetProperty = DependencyProperty.Register(nameof(BaseStylesheet), typeof(string), typeof(HtmlControl), new PropertyMetadata(null, OnDependencyProperty_valueChanged));
+ public static readonly DependencyProperty TextProperty = DependencyProperty.Register(nameof(Text), typeof(string), typeof(HtmlControl), new PropertyMetadata(null, OnDependencyProperty_valueChanged));
+
+ #endregion
+
+ ///
+ /// Creates a new HtmlControl and sets a basic css for it's styling.
+ ///
+ protected HtmlControl()
+ {
+ _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
+
+ _rootPanel = new Grid();
+ _canvas = new CanvasControl
+ {
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ };
+ _canvas.Draw += OnCanvasDraw;
+ _rootPanel.Children.Add(_canvas);
+ Content = _rootPanel;
+
+ _htmlContainer = new HtmlContainer();
+ _htmlContainer.LoadComplete += OnLoadCompleteInternal;
+ _htmlContainer.LinkClicked += OnLinkClickedInternal;
+ _htmlContainer.RenderError += OnRenderErrorInternal;
+ _htmlContainer.Refresh += OnRefreshInternal;
+ _htmlContainer.StylesheetLoad += OnStylesheetLoadInternal;
+ _htmlContainer.ImageLoad += OnImageLoadInternal;
+ }
+
+ ///
+ /// Raised when the set html document has been fully loaded.
+ /// Allows manipulation of the html dom, scroll position, etc.
+ ///
+ public event EventHandler LoadComplete;
+
+ ///
+ /// Raised when the user clicks on a link in the html.
+ /// Allows canceling the execution of the link.
+ ///
+ public event EventHandler LinkClicked;
+
+ ///
+ /// Raised when an error occurred during html rendering.
+ ///
+ public event EventHandler RenderError;
+
+ ///
+ /// Raised when a stylesheet is about to be loaded by file path or URI by link element.
+ ///
+ public event EventHandler StylesheetLoad;
+
+ ///
+ /// Raised when an image is about to be loaded by file path or URI.
+ ///
+ public event EventHandler ImageLoad;
+
+ ///
+ /// Gets or sets a value indicating if image loading only when visible should be avoided (default - false).
+ ///
+ public bool AvoidImagesLateLoading
+ {
+ get { return (bool)GetValue(AvoidImagesLateLoadingProperty); }
+ set { SetValue(AvoidImagesLateLoadingProperty, value); }
+ }
+
+ ///
+ /// Is content selection is enabled for the rendered html (default - true).
+ ///
+ public bool IsSelectionEnabled
+ {
+ get { return (bool)GetValue(IsSelectionEnabledProperty); }
+ set { SetValue(IsSelectionEnabledProperty, value); }
+ }
+
+ ///
+ /// Is the build-in context menu enabled and will be shown on mouse right click (default - true)
+ ///
+ public bool IsContextMenuEnabled
+ {
+ get { return (bool)GetValue(IsContextMenuEnabledProperty); }
+ set { SetValue(IsContextMenuEnabledProperty, value); }
+ }
+
+ ///
+ /// Set base stylesheet to be used by html rendered in the panel.
+ ///
+ public string BaseStylesheet
+ {
+ get { return (string)GetValue(BaseStylesheetProperty); }
+ set { SetValue(BaseStylesheetProperty, value); }
+ }
+
+ ///
+ /// Gets or sets the text of this panel
+ ///
+ public string Text
+ {
+ get { return (string)GetValue(TextProperty); }
+ set { SetValue(TextProperty, value); }
+ }
+
+ ///
+ /// Get the currently selected text segment in the html.
+ ///
+ public virtual string SelectedText
+ {
+ get { return _htmlContainer.SelectedText; }
+ }
+
+ ///
+ /// Copy the currently selected html segment with style.
+ ///
+ public virtual string SelectedHtml
+ {
+ get { return _htmlContainer.SelectedHtml; }
+ }
+
+ ///
+ /// The last known pointer location relative to this control, as tracked from its own pointer
+ /// events - WinUI 3 has no queryable global pointer-button state the way WPF's
+ /// does, so reads
+ /// this cached value instead.
+ ///
+ internal RPoint LastPointerLocation
+ {
+ get { return _lastPointerLocation; }
+ }
+
+ ///
+ /// Whether the left pointer button was down as of the last pointer event this control observed.
+ ///
+ internal bool IsLeftButtonPressed
+ {
+ get { return _isLeftButtonPressed; }
+ }
+
+ ///
+ /// Whether the right pointer button was down as of the last pointer event this control observed.
+ ///
+ internal bool IsRightButtonPressed
+ {
+ get { return _isRightButtonPressed; }
+ }
+
+ ///
+ /// Get html from the current DOM tree with inline style.
+ ///
+ public virtual string GetHtml()
+ {
+ return _htmlContainer != null ? _htmlContainer.GetHtml() : null;
+ }
+
+ ///
+ /// Get the rectangle of html element as calculated by html layout.
+ ///
+ public virtual Rect? GetElementRectangle(string elementId)
+ {
+ return _htmlContainer != null ? _htmlContainer.GetElementRectangle(elementId) : null;
+ }
+
+ ///
+ /// Clear the current selection.
+ ///
+ public void ClearSelection()
+ {
+ _htmlContainer?.ClearSelection();
+ }
+
+ ///
+ /// Set the cursor shown over this control's rendered html.
+ ///
+ ///
+ /// is a protected member - it can only be set from within
+ /// a subclass's own code, unlike WPF's public Control.Cursor
+ /// setter, so (an external class) calls through this method
+ /// rather than setting a cursor property directly.
+ ///
+ internal void SetCursorShape(CursorShape shape)
+ {
+ InputSystemCursorShape cursorShape;
+ switch (shape)
+ {
+ case CursorShape.Hand:
+ cursorShape = InputSystemCursorShape.Hand;
+ break;
+ case CursorShape.IBeam:
+ cursorShape = InputSystemCursorShape.IBeam;
+ break;
+ default:
+ cursorShape = InputSystemCursorShape.Arrow;
+ break;
+ }
+ ProtectedCursor = InputSystemCursor.Create(cursorShape);
+ }
+
+ ///
+ /// Invalidate the composed canvas, causing a repaint.
+ ///
+ internal void InvalidateCanvas()
+ {
+ _canvas?.Invalidate();
+ }
+
+ #region Protected methods
+
+ ///
+ /// Perform paint of the html in the control.
+ ///
+ private void OnCanvasDraw(CanvasControl sender, CanvasDrawEventArgs args)
+ {
+ var g = args.DrawingSession;
+ var renderSize = new Size(sender.ActualWidth, sender.ActualHeight);
+
+ if (Background is SolidColorBrush backgroundBrush && backgroundBrush.Opacity > 0)
+ g.FillRectangle(new Rect(0, 0, renderSize.Width, renderSize.Height), backgroundBrush.Color);
+
+ var borderThickness = BorderThickness;
+ if (borderThickness != new Thickness(0) && BorderBrush is SolidColorBrush borderColorBrush)
+ {
+ var color = borderColorBrush.Color;
+ if (borderThickness.Top > 0)
+ g.FillRectangle(new Rect(0, 0, renderSize.Width, borderThickness.Top), color);
+ if (borderThickness.Bottom > 0)
+ g.FillRectangle(new Rect(0, renderSize.Height - borderThickness.Bottom, renderSize.Width, borderThickness.Bottom), color);
+ if (borderThickness.Left > 0)
+ g.FillRectangle(new Rect(0, 0, borderThickness.Left, renderSize.Height), color);
+ if (borderThickness.Right > 0)
+ g.FillRectangle(new Rect(renderSize.Width - borderThickness.Right, 0, borderThickness.Right, renderSize.Height), color);
+ }
+
+ var htmlWidth = HtmlWidth(renderSize);
+ var htmlHeight = HtmlHeight(renderSize);
+ if (_htmlContainer != null && htmlWidth > 0 && htmlHeight > 0)
+ {
+ var clip = new Rect(Padding.Left + borderThickness.Left, Padding.Top + borderThickness.Top, htmlWidth, htmlHeight);
+ using (g.CreateLayer(1f, clip))
+ {
+ _htmlContainer.Location = new Point(Padding.Left + borderThickness.Left, Padding.Top + borderThickness.Top);
+ _htmlContainer.PerformPaint(g, clip);
+ }
+
+ if (!_lastScrollOffset.Equals(_htmlContainer.ScrollOffset))
+ {
+ _lastScrollOffset = _htmlContainer.ScrollOffset;
+ InvokeMouseMove();
+ }
+ }
+ }
+
+ ///
+ /// Handle pointer move to handle hover cursor and text selection.
+ ///
+ protected override void OnPointerMoved(PointerRoutedEventArgs e)
+ {
+ base.OnPointerMoved(e);
+ UpdatePointerState(e);
+ _htmlContainer?.HandleMouseMove(this, Utils.Convert(_lastPointerLocation));
+ }
+
+ ///
+ /// Handle pointer exit to handle cursor change.
+ ///
+ protected override void OnPointerExited(PointerRoutedEventArgs e)
+ {
+ base.OnPointerExited(e);
+ _htmlContainer?.HandleMouseLeave(this);
+ }
+
+ ///
+ /// Handle pointer press to handle selection, and hand-rolled double-click detection.
+ ///
+ protected override void OnPointerPressed(PointerRoutedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ UpdatePointerState(e);
+
+ var point = e.GetCurrentPoint(this).Position;
+ var now = DateTime.UtcNow;
+ var isDoubleClick = (now - _lastPointerPressedTime).TotalMilliseconds <= DoubleClickThresholdMs
+ && Distance(point, _lastPointerPressedPosition) <= DoubleClickThresholdPixels;
+
+ if (isDoubleClick)
+ {
+ // Reset so a third rapid click starts a fresh detection window rather than chaining.
+ _lastPointerPressedTime = DateTime.MinValue;
+ _htmlContainer?.HandleMouseDoubleClick(this, e);
+ }
+ else
+ {
+ _lastPointerPressedTime = now;
+ _lastPointerPressedPosition = point;
+ _htmlContainer?.HandleMouseDown(this, e);
+ }
+ }
+
+ ///
+ /// Handle pointer release to handle selection and link click.
+ ///
+ protected override void OnPointerReleased(PointerRoutedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ UpdatePointerState(e);
+ _htmlContainer?.HandleMouseUp(this, e);
+ }
+
+ ///
+ /// Handle key down event for selection, copy and scrollbars handling.
+ ///
+ protected override void OnKeyDown(KeyRoutedEventArgs e)
+ {
+ base.OnKeyDown(e);
+ _htmlContainer?.HandleKeyDown(this, e);
+ }
+
+ ///
+ /// Propagate the LoadComplete event from root container.
+ ///
+ protected virtual void OnLoadComplete(EventArgs e)
+ {
+ LoadComplete?.Invoke(this, e);
+ }
+
+ ///
+ /// Propagate the LinkClicked event from root container.
+ ///
+ protected virtual void OnLinkClicked(HtmlLinkClickedEventArgs e)
+ {
+ LinkClicked?.Invoke(this, e);
+ }
+
+ ///
+ /// Propagate the Render Error event from root container.
+ ///
+ protected virtual void OnRenderError(HtmlRenderErrorEventArgs e)
+ {
+ RenderError?.Invoke(this, e);
+ }
+
+ ///
+ /// Propagate the stylesheet load event from root container.
+ ///
+ protected virtual void OnStylesheetLoad(HtmlStylesheetLoadEventArgs e)
+ {
+ StylesheetLoad?.Invoke(this, e);
+ }
+
+ ///
+ /// Propagate the image load event from root container.
+ ///
+ protected virtual void OnImageLoad(HtmlImageLoadEventArgs e)
+ {
+ ImageLoad?.Invoke(this, e);
+ }
+
+ ///
+ /// Handle html renderer invalidate and re-layout as requested.
+ ///
+ protected virtual void OnRefresh(HtmlRefreshEventArgs e)
+ {
+ if (e.Layout)
+ InvalidateMeasure();
+ _canvas?.Invalidate();
+ }
+
+ ///
+ /// Get the width the HTML has to render in (not including vertical scroll iff it is visible)
+ ///
+ protected virtual double HtmlWidth(Size size)
+ {
+ return size.Width - Padding.Left - Padding.Right - BorderThickness.Left - BorderThickness.Right;
+ }
+
+ ///
+ /// Get the height the HTML has to render in (not including horizontal scroll iff it is visible)
+ ///
+ protected virtual double HtmlHeight(Size size)
+ {
+ return size.Height - Padding.Top - Padding.Bottom - BorderThickness.Top - BorderThickness.Bottom;
+ }
+
+ ///
+ /// call mouse move to handle paint after scroll or html change affecting mouse cursor.
+ ///
+ protected virtual void InvokeMouseMove()
+ {
+ _htmlContainer.HandleMouseMove(this, Utils.Convert(_lastPointerLocation));
+ }
+
+ ///
+ /// Sets the html of this control and awaits the async load - the real entry point behind the
+ /// / dependency-property callbacks.
+ ///
+ public async Task SetTextAsync(string html, CancellationToken cancellationToken = default)
+ {
+ var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _pendingLoad?.Cancel();
+ _pendingLoad = cts;
+
+ try
+ {
+ await _htmlContainer.SetHtml(html, _baseCssData);
+ }
+ catch (Exception ex)
+ {
+ if (cts == _pendingLoad)
+ {
+ OnRenderError(new HtmlRenderErrorEventArgs(HtmlRenderErrorType.General, "Failed to set html", ex));
+ }
+ return;
+ }
+
+ if (cts != _pendingLoad || cts.IsCancellationRequested)
+ {
+ return;
+ }
+
+ InvalidateMeasure();
+ _canvas?.Invalidate();
+ InvokeMouseMove();
+ }
+
+ #endregion
+
+ #region Private methods
+
+ private void UpdatePointerState(PointerRoutedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(this);
+ _isLeftButtonPressed = point.Properties.IsLeftButtonPressed;
+ _isRightButtonPressed = point.Properties.IsRightButtonPressed;
+ _lastPointerLocation = Utils.Convert(point.Position);
+ }
+
+ private static double Distance(Point a, Point b)
+ {
+ var dx = a.X - b.X;
+ var dy = a.Y - b.Y;
+ return Math.Sqrt(dx * dx + dy * dy);
+ }
+
+ ///
+ /// Handle when dependency property value changes to update the underline HtmlContainer with the new value.
+ ///
+ private static void OnDependencyProperty_valueChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
+ {
+ var control = dependencyObject as HtmlControl;
+ if (control != null)
+ {
+ var htmlContainer = control._htmlContainer;
+ if (e.Property == AvoidImagesLateLoadingProperty)
+ {
+ htmlContainer.AvoidImagesLateLoading = (bool)e.NewValue;
+ }
+ else if (e.Property == IsSelectionEnabledProperty)
+ {
+ htmlContainer.IsSelectionEnabled = (bool)e.NewValue;
+ }
+ else if (e.Property == IsContextMenuEnabledProperty)
+ {
+ htmlContainer.IsContextMenuEnabled = (bool)e.NewValue;
+ }
+ else if (e.Property == BaseStylesheetProperty)
+ {
+ var baseCssData = HtmlRender.ParseStyleSheet((string)e.NewValue);
+ control._baseCssData = baseCssData;
+ _ = control.SetTextAsync(control.Text);
+ }
+ else if (e.Property == TextProperty)
+ {
+ htmlContainer.ScrollOffset = new Point(0, 0);
+ _ = control.SetTextAsync((string)e.NewValue);
+ }
+ }
+ }
+
+ #region Private event handlers (marshal onto the UI thread)
+
+ private void OnLoadCompleteInternal(object sender, EventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnLoadComplete(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnLoadComplete(e));
+ }
+
+ private void OnLinkClickedInternal(object sender, HtmlLinkClickedEventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnLinkClicked(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnLinkClicked(e));
+ }
+
+ private void OnRenderErrorInternal(object sender, HtmlRenderErrorEventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnRenderError(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnRenderError(e));
+ }
+
+ private void OnStylesheetLoadInternal(object sender, HtmlStylesheetLoadEventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnStylesheetLoad(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnStylesheetLoad(e));
+ }
+
+ private void OnImageLoadInternal(object sender, HtmlImageLoadEventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnImageLoad(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnImageLoad(e));
+ }
+
+ private void OnRefreshInternal(object sender, HtmlRefreshEventArgs e)
+ {
+ if (_dispatcherQueue.HasThreadAccess)
+ OnRefresh(e);
+ else
+ _dispatcherQueue.TryEnqueue(() => OnRefresh(e));
+ }
+
+ #endregion
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlLabel.cs b/Source/HtmlRenderer.WinUI/HtmlLabel.cs
new file mode 100644
index 000000000..2ecd5af0e
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlLabel.cs
@@ -0,0 +1,137 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Media;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.WinUI.Adapters;
+using Windows.Foundation;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI
+{
+ ///
+ /// Provides HTML rendering using the text property.
+ /// WinUI 3 control that will render html content in it's client rectangle.
+ /// Using and client can control how the html content effects the
+ /// size of the label. Either case scrollbars are never shown and html content outside of client bounds will be clipped.
+ /// MaxWidth/MaxHeight and MinWidth/MinHeight with AutoSize can limit the max/min size of the control
+ /// The control will handle pointer and keyboard events on it to support html text selection, copy-paste and mouse clicks.
+ ///
+ ///
+ /// See for more info.
+ ///
+ public class HtmlLabel : HtmlControl
+ {
+ #region Dependency properties
+
+ public static readonly DependencyProperty AutoSizeProperty = DependencyProperty.Register(nameof(AutoSize), typeof(bool), typeof(HtmlLabel), new PropertyMetadata(true, OnDependencyProperty_valueChanged));
+ public static readonly DependencyProperty AutoSizeHeightOnlyProperty = DependencyProperty.Register(nameof(AutoSizeHeightOnly), typeof(bool), typeof(HtmlLabel), new PropertyMetadata(false, OnDependencyProperty_valueChanged));
+
+ #endregion
+
+ ///
+ /// Init.
+ ///
+ public HtmlLabel()
+ {
+ Background = new SolidColorBrush(Colors.Transparent);
+ }
+
+ ///
+ /// Automatically sets the size of the label by content size
+ ///
+ public bool AutoSize
+ {
+ get { return (bool)GetValue(AutoSizeProperty); }
+ set { SetValue(AutoSizeProperty, value); }
+ }
+
+ ///
+ /// Automatically sets the height of the label by content height (width is not effected).
+ ///
+ public virtual bool AutoSizeHeightOnly
+ {
+ get { return (bool)GetValue(AutoSizeHeightOnlyProperty); }
+ set { SetValue(AutoSizeHeightOnlyProperty, value); }
+ }
+
+ #region Protected methods
+
+ ///
+ /// Perform the layout of the html in the control.
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ if (_htmlContainer != null)
+ {
+ using (var ig = new GraphicsAdapter())
+ {
+ var horizontal = Padding.Left + Padding.Right + BorderThickness.Left + BorderThickness.Right;
+ var vertical = Padding.Top + Padding.Bottom + BorderThickness.Top + BorderThickness.Bottom;
+
+ var size = new RSize(availableSize.Width < double.PositiveInfinity ? availableSize.Width - horizontal : 0, availableSize.Height < double.PositiveInfinity ? availableSize.Height - vertical : 0);
+ var minSize = new RSize(MinWidth < double.PositiveInfinity ? MinWidth - horizontal : 0, MinHeight < double.PositiveInfinity ? MinHeight - vertical : 0);
+ var maxSize = new RSize(MaxWidth < double.PositiveInfinity ? MaxWidth - horizontal : 0, MaxHeight < double.PositiveInfinity ? MaxHeight - vertical : 0);
+
+ var newSize = HtmlRendererUtils.Layout(ig, _htmlContainer.HtmlContainerInt, size, minSize, maxSize, AutoSize, AutoSizeHeightOnly);
+
+ availableSize = new Size(newSize.Width + horizontal, newSize.Height + vertical);
+ }
+ }
+
+ base.MeasureOverride(availableSize);
+
+ if (double.IsPositiveInfinity(availableSize.Width) || double.IsPositiveInfinity(availableSize.Height))
+ availableSize = new Size(0, 0);
+
+ return availableSize;
+ }
+
+ #endregion
+
+ #region Private methods
+
+ ///
+ /// Handle when dependency property value changes to update the underline HtmlContainer with the new value.
+ ///
+ private static void OnDependencyProperty_valueChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
+ {
+ var control = dependencyObject as HtmlLabel;
+ if (control != null)
+ {
+ if (e.Property == AutoSizeProperty)
+ {
+ if ((bool)e.NewValue)
+ {
+ dependencyObject.SetValue(AutoSizeHeightOnlyProperty, false);
+ control.InvalidateMeasure();
+ control.InvalidateCanvas();
+ }
+ }
+ else if (e.Property == AutoSizeHeightOnlyProperty)
+ {
+ if ((bool)e.NewValue)
+ {
+ dependencyObject.SetValue(AutoSizeProperty, false);
+ control.InvalidateMeasure();
+ control.InvalidateCanvas();
+ }
+ }
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlPanel.cs b/Source/HtmlRenderer.WinUI/HtmlPanel.cs
new file mode 100644
index 000000000..6e665ca29
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlPanel.cs
@@ -0,0 +1,369 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using Microsoft.UI;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using Windows.Foundation;
+using Windows.System;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI
+{
+ ///
+ /// Provides HTML rendering using the text property.
+ /// WinUI 3 control that will render html content in it's client rectangle.
+ /// If the layout of the html resulted in its content beyond the client bounds of the panel it will show scrollbars (horizontal/vertical) allowing to scroll the content.
+ /// The control will handle pointer and keyboard events on it to support html text selection, copy-paste and mouse clicks.
+ ///
+ ///
+ /// See for more info. Unlike WPF's own HtmlPanel, which docks its
+ /// scrollbars via raw visual children (FrameworkElement.AddVisualChild/manual
+ /// ArrangeOverride pixel math) - a WPF-only API with no WinUI 3 equivalent - this control docks
+ /// its two s as ordinary children of ,
+ /// positioned via /
+ /// instead of a manual per-frame Rect computation. The HTML-side layout math (max width,
+ /// scrollbar min/max/viewport, visibility toggling to avoid a wrap/re-layout thrash) is still a
+ /// mechanical, close port of WPF's own.
+ ///
+ public class HtmlPanel : HtmlControl
+ {
+ #region Fields and Consts
+
+ ///
+ /// the vertical scroll bar for the control to scroll to html content out of view
+ ///
+ protected readonly ScrollBar _verticalScrollBar;
+
+ ///
+ /// the horizontal scroll bar for the control to scroll to html content out of view
+ ///
+ protected readonly ScrollBar _horizontalScrollBar;
+
+ #endregion
+
+ ///
+ /// Creates a new HtmlPanel and sets a basic css for it's styling.
+ ///
+ public HtmlPanel()
+ {
+ Background = new SolidColorBrush(Colors.White);
+
+ _verticalScrollBar = new ScrollBar
+ {
+ Orientation = Orientation.Vertical,
+ // A standalone ScrollBar (outside a ScrollViewer) defaults IndicatorMode to None, which
+ // renders nothing at all regardless of Visibility - confirmed via a documented WinUI 3
+ // issue (https://learn.microsoft.com/en-us/answers/questions/792524), whose fix is
+ // exactly this: set IndicatorMode explicitly.
+ IndicatorMode = ScrollingIndicatorMode.MouseIndicator,
+ Width = 18,
+ Visibility = Visibility.Collapsed,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Stretch,
+ };
+ _verticalScrollBar.Scroll += OnScrollBarScroll;
+ _rootPanel.Children.Add(_verticalScrollBar);
+
+ _horizontalScrollBar = new ScrollBar
+ {
+ Orientation = Orientation.Horizontal,
+ IndicatorMode = ScrollingIndicatorMode.MouseIndicator,
+ Height = 18,
+ Visibility = Visibility.Collapsed,
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ };
+ _horizontalScrollBar.Scroll += OnScrollBarScroll;
+ _rootPanel.Children.Add(_horizontalScrollBar);
+
+ _htmlContainer.ScrollChange += OnScrollChange;
+
+ // WPF overrides TextProperty's own PropertyMetadata for the HtmlPanel type
+ // (DependencyProperty.OverrideMetadata) to reset scroll position on text change - WinUI 3's
+ // DependencyProperty has no per-subclass metadata override, so this uses the instance-level
+ // RegisterPropertyChangedCallback substitute instead.
+ RegisterPropertyChangedCallback(TextProperty, (d, dp) =>
+ {
+ _horizontalScrollBar.Value = _verticalScrollBar.Value = 0;
+ });
+ }
+
+ ///
+ /// Adjust the scrollbar of the panel on html element by the given id.
+ /// The top of the html element rectangle will be at the top of the panel, if there
+ /// is not enough height to scroll to the top the scroll will be at maximum.
+ ///
+ /// the id of the element to scroll to
+ public virtual void ScrollToElement(string elementId)
+ {
+ ArgChecker.AssertArgNotNullOrEmpty(elementId, "elementId");
+
+ if (_htmlContainer != null)
+ {
+ var rect = _htmlContainer.GetElementRectangle(elementId);
+ if (rect.HasValue)
+ {
+ ScrollToPoint(rect.Value.X, rect.Value.Y);
+ InvokeMouseMove();
+ }
+ }
+ }
+
+ #region Protected methods
+
+ ///
+ /// Perform the layout of the html in the control.
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var size = PerformHtmlLayout(availableSize);
+
+ // to handle if scrollbar is appearing or disappearing
+ bool relayout = false;
+ var htmlHeight = HtmlHeight(availableSize);
+ var verticalNeeded = size.Height > htmlHeight;
+ if ((_verticalScrollBar.Visibility == Visibility.Collapsed && verticalNeeded) ||
+ (_verticalScrollBar.Visibility == Visibility.Visible && !verticalNeeded))
+ {
+ _verticalScrollBar.Visibility = verticalNeeded ? Visibility.Visible : Visibility.Collapsed;
+ relayout = true;
+ }
+
+ var htmlWidth = HtmlWidth(availableSize);
+ var horizontalNeeded = size.Width > htmlWidth;
+ if ((_horizontalScrollBar.Visibility == Visibility.Collapsed && horizontalNeeded) ||
+ (_horizontalScrollBar.Visibility == Visibility.Visible && !horizontalNeeded))
+ {
+ _horizontalScrollBar.Visibility = horizontalNeeded ? Visibility.Visible : Visibility.Collapsed;
+ relayout = true;
+ }
+
+ if (relayout)
+ size = PerformHtmlLayout(availableSize);
+
+ UpdateScrollBarRanges(availableSize);
+
+ base.MeasureOverride(availableSize);
+
+ if (double.IsPositiveInfinity(availableSize.Width) || double.IsPositiveInfinity(availableSize.Height))
+ return size;
+
+ return availableSize;
+ }
+
+ ///
+ /// After measurement update the scrollbar ranges to match the arranged size.
+ ///
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ var result = base.ArrangeOverride(finalSize);
+ UpdateScrollBarRanges(finalSize);
+ UpdateScrollOffsets();
+ return result;
+ }
+
+ ///
+ /// Perform html container layout by the current panel client size.
+ ///
+ protected Size PerformHtmlLayout(Size constraint)
+ {
+ if (_htmlContainer != null)
+ {
+ _htmlContainer.MaxSize = new Size(HtmlWidth(constraint), 0);
+ _htmlContainer.PerformLayout();
+ return _htmlContainer.ActualSize;
+ }
+ return new Size(0, 0);
+ }
+
+ ///
+ /// Handle pointer release to set focus on the control.
+ ///
+ protected override void OnPointerReleased(PointerRoutedEventArgs e)
+ {
+ base.OnPointerReleased(e);
+ Focus(FocusState.Pointer);
+ }
+
+ ///
+ /// Handle mouse wheel for scrolling.
+ ///
+ protected override void OnPointerWheelChanged(PointerRoutedEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ if (_verticalScrollBar.Visibility == Visibility.Visible)
+ {
+ var delta = e.GetCurrentPoint(this).Properties.MouseWheelDelta;
+ _verticalScrollBar.Value -= delta;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ }
+
+ ///
+ /// Handle key down event for selection, copy and scrollbars handling.
+ ///
+ protected override void OnKeyDown(KeyRoutedEventArgs e)
+ {
+ base.OnKeyDown(e);
+
+ if (_verticalScrollBar.Visibility == Visibility.Visible)
+ {
+ if (e.Key == VirtualKey.Up)
+ {
+ _verticalScrollBar.Value -= _verticalScrollBar.SmallChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.Down)
+ {
+ _verticalScrollBar.Value += _verticalScrollBar.SmallChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.PageUp)
+ {
+ _verticalScrollBar.Value -= _verticalScrollBar.LargeChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.PageDown)
+ {
+ _verticalScrollBar.Value += _verticalScrollBar.LargeChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.Home)
+ {
+ _verticalScrollBar.Value = 0;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.End)
+ {
+ _verticalScrollBar.Value = _verticalScrollBar.Maximum;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ }
+
+ if (_horizontalScrollBar.Visibility == Visibility.Visible)
+ {
+ if (e.Key == VirtualKey.Left)
+ {
+ _horizontalScrollBar.Value -= _horizontalScrollBar.SmallChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ else if (e.Key == VirtualKey.Right)
+ {
+ _horizontalScrollBar.Value += _horizontalScrollBar.SmallChange;
+ UpdateScrollOffsets();
+ e.Handled = true;
+ }
+ }
+ }
+
+ ///
+ /// Get the width the HTML has to render in (not including vertical scroll iff it is visible)
+ ///
+ protected override double HtmlWidth(Size size)
+ {
+ var width = base.HtmlWidth(size) - (_verticalScrollBar.Visibility == Visibility.Visible ? _verticalScrollBar.Width : 0);
+ return width > 1 ? width : 1;
+ }
+
+ ///
+ /// Get the height the HTML has to render in (not including horizontal scroll iff it is visible)
+ ///
+ protected override double HtmlHeight(Size size)
+ {
+ var height = base.HtmlHeight(size) - (_horizontalScrollBar.Visibility == Visibility.Visible ? _horizontalScrollBar.Height : 0);
+ return height > 1 ? height : 1;
+ }
+
+ #endregion
+
+ #region Private methods
+
+ ///
+ /// On HTML container scroll change request scroll to the requested location.
+ ///
+ private void OnScrollChange(object sender, Core.Entities.HtmlScrollEventArgs e)
+ {
+ ScrollToPoint(e.X, e.Y);
+ }
+
+ ///
+ /// Set the control scroll offset to the given values.
+ ///
+ private void ScrollToPoint(double x, double y)
+ {
+ _horizontalScrollBar.Value = x;
+ _verticalScrollBar.Value = y;
+ UpdateScrollOffsets();
+ }
+
+ ///
+ /// On scrollbar scroll update the scroll offsets and invalidate.
+ ///
+ private void OnScrollBarScroll(object sender, ScrollEventArgs e)
+ {
+ UpdateScrollOffsets();
+ }
+
+ ///
+ /// Update the scroll range/viewport of the scrollbars to match the given bounds.
+ ///
+ private void UpdateScrollBarRanges(Size bounds)
+ {
+ if (_htmlContainer == null)
+ return;
+
+ if (_verticalScrollBar.Visibility == Visibility.Visible)
+ {
+ _verticalScrollBar.ViewportSize = HtmlHeight(bounds);
+ _verticalScrollBar.SmallChange = 25;
+ _verticalScrollBar.LargeChange = _verticalScrollBar.ViewportSize * .9;
+ _verticalScrollBar.Maximum = Math.Max(0, _htmlContainer.ActualSize.Height - _verticalScrollBar.ViewportSize);
+ }
+
+ if (_horizontalScrollBar.Visibility == Visibility.Visible)
+ {
+ _horizontalScrollBar.ViewportSize = HtmlWidth(bounds);
+ _horizontalScrollBar.SmallChange = 25;
+ _horizontalScrollBar.LargeChange = _horizontalScrollBar.ViewportSize * .9;
+ _horizontalScrollBar.Maximum = Math.Max(0, _htmlContainer.ActualSize.Width - _horizontalScrollBar.ViewportSize);
+ }
+ }
+
+ ///
+ /// Update the scroll offset of the HTML container and invalidate visual to re-render.
+ ///
+ private void UpdateScrollOffsets()
+ {
+ var newScrollOffset = new Point(-_horizontalScrollBar.Value, -_verticalScrollBar.Value);
+ if (!newScrollOffset.Equals(_htmlContainer.ScrollOffset))
+ {
+ _htmlContainer.ScrollOffset = newScrollOffset;
+ InvalidateCanvas();
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlRender.cs b/Source/HtmlRenderer.WinUI/HtmlRender.cs
new file mode 100644
index 000000000..1bc086bcd
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlRender.cs
@@ -0,0 +1,315 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.Graphics.Canvas;
+using Microsoft.Graphics.Canvas.Text;
+using Microsoft.UI;
+using TheArtOfDev.HtmlRenderer.Core;
+using TheArtOfDev.HtmlRenderer.Core.Entities;
+using TheArtOfDev.HtmlRenderer.Core.Utils;
+using TheArtOfDev.HtmlRenderer.WinUI.Adapters;
+using TheArtOfDev.HtmlRenderer.WinUI.Utilities;
+using Windows.Foundation;
+using Windows.UI;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI
+{
+ ///
+ /// Standalone static class for simple and direct HTML rendering.
+ /// For WinUI 3 UI prefer using HTML controls: or .
+ /// For low-level control and performance consider using .
+ ///
+ public static class HtmlRender
+ {
+ ///
+ /// Adds a font family to be used in html rendering.
+ /// The added font will be used by all rendering function including and all WinUI 3 controls.
+ ///
+ ///
+ /// Unlike WPF's AddFontFamily(FontFamily), WinUI/DirectWrite has no lightweight standalone
+ /// "font family object" outside of a loaded - pass the font set that
+ /// registered (see 's
+ /// own use of it) when adding a custom-loaded family, or leave it null for an already
+ /// system-installed family name.
+ ///
+ /// The font family name to add.
+ /// optional: the font set that backs the family, kept alive for as long as the family is referenced
+ public static void AddFontFamily(string familyName, CanvasFontSet fontSet = null)
+ {
+ ArgChecker.AssertArgNotNullOrEmpty(familyName, "familyName");
+
+ WinUIAdapter.Instance.AddFontFamily(new FontFamilyAdapter(familyName, fontSet));
+ }
+
+ ///
+ /// Adds a font mapping from to iff the is not found.
+ ///
+ /// the font family to replace
+ /// the font family to replace with
+ public static void AddFontFamilyMapping(string fromFamily, string toFamily)
+ {
+ ArgChecker.AssertArgNotNullOrEmpty(fromFamily, "fromFamily");
+ ArgChecker.AssertArgNotNullOrEmpty(toFamily, "toFamily");
+
+ WinUIAdapter.Instance.AddFontFamilyMapping(fromFamily, toFamily);
+ }
+
+ ///
+ /// Parse the given stylesheet to object.
+ ///
+ /// the stylesheet source to parse
+ /// true - combine the parsed css data with default css data, false - return only the parsed css data
+ /// the parsed css data
+ public static CssData ParseStyleSheet(string stylesheet, bool combineWithDefault = true)
+ {
+ return CssData.Parse(WinUIAdapter.Instance, stylesheet, combineWithDefault);
+ }
+
+ ///
+ /// Measure the size (width and height) required to draw the given html under given max width restriction.
+ ///
+ /// HTML source to render
+ /// optional: bound the width of the html to render in (default - 0, unlimited)
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the size required for the html
+ public static async Task MeasureAsync(string html, double maxWidth = 0, CssData cssData = null,
+ EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ Size actualSize = default;
+ if (!string.IsNullOrEmpty(html))
+ {
+ using (var container = new HtmlContainer())
+ {
+ container.MaxSize = new Size(maxWidth, 0);
+ container.AvoidAsyncImagesLoading = true;
+ container.AvoidImagesLateLoading = true;
+
+ if (stylesheetLoad != null)
+ container.StylesheetLoad += stylesheetLoad;
+ if (imageLoad != null)
+ container.ImageLoad += imageLoad;
+
+ await container.SetHtml(html, cssData).ConfigureAwait(false);
+ container.PerformLayout();
+
+ actualSize = container.ActualSize;
+ }
+ }
+ return actualSize;
+ }
+
+ ///
+ /// Renders the specified HTML source on the specified location and max width restriction.
+ ///
+ /// Device to render with
+ /// HTML source to render
+ /// optional: the left most location to start render the html at (default - 0)
+ /// optional: the top most location to start render the html at (default - 0)
+ /// optional: bound the width of the html to render in (default - 0, unlimited)
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the actual size of the rendered html
+ public static Task RenderAsync(CanvasDrawingSession g, string html, double left = 0, double top = 0, double maxWidth = 0, CssData cssData = null,
+ EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ ArgChecker.AssertArgNotNull(g, "g");
+ return RenderClip(g, html, new Point(left, top), new Size(maxWidth, 0), cssData, stylesheetLoad, imageLoad);
+ }
+
+ ///
+ /// Renders the specified HTML source on the specified location and max size restriction.
+ ///
+ /// Device to render with
+ /// HTML source to render
+ /// the top-left most location to start render the html at
+ /// the max size of the rendered html (if height above zero it will be clipped)
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the actual size of the rendered html
+ public static Task RenderAsync(CanvasDrawingSession g, string html, Point location, Size maxSize, CssData cssData = null,
+ EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ ArgChecker.AssertArgNotNull(g, "g");
+ return RenderClip(g, html, location, maxSize, cssData, stylesheetLoad, imageLoad);
+ }
+
+ ///
+ /// Renders the specified HTML into a new image of the requested size.
+ /// The HTML will be layout by the given size but will be clipped if cannot fit.
+ ///
+ ///
+ /// Unlike WPF's RenderToImageAsync, which needs a UI-thread-affine RenderTargetBitmap,
+ /// Win2D's is a plain GPU/CPU resource with no UI-thread
+ /// affinity - this needs no dispatcher/UI thread at all.
+ ///
+ /// HTML source to render
+ /// The size of the image to render into, layout html by width and clipped by height
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the generated image of the html
+ public static async Task RenderToImageAsync(string html, Size size, CssData cssData = null,
+ EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ var renderTarget = new CanvasRenderTarget(WinUIAdapter.Instance.Device, (float)Math.Max(1, size.Width), (float)Math.Max(1, size.Height), 96);
+
+ if (!string.IsNullOrEmpty(html))
+ {
+ using (var g = renderTarget.CreateDrawingSession())
+ {
+ g.Clear(Colors.White);
+ await RenderHtml(g, html, new Point(), size, cssData, stylesheetLoad, imageLoad).ConfigureAwait(false);
+ }
+ }
+
+ return renderTarget;
+ }
+
+ ///
+ /// Renders the specified HTML into a new image of unknown size that will be determined by max width/height and HTML layout.
+ ///
+ /// HTML source to render
+ /// optional: the max width of the rendered html, if not zero and html cannot be layout within the limit it will be clipped
+ /// optional: the max height of the rendered html, if not zero and html cannot be layout within the limit it will be clipped
+ /// optional: the color to fill the image with (default - white)
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the generated image of the html
+ public static Task RenderToImageAsync(string html, int maxWidth = 0, int maxHeight = 0, Color backgroundColor = default,
+ CssData cssData = null, EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ return RenderToImageAsync(html, default, new Size(maxWidth, maxHeight), backgroundColor, cssData, stylesheetLoad, imageLoad);
+ }
+
+ ///
+ /// Renders the specified HTML into a new image of unknown size that will be determined by min/max width/height and HTML layout.
+ ///
+ /// HTML source to render
+ /// optional: the min size of the rendered html (zero - not limit the width/height)
+ /// optional: the max size of the rendered html, if not zero and html cannot be layout within the limit it will be clipped (zero - not limit the width/height)
+ /// optional: the color to fill the image with (default - white)
+ /// optional: the style to use for html rendering (default - use W3 default style)
+ /// optional: can be used to overwrite stylesheet resolution logic
+ /// optional: can be used to overwrite image resolution logic
+ /// the generated image of the html
+ public static async Task RenderToImageAsync(string html, Size minSize, Size maxSize, Color backgroundColor = default, CssData cssData = null,
+ EventHandler stylesheetLoad = null, EventHandler imageLoad = null)
+ {
+ CanvasRenderTarget renderTarget;
+ if (!string.IsNullOrEmpty(html))
+ {
+ using (var container = new HtmlContainer())
+ {
+ container.AvoidAsyncImagesLoading = true;
+ container.AvoidImagesLateLoading = true;
+
+ if (stylesheetLoad != null)
+ container.StylesheetLoad += stylesheetLoad;
+ if (imageLoad != null)
+ container.ImageLoad += imageLoad;
+ await container.SetHtml(html, cssData).ConfigureAwait(false);
+
+ var finalSize = MeasureHtmlByRestrictions(container, minSize, maxSize);
+ container.MaxSize = finalSize;
+
+ renderTarget = new CanvasRenderTarget(WinUIAdapter.Instance.Device, (float)Math.Max(1, finalSize.Width), (float)Math.Max(1, finalSize.Height), 96);
+
+ using (var g = renderTarget.CreateDrawingSession())
+ {
+ g.Clear(backgroundColor.Equals(default(Color)) ? Colors.White : backgroundColor);
+ container.PerformPaint(g, new Rect(0, 0, maxSize.Width > 0 ? maxSize.Width : double.MaxValue, maxSize.Height > 0 ? maxSize.Height : double.MaxValue));
+ }
+ }
+ }
+ else
+ {
+ renderTarget = new CanvasRenderTarget(WinUIAdapter.Instance.Device, 1, 1, 96);
+ }
+
+ return renderTarget;
+ }
+
+ #region Private methods
+
+ ///
+ /// Measure the size of the html by performing layout under the given restrictions.
+ ///
+ private static Size MeasureHtmlByRestrictions(HtmlContainer htmlContainer, Size minSize, Size maxSize)
+ {
+ using (var mg = new GraphicsAdapter())
+ {
+ var sizeInt = HtmlRendererUtils.MeasureHtmlByRestrictions(mg, htmlContainer.HtmlContainerInt, Utils.Convert(minSize), Utils.Convert(maxSize));
+ if (maxSize.Width < 1 && sizeInt.Width > 4096)
+ sizeInt.Width = 4096;
+ return Utils.ConvertRound(sizeInt);
+ }
+ }
+
+ ///
+ /// Renders the specified HTML source on the specified location and max size restriction, clipping
+ /// the graphics so the html will not be rendered outside the max height bound given.
+ ///
+ private static async Task RenderClip(CanvasDrawingSession g, string html, Point location, Size maxSize, CssData cssData, EventHandler stylesheetLoad, EventHandler imageLoad)
+ {
+ if (maxSize.Height > 0)
+ {
+ using (g.CreateLayer(1f, new Rect(location, maxSize)))
+ {
+ return await RenderHtml(g, html, location, maxSize, cssData, stylesheetLoad, imageLoad).ConfigureAwait(false);
+ }
+ }
+
+ return await RenderHtml(g, html, location, maxSize, cssData, stylesheetLoad, imageLoad).ConfigureAwait(false);
+ }
+
+ ///
+ /// Renders the specified HTML source on the specified location and max size restriction.
+ ///
+ private static async Task RenderHtml(CanvasDrawingSession g, string html, Point location, Size maxSize, CssData cssData, EventHandler stylesheetLoad, EventHandler imageLoad)
+ {
+ Size actualSize = default;
+
+ if (!string.IsNullOrEmpty(html))
+ {
+ using (var container = new HtmlContainer())
+ {
+ container.Location = location;
+ container.MaxSize = maxSize;
+ container.AvoidAsyncImagesLoading = true;
+ container.AvoidImagesLateLoading = true;
+
+ if (stylesheetLoad != null)
+ container.StylesheetLoad += stylesheetLoad;
+ if (imageLoad != null)
+ container.ImageLoad += imageLoad;
+
+ await container.SetHtml(html, cssData).ConfigureAwait(false);
+ container.PerformLayout();
+ container.PerformPaint(g, new Rect(0, 0, double.MaxValue, double.MaxValue));
+
+ actualSize = container.ActualSize;
+ }
+ }
+
+ return actualSize;
+ }
+
+ #endregion
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/HtmlRenderer.WinUI.csproj b/Source/HtmlRenderer.WinUI/HtmlRenderer.WinUI.csproj
new file mode 100644
index 000000000..7e3333c43
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/HtmlRenderer.WinUI.csproj
@@ -0,0 +1,46 @@
+
+
+ net8.0-windows10.0.19041.0
+ 10.0.17763.0
+ Library
+ TheArtOfDev.HtmlRenderer.WinUI
+ true
+ true
+ true
+ None
+ false
+
+
+
+ HtmlRenderer.WinUI
+ HTML Renderer for WinUI 3
+ html render renderer draw control WinUI Win2D
+ Multipurpose (UI Controls / Image generation), 100% managed (C#), High performance HTML Rendering library for WinUI 3.
+
+HTML UI in .NET WinUI 3 applications using controls or static rendering.
+
+Features and Benefits:
+---
+* Controls: HtmlPanel, HtmlLabel.
+* Create images from HTML snippets.
+* 100% managed code and no external dependencies, no ActiveX, no MSHTML.
+* Extensive HTML 4.01 and CSS level 2 specifications support.
+* Support separating CSS from HTML by loading stylesheet code separately.
+* Support text selection, copy-paste and context menu.
+* Handles "real world" malformed HTML, it doesn't have to be XHTML.
+* Lightweight, only two DLLs (~300K).
+* High performance and low memory footprint.
+* Extendable and configurable.
+ Multipurpose (UI Controls / Image generation), 100% managed (C#), High performance HTML Rendering library for WinUI 3.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/HtmlRenderer.WinUI/README.md b/Source/HtmlRenderer.WinUI/README.md
new file mode 100644
index 000000000..fe9835718
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/README.md
@@ -0,0 +1,40 @@
+# Welcome to the HTML Renderer WinUI 3 library!
+
+This library provides the rich formatting power of HTML in your WinUI 3 .NET applications using
+simple controls or static rendering code.
+For more info see HTML Renderer on GitHub: https://github.com/ArthurHub/HTML-Renderer
+
+## FEEDBACK / RELEASE NOTES
+
+If you have problems, wish to report a bug, or have a suggestion, please open an issue on the
+HTML Renderer issue page: https://github.com/ArthurHub/HTML-Renderer/issues
+
+For full release notes and all versions see: https://github.com/ArthurHub/HTML-Renderer/releases
+
+---
+
+## Quick Start: Use HTML panel control on a WinUI 3 window
+
+```xaml
+
+
+
+
+
+```
+
+## Quick Start: Create image from HTML snippet
+
+```csharp
+class Program
+{
+ private static async Task Main(string[] args)
+ {
+ var bitmap = await HtmlRender.RenderToImageAsync("
Hello World
This is html rendered text
");
+ await bitmap.SaveAsync("image.png", CanvasBitmapFileFormat.Png);
+ }
+}
+```
diff --git a/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs b/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs
new file mode 100644
index 000000000..bcd68e435
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs
@@ -0,0 +1,72 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Windows.ApplicationModel.DataTransfer;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Utilities
+{
+ ///
+ /// Helper to set HTML fragment and plain text data to the clipboard.
+ ///
+ ///
+ /// HtmlRenderer.WPF's own -based helper hand-builds the
+ /// full CF_HTML header (Version:/StartHTML:/EndHTML:/StartFragment:/
+ /// EndFragment: with byte offsets computed via manual UTF-8 byte counting) because WPF's own
+ /// clipboard API has no higher-level "give me an HTML fragment, I'll build the header" helper.
+ ///
+ /// The WinRT clipboard API does: takes a plain HTML
+ /// fragment string and returns it already wrapped in a correct CF_HTML header (offsets included), and
+ /// is documented to accept exactly that pre-built CF_HTML
+ /// string. That makes WPF's entire byte-offset-arithmetic GetHtmlDataString implementation
+ /// (StartFragment/EndFragment comment injection, UTF-8 byte counting, header back-patching)
+ /// unnecessary here - confirmed during implementation per the plan's Step 6 note anticipating exactly
+ /// this outcome.
+ ///
+ ///
+ internal static class ClipboardHelper
+ {
+ ///
+ /// Create a with given html and plain-text ready to be used for clipboard
+ /// or drag-drop operation.
+ ///
+ /// a html fragment
+ /// the plain text
+ public static DataPackage CreateDataObject(string html, string plainText)
+ {
+ var dataPackage = new DataPackage();
+ dataPackage.SetHtmlFormat(HtmlFormatHelper.CreateHtmlFormat(html ?? string.Empty));
+ dataPackage.SetText(plainText ?? string.Empty);
+ return dataPackage;
+ }
+
+ ///
+ /// Clears clipboard and sets the given HTML and plain text fragment to the clipboard.
+ ///
+ /// a html fragment
+ /// the plain text
+ public static void CopyToClipboard(string html, string plainText)
+ {
+ Clipboard.SetContent(CreateDataObject(html, plainText));
+ }
+
+ ///
+ /// Clears clipboard and sets the given plain text fragment to the clipboard.
+ ///
+ /// the plain text
+ public static void CopyToClipboard(string plainText)
+ {
+ var dataPackage = new DataPackage();
+ dataPackage.SetText(plainText ?? string.Empty);
+ Clipboard.SetContent(dataPackage);
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Utilities/Utils.cs b/Source/HtmlRenderer.WinUI/Utilities/Utils.cs
new file mode 100644
index 000000000..98b54bbd9
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Utilities/Utils.cs
@@ -0,0 +1,112 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Windows.Foundation;
+using Windows.UI;
+using TheArtOfDev.HtmlRenderer.Adapters.Entities;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Utilities
+{
+ ///
+ /// Utilities for converting WinUI entities to HtmlRenderer core entities.
+ ///
+ internal static class Utils
+ {
+ ///
+ /// Convert from WinUI point to core point.
+ ///
+ public static RPoint Convert(Point p)
+ {
+ return new RPoint(p.X, p.Y);
+ }
+
+ ///
+ /// Convert from core point to WinUI point.
+ ///
+ public static Point Convert(RPoint p)
+ {
+ return new Point(p.X, p.Y);
+ }
+
+ ///
+ /// Convert from core point to WinUI point.
+ ///
+ public static Point ConvertRound(RPoint p)
+ {
+ return new Point((int)p.X, (int)p.Y);
+ }
+
+ ///
+ /// Convert from WinUI size to core size.
+ ///
+ public static RSize Convert(Size s)
+ {
+ return new RSize(s.Width, s.Height);
+ }
+
+ ///
+ /// Convert from core size to WinUI size.
+ ///
+ public static Size Convert(RSize s)
+ {
+ return new Size(s.Width, s.Height);
+ }
+
+ ///
+ /// Convert from core size to WinUI size.
+ ///
+ public static Size ConvertRound(RSize s)
+ {
+ return new Size((int)s.Width, (int)s.Height);
+ }
+
+ ///
+ /// Convert from WinUI rectangle to core rectangle.
+ ///
+ public static RRect Convert(Rect r)
+ {
+ return new RRect(r.X, r.Y, r.Width, r.Height);
+ }
+
+ ///
+ /// Convert from core rectangle to WinUI rectangle.
+ ///
+ public static Rect Convert(RRect r)
+ {
+ return new Rect(r.X, r.Y, r.Width, r.Height);
+ }
+
+ ///
+ /// Convert from core rectangle to WinUI rectangle.
+ ///
+ public static Rect ConvertRound(RRect r)
+ {
+ return new Rect((int)r.X, (int)r.Y, (int)r.Width, (int)r.Height);
+ }
+
+ ///
+ /// Convert from WinUI color to core color.
+ ///
+ public static RColor Convert(Color c)
+ {
+ return RColor.FromArgb(c.A, c.R, c.G, c.B);
+ }
+
+ ///
+ /// Convert from core color to WinUI color.
+ ///
+ public static Color Convert(RColor c)
+ {
+ return Color.FromArgb(c.A, c.R, c.G, c.B);
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs b/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs
new file mode 100644
index 000000000..b8f0041e7
--- /dev/null
+++ b/Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs
@@ -0,0 +1,62 @@
+// "Therefore those skilled at the unorthodox
+// are infinite as heaven and earth,
+// inexhaustible as the great rivers.
+// When they come to an end,
+// they begin again,
+// like the days and months;
+// they die and are reborn,
+// like the four seasons."
+//
+// - Sun Tsu,
+// "The Art of War"
+
+using Microsoft.Win32;
+using TheArtOfDev.HtmlRenderer.Adapters;
+
+namespace TheArtOfDev.HtmlRenderer.WinUI.Utilities
+{
+ ///
+ /// Reads the Windows app theme, which is what prefers-color-scheme reports for on-screen
+ /// rendering.
+ ///
+ ///
+ /// The registry read is ported byte-for-byte from HtmlRenderer.WPF's own
+ /// Utilities.WindowsTheme - it has no WPF-specific dependency, it's the same
+ /// HKCU\...\Personalize\AppsUseLightTheme value WPF reads. Only the change-notification
+ /// source differs (see 's constructor, which subscribes to
+ /// Windows.UI.ViewManagement.UISettings.ColorValuesChanged instead of WPF's
+ /// SystemEvents.UserPreferenceChanged - the modern WinRT-native signal for a WinUI 3 app).
+ ///
+ internal static class WindowsTheme
+ {
+ private const string PersonalizeKey = @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
+ private const string AppsUseLightTheme = "AppsUseLightTheme";
+
+ ///
+ /// The user's app theme, or when it cannot be determined -
+ /// light is the Windows default and the safer assumption for a document that declares no dark
+ /// styling of its own.
+ ///
+ public static RColorScheme GetAppsColorScheme()
+ {
+ try
+ {
+ using (var key = Registry.CurrentUser.OpenSubKey(PersonalizeKey))
+ {
+ if (key != null)
+ {
+ var value = key.GetValue(AppsUseLightTheme);
+ if (value is int)
+ return (int)value == 0 ? RColorScheme.Dark : RColorScheme.Light;
+ }
+ }
+ }
+ catch
+ {
+ // A locked-down or missing key just means "no preference expressed".
+ }
+
+ return RColorScheme.Light;
+ }
+ }
+}
diff --git a/Source/HtmlRenderer.sln b/Source/HtmlRenderer.sln
index fd1c8d0d3..521afb251 100644
--- a/Source/HtmlRenderer.sln
+++ b/Source/HtmlRenderer.sln
@@ -11,10 +11,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.Demo.WinForms"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.Demo.WPF", "Demo\WPF\HtmlRenderer.Demo.WPF.csproj", "{F02E0216-4AE3-474F-9381-FCB93411CDB0}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.Demo.WinUI", "Demo\WinUI\HtmlRenderer.Demo.WinUI.csproj", "{C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.WinForms", "HtmlRenderer.WinForms\HtmlRenderer.WinForms.csproj", "{1B058920-24B4-4140-8AE7-C8C6C38CA52D}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.WPF", "HtmlRenderer.WPF\HtmlRenderer.WPF.csproj", "{7E4E8DB5-85AD-4388-BDCB-38C6F423B8B0}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.WinUI", "HtmlRenderer.WinUI\HtmlRenderer.WinUI.csproj", "{592AB740-4283-49C6-91C2-065BE3D238FE}"
+EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer", "HtmlRenderer\HtmlRenderer.csproj", "{FE611685-391F-4E3E-B27E-D3150E51E49B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HtmlRenderer.PdfSharp", "HtmlRenderer.PdfSharp\HtmlRenderer.PdfSharp.csproj", "{CA249F5D-9285-40A6-B217-5889EF79FD7E}"
@@ -87,6 +91,26 @@ Global
{7E4E8DB5-85AD-4388-BDCB-38C6F423B8B0}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
{7E4E8DB5-85AD-4388-BDCB-38C6F423B8B0}.Release|Mixed Platforms.Build.0 = Release|Any CPU
{7E4E8DB5-85AD-4388-BDCB-38C6F423B8B0}.Release|x86.ActiveCfg = Release|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {592AB740-4283-49C6-91C2-065BE3D238FE}.Release|x86.ActiveCfg = Release|Any CPU
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Debug|Any CPU.ActiveCfg = Debug|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Debug|Any CPU.Build.0 = Debug|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Debug|Mixed Platforms.ActiveCfg = Debug|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Debug|Mixed Platforms.Build.0 = Debug|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Debug|x86.ActiveCfg = Debug|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Release|Any CPU.ActiveCfg = Release|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Release|Any CPU.Build.0 = Release|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Release|Mixed Platforms.ActiveCfg = Release|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Release|Mixed Platforms.Build.0 = Release|x64
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B}.Release|x86.ActiveCfg = Release|x64
{FE611685-391F-4E3E-B27E-D3150E51E49B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE611685-391F-4E3E-B27E-D3150E51E49B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE611685-391F-4E3E-B27E-D3150E51E49B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -151,6 +175,7 @@ Global
{2390B71F-9400-47F4-B23A-7F2649C87D35} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
{8AD34FE8-8382-4A8A-B3AA-A0392ED42423} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
{F02E0216-4AE3-474F-9381-FCB93411CDB0} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
+ {C7C711A6-46AB-4FA2-89ED-D0C79978DE5B} = {E263EA16-2E6A-4269-A319-AA2F97ADA8E1}
{AF39851E-37B9-409E-A34D-CE091A098C86} = {E508FA78-D57E-492F-9BF7-23640001848A}
{A12F4432-337B-4030-B834-5D312A9FB16C} = {E508FA78-D57E-492F-9BF7-23640001848A}
{7427E12F-1524-4405-AAD1-5AF8ACB3BBBC} = {E508FA78-D57E-492F-9BF7-23640001848A}
diff --git a/Source/HtmlRenderer/HtmlRenderer.csproj b/Source/HtmlRenderer/HtmlRenderer.csproj
index 4eabf4124..1f32d844b 100644
--- a/Source/HtmlRenderer/HtmlRenderer.csproj
+++ b/Source/HtmlRenderer/HtmlRenderer.csproj
@@ -36,6 +36,7 @@ For existing implementations see: HtmlRenderer.WinForms, HtmlRenderer.WPF and Ht
prefers-color-scheme rendering deterministic regardless of the host machine's own theme setting. -->
+