From 950bf6bdd2bbaaaae07470f42d4c346dd5e1eb27 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 18:55:14 -0400 Subject: [PATCH 1/2] Add HtmlRenderer.WinUI adapter project for WinUI 3 / Win2D New adapter beside HtmlRenderer.WinForms and HtmlRenderer.WPF, targeting WinUI 3 (Windows App SDK) via Win2D for 2D rendering. Implements the full RAdapter/RGraphics/RFont/RImage/RBrush/RPen/RGraphicsPath/RControl/ RContextMenu adapter surface, an HtmlControl/HtmlPanel/HtmlLabel/HtmlRender control layer mirroring the WPF adapter's shape, dark-mode support via UISettings, CF_HTML clipboard copy/paste, and a minimal demo app with the same sample-browser experience as the WinForms/WPF demos. Solution/csproj wiring: new HtmlRenderer.WinUI and HtmlRenderer.Demo.WinUI projects added to HtmlRenderer.sln; InternalsVisibleTo added to the core HtmlRenderer.csproj for the new adapter's test-seam access. --- Source/Demo/WinUI/App.xaml | 16 + Source/Demo/WinUI/App.xaml.cs | 37 + .../Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj | 33 + Source/Demo/WinUI/MainWindow.xaml | 20 + Source/Demo/WinUI/MainWindow.xaml.cs | 179 +++++ Source/Demo/WinUI/app.manifest | 31 + .../Adapters/BrushAdapter.cs | 71 ++ .../Adapters/ContextMenuAdapter.cs | 86 +++ .../Adapters/ControlAdapter.cs | 111 +++ .../Adapters/FontAdapter.cs | 157 ++++ .../Adapters/FontFamilyAdapter.cs | 65 ++ .../Adapters/GraphicsAdapter.cs | 354 ++++++++++ .../Adapters/GraphicsPathAdapter.cs | 84 +++ .../Adapters/ImageAdapter.cs | 61 ++ .../HtmlRenderer.WinUI/Adapters/PenAdapter.cs | 106 +++ .../Adapters/WinUIAdapter.cs | 412 +++++++++++ Source/HtmlRenderer.WinUI/HtmlContainer.cs | 424 +++++++++++ Source/HtmlRenderer.WinUI/HtmlControl.cs | 668 ++++++++++++++++++ Source/HtmlRenderer.WinUI/HtmlLabel.cs | 137 ++++ Source/HtmlRenderer.WinUI/HtmlPanel.cs | 369 ++++++++++ Source/HtmlRenderer.WinUI/HtmlRender.cs | 315 +++++++++ .../HtmlRenderer.WinUI.csproj | 46 ++ Source/HtmlRenderer.WinUI/README.md | 40 ++ .../Utilities/ClipboardHelper.cs | 72 ++ Source/HtmlRenderer.WinUI/Utilities/Utils.cs | 112 +++ .../Utilities/WindowsTheme.cs | 62 ++ Source/HtmlRenderer.sln | 25 + Source/HtmlRenderer/HtmlRenderer.csproj | 1 + 28 files changed, 4094 insertions(+) create mode 100644 Source/Demo/WinUI/App.xaml create mode 100644 Source/Demo/WinUI/App.xaml.cs create mode 100644 Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj create mode 100644 Source/Demo/WinUI/MainWindow.xaml create mode 100644 Source/Demo/WinUI/MainWindow.xaml.cs create mode 100644 Source/Demo/WinUI/app.manifest create mode 100644 Source/HtmlRenderer.WinUI/Adapters/BrushAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/ContextMenuAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/ControlAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/FontAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/FontFamilyAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/GraphicsAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/GraphicsPathAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/ImageAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/PenAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/Adapters/WinUIAdapter.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlContainer.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlControl.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlLabel.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlPanel.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlRender.cs create mode 100644 Source/HtmlRenderer.WinUI/HtmlRenderer.WinUI.csproj create mode 100644 Source/HtmlRenderer.WinUI/README.md create mode 100644 Source/HtmlRenderer.WinUI/Utilities/ClipboardHelper.cs create mode 100644 Source/HtmlRenderer.WinUI/Utilities/Utils.cs create mode 100644 Source/HtmlRenderer.WinUI/Utilities/WindowsTheme.cs 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.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. --> + From 137dbc0a2b6d0ecc479e97c954beaac628162289 Mon Sep 17 00:00:00 2001 From: Justin Haygood Date: Sat, 29 Aug 2026 19:02:23 -0400 Subject: [PATCH 2/2] Fix CI: build a cross-platform solution filter on non-Windows runners HtmlRenderer.WinUI/HtmlRenderer.Demo.WinUI depend on the Windows App SDK's MSIX PRI-generation step, which shells out to native Windows x86 binaries (makepri.exe/makeappx.exe) - 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 ("Exec format error" on Ubuntu/macOS). Adds Source/HtmlRenderer.CrossPlatform.slnf, excluding just those two projects, and has the workflow build the full .sln on Windows but the filtered one everywhere else - every other project (including the WinUI adapter's own test-relevant siblings) still builds and tests identically on all three OSes. --- .github/workflows/build.yml | 16 ++++++++++++++-- Source/HtmlRenderer.CrossPlatform.slnf | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 Source/HtmlRenderer.CrossPlatform.slnf 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/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" + ] + } +}