diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2f3e6ab48..07faf1b4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,12 +49,7 @@ jobs: - 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. + # WinUI needs the Windows-only XAML and MSIX tooling, the other platforms build everything else $target = $IsWindows ? "Source/HtmlRenderer.sln" : "Source/HtmlRenderer.CrossPlatform.slnf" echo "path=$target" >> $env:GITHUB_OUTPUT @@ -107,6 +102,10 @@ jobs: - name: Create HtmlRenderer.WPF NuGet package run: dotnet pack Source/HtmlRenderer.WPF/HtmlRenderer.WPF.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --no-build --verbosity normal --output ${{ env.NuGetDirectory }} + - name: Create HtmlRenderer.WinUI NuGet package + if: runner.os == 'Windows' + run: dotnet pack Source/HtmlRenderer.WinUI/HtmlRenderer.WinUI.csproj --configuration Release --include-symbols -p:SymbolPackageFormat=snupkg --no-build --verbosity normal --output ${{ env.NuGetDirectory }} + - name: Upload NuGet package artifacts uses: actions/upload-artifact@v7 with: diff --git a/README.md b/README.md index 0a8553037..1c8dde57d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HTML Renderer -**Cross framework** (WinForms/WPF/PDF/Metro/Mono/etc.), **Multipurpose** (UI Controls / Image generation / PDF generation / etc.), **100% managed** (C#), High performance HTML Rendering library. +**Cross framework** (WinForms/WPF/WinUI/PDF/Mono/etc.), **Multipurpose** (UI Controls / Image generation / PDF generation / etc.), **100% managed** (C#), High performance HTML Rendering library. The library is 100% managed **C#** code without any external dependencies (no WebBrowser control, ActiveX / COM or MSHTML dll), the only requirement is **.NET Standard 2.0 / .NET 8.0 or higher**. @@ -22,6 +22,7 @@ The latest NuGet packages can be found on NuGet.org: * [HtmlRenderer.WinForms](https://www.nuget.org/packages/HtmlRenderer.WinForms) * [HtmlRenderer.WPF](https://www.nuget.org/packages/HtmlRenderer.WPF) +* [HtmlRenderer.WinUI](https://www.nuget.org/packages/HtmlRenderer.WinUI) * [HtmlRenderer.PdfSharp](https://www.nuget.org/packages/HtmlRenderer.PdfSharp) * [HtmlRenderer.Core](https://www.nuget.org/packages/HtmlRenderer.Core) @@ -33,6 +34,7 @@ The latest NuGet packages can be found on NuGet.org: * Support text selection, copy-paste and context menu. * WinForms controls: HtmlPanel, HtmlLabel and HtmlToolTip. * WPF controls: HtmlPanel and HtmlLabel. +* WinUI 3 controls: HtmlPanel and HtmlLabel. * Works on Mono. * Create images/PDFs from HTML snippets. * Handles "real world" malformed HTML, it doesn't have to be XHTML. @@ -43,7 +45,7 @@ The latest NuGet packages can be found on NuGet.org: * Powerful [Demo application](https://codeplexarchive.org/ProjectTab/Wiki/HtmlRenderer/Documentation/Demo%20application) to explore and learn the library. -## WinForms/WPF controls +## WinForms/WPF/WinUI controls * *HtmlPanel* - The full power of HTML control build to replace WebBrowser control, accepts HTML, text selection, scrollbars, link click intercept, image load intercept and much more. * *HtmlLabel* - As WinForms label but accepts HTML, text selection, auto-size capabilities, transparent background and more. diff --git a/Source/Demo/WinUI/App.xaml b/Source/Demo/WinUI/App.xaml index d8ca34c0e..6e653679b 100644 --- a/Source/Demo/WinUI/App.xaml +++ b/Source/Demo/WinUI/App.xaml @@ -1,14 +1,12 @@ - + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + RequestedTheme="Light"> + - diff --git a/Source/Demo/WinUI/App.xaml.cs b/Source/Demo/WinUI/App.xaml.cs index cad246ffb..ec0316443 100644 --- a/Source/Demo/WinUI/App.xaml.cs +++ b/Source/Demo/WinUI/App.xaml.cs @@ -10,15 +10,14 @@ // - Sun Tsu, // "The Art of War" +using System; +using System.IO; using Microsoft.UI.Xaml; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using TheArtOfDev.HtmlRenderer.WinUI; 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; @@ -30,8 +29,23 @@ public App() protected override void OnLaunched(LaunchActivatedEventArgs args) { + SamplesLoader.Init("WinUI", typeof(HtmlRender).Assembly.GetName().Version.ToString()); + + LoadCustomFonts(); + _window = new MainWindow(); _window.Activate(); } + + /// + /// Load custom fonts to be used by renderer HTMLs + /// + private static void LoadCustomFonts() + { + // Win2D loads fonts only from files + var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".ttf"); + File.WriteAllBytes(file, Common.Resources.CustomFont); + HtmlRender.AddFontFamily(new Uri(file)); + } } } diff --git a/Source/Demo/WinUI/GenerateImageWindow.xaml b/Source/Demo/WinUI/GenerateImageWindow.xaml new file mode 100644 index 000000000..4b4420f1f --- /dev/null +++ b/Source/Demo/WinUI/GenerateImageWindow.xaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/GenerateImageWindow.xaml.cs b/Source/Demo/WinUI/GenerateImageWindow.xaml.cs new file mode 100644 index 000000000..ea25efc8b --- /dev/null +++ b/Source/Demo/WinUI/GenerateImageWindow.xaml.cs @@ -0,0 +1,130 @@ +// "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.Threading.Tasks; +using Microsoft.Graphics.Canvas; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Media.Imaging; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using TheArtOfDev.HtmlRenderer.WinUI; +using Windows.Foundation; +using Windows.Storage; +using Windows.Storage.Streams; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + public sealed partial class GenerateImageWindow : Window + { + private readonly string _html; + private CanvasRenderTarget _generatedImage; + + public GenerateImageWindow(string html) + { + _html = html; + + InitializeComponent(); + + WindowHelper.Setup(this, "Generate Image", 0.56, 0.64); + + _imageBoxBorder.SizeChanged += OnFirstSizeChanged; + Closed += (sender, args) => _generatedImage?.Dispose(); + } + + private async void OnFirstSizeChanged(object sender, SizeChangedEventArgs e) + { + _imageBoxBorder.SizeChanged -= OnFirstSizeChanged; + await GenerateImage(); + } + + private async void OnGenerateImage_Click(object sender, RoutedEventArgs e) + { + await GenerateImage(); + } + + private async void OnSaveToFile_Click(object sender, RoutedEventArgs e) + { + if (_generatedImage == null) + return; + + try + { + var picker = WindowHelper.CreateSavePicker(this); + picker.SuggestedFileName = "image"; + picker.DefaultFileExtension = ".png"; + picker.FileTypeChoices.Add("Images", new List { ".png", ".bmp", ".jpg", ".tif", ".gif" }); + + var file = await picker.PickSaveFileAsync(); + if (file == null) + return; + + using var stream = await file.OpenAsync(FileAccessMode.ReadWrite); + stream.Size = 0; + await _generatedImage.SaveAsync(stream, GetBitmapFileFormat(Path.GetExtension(file.Path))); + } + catch (Exception ex) + { + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to save the image"); + } + } + + private async Task GenerateImage() + { + var size = new Size(_imageBoxBorder.ActualWidth, _imageBoxBorder.ActualHeight); + if (size.Width > 0 && size.Height > 0) + { + try + { + _generatedImage?.Dispose(); + _generatedImage = await HtmlRender.RenderToImageAsync(_html, size, null, DemoUtils.OnStylesheetLoad, HtmlRenderingHelper.OnImageLoad); + + using var stream = new InMemoryRandomAccessStream(); + await _generatedImage.SaveAsync(stream, CanvasBitmapFileFormat.Png); + stream.Seek(0); + + var bitmap = new BitmapImage(); + await bitmap.SetSourceAsync(stream); + _imageBox.Source = bitmap; + } + catch (Exception ex) + { + _generatedImage = null; + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to generate the image"); + } + } + } + + /// + /// Get the Win2D format to encode the image with 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; + } + } + } +} diff --git a/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj index 14b61a3c3..725b35a20 100644 --- a/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj +++ b/Source/Demo/WinUI/HtmlRenderer.Demo.WinUI.csproj @@ -6,12 +6,8 @@ TheArtOfDev.HtmlRenderer.Demo.WinUI HtmlRendererWinUIDemo true - true true - + None true true @@ -23,8 +19,7 @@ html.ico - + @@ -34,12 +29,13 @@ + - + diff --git a/Source/Demo/WinUI/HtmlRenderingHelper.cs b/Source/Demo/WinUI/HtmlRenderingHelper.cs new file mode 100644 index 000000000..7f356fbca --- /dev/null +++ b/Source/Demo/WinUI/HtmlRenderingHelper.cs @@ -0,0 +1,143 @@ +// "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.Threading; +using Microsoft.Graphics.Canvas; +using PdfSharp.Drawing; +using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Demo.Common; +using Windows.Storage.Streams; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + internal static class HtmlRenderingHelper + { + private const string ByEventImageUrl = "https://images.unsplash.com/photo-1608848461950-0fe51dfc41cb?w=500&q=80"; + + /// + /// Cache for resource images, the images are loaded from the render threads as well + /// + private static readonly Dictionary _imageCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Get image by resource key. + /// + public static CanvasBitmap TryLoadResourceImage(string src) + { + lock (_imageCache) + { + if (!_imageCache.TryGetValue(src, out var image)) + { + using var stream = DemoUtils.GetImageStream(src); + if (stream != null) + { + image = CanvasBitmap.LoadAsync(CanvasDevice.GetSharedDevice(), stream.AsRandomAccessStream()).AsTask().GetAwaiter().GetResult(); + _imageCache[src] = image; + } + } + return image; + } + } + + /// + /// Get the resource image of the given key as data URI, to show the html in a browser. + /// + public static string TryGetResourceImageDataUri(string src) + { + using var stream = DemoUtils.GetImageStream(src); + if (stream == null) + return null; + + using var memory = new MemoryStream(); + stream.CopyTo(memory); + var bytes = memory.ToArray(); + var mimeType = bytes.Length > 3 && bytes[0] == 'G' && bytes[1] == 'I' && bytes[2] == 'F' ? "image/gif" : "image/png"; + return "data:" + mimeType + ";base64," + Convert.ToBase64String(bytes); + } + + /// + /// On image load in renderer set the image by event async. + /// + public static void OnImageLoad(object sender, HtmlImageLoadEventArgs args) + { + ImageLoad(args, false); + } + + /// + /// On image load in renderer set the image by event async. + /// + public static void OnImageLoadPdfSharp(object sender, HtmlImageLoadEventArgs args) + { + ImageLoad(args, true); + } + + /// + /// On image load in renderer set the image by event async. + /// + private static void ImageLoad(HtmlImageLoadEventArgs e, bool pdfSharp) + { + var img = TryLoadResourceImage(e.Src); + var imgObj = pdfSharp ? (object)(img != null ? CreatePdfSharpImage(img) : null) : img; + + if (!e.Handled && e.Attributes != null) + { + if (e.Attributes.ContainsKey("byevent")) + { + if (int.TryParse(e.Attributes["byevent"], out var delay)) + { + e.Handled = true; + ThreadPool.QueueUserWorkItem(state => + { + Thread.Sleep(delay); + e.Callback(ByEventImageUrl); + }); + } + else + { + e.Callback(ByEventImageUrl); + } + return; + } + + if (e.Attributes.ContainsKey("byrect")) + { + var split = e.Attributes["byrect"].Split(','); + var defaultResourceImage = TryLoadResourceImage("htmlicon"); + var defaultImg = pdfSharp && defaultResourceImage != null ? (object)CreatePdfSharpImage(defaultResourceImage) : defaultResourceImage; + e.Callback(imgObj ?? defaultImg, int.Parse(split[0]), int.Parse(split[1]), int.Parse(split[2]), int.Parse(split[3])); + return; + } + } + + if (img != null) + e.Callback(imgObj); + } + + /// + /// Convert the image to PNG before creating the , PdfSharp doesn't support GIF. + /// + private static XImage CreatePdfSharpImage(CanvasBitmap image) + { + using var stream = new InMemoryRandomAccessStream(); + image.SaveAsync(stream, CanvasBitmapFileFormat.Png).AsTask().GetAwaiter().GetResult(); + + stream.Seek(0); + var memory = new MemoryStream(); + stream.AsStreamForRead().CopyTo(memory); + memory.Position = 0; + return XImage.FromStream(memory); + } + } +} diff --git a/Source/Demo/WinUI/MainControl.xaml b/Source/Demo/WinUI/MainControl.xaml new file mode 100644 index 000000000..fbcef0e35 --- /dev/null +++ b/Source/Demo/WinUI/MainControl.xaml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/MainControl.xaml.cs b/Source/Demo/WinUI/MainControl.xaml.cs new file mode 100644 index 000000000..5e821f9cb --- /dev/null +++ b/Source/Demo/WinUI/MainControl.xaml.cs @@ -0,0 +1,357 @@ +// "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.Text; +using System.Text.RegularExpressions; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using TheArtOfDev.HtmlRenderer.Core.Entities; +using TheArtOfDev.HtmlRenderer.Demo.Common; + +namespace TheArtOfDev.HtmlRenderer.Demo.WinUI +{ + public sealed partial class MainControl : UserControl + { + #region Fields and Consts + + /// + /// the name of the tree node root for all performance samples + /// + private const string PerformanceSamplesTreeNodeName = "Performance Samples"; + + /// + /// The sample of each tree node, the node itself only shows the name + /// + private readonly Dictionary _nodeToSample = new Dictionary(); + + /// + /// timer to update the rendered html when html in editor changes with delay + /// + private readonly DispatcherQueueTimer _updateHtmlTimer; + + /// + /// The html last set to the editor by the code, to tell it apart from the user's changes + /// + private string _setEditorText; + + #endregion + + public MainControl() + { + InitializeComponent(); + + _htmlPanel.RenderError += OnRenderError; + _htmlPanel.LinkClicked += OnLinkClicked; + _htmlPanel.StylesheetLoad += DemoUtils.OnStylesheetLoad; + _htmlPanel.ImageLoad += HtmlRenderingHelper.OnImageLoad; + _htmlPanel.LoadComplete += (sender, args) => _htmlPanel.ScrollToElement("C4"); + + _htmlTooltipLabel.AvoidImagesLateLoading = true; + _htmlTooltipLabel.StylesheetLoad += DemoUtils.OnStylesheetLoad; + _htmlTooltipLabel.ImageLoad += HtmlRenderingHelper.OnImageLoad; + _htmlTooltipLabel.Text = "
" + Common.Resources.Tooltip + "
"; + + _updateHtmlTimer = DispatcherQueue.CreateTimer(); + _updateHtmlTimer.Interval = TimeSpan.FromSeconds(1); + _updateHtmlTimer.IsRepeating = false; + _updateHtmlTimer.Tick += OnUpdateHtmlTimerTick; + + LoadSamples(); + } + + /// + /// used ignore html editor updates when updating separately + /// + public bool UpdateLock { get; set; } + + /// + /// In browser view if to show original html or the html generated from the html control + /// + public bool UseGeneratedHtml { get; set; } + + /// + /// Show\Hide the web browser viewer. + /// + public void ShowWebBrowserView(bool show) + { + _webView.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + _viewsGrid.ColumnDefinitions[1].Width = show ? new GridLength(1, GridUnitType.Star) : new GridLength(0); + + if (show) + UpdateWebBrowserHtml(); + } + + /// + /// Update the html shown in the web browser + /// + public async void UpdateWebBrowserHtml() + { + if (_webView.Visibility == Visibility.Visible) + { + try + { + await _webView.EnsureCoreWebView2Async(); + _webView.NavigateToString(UseGeneratedHtml ? _htmlPanel.GetHtml() : GetFixedHtml()); + } + catch (Exception ex) + { + await WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to show the HTML in WebView2"); + } + } + } + + public string GetHtml() + { + return UseGeneratedHtml ? _htmlPanel.GetHtml() : GetHtmlEditorText(); + } + + /// + /// Set the html and lay it out right away, used to measure the performance. + /// + public void SetHtml(string html) + { + _htmlPanel.Text = html; + _htmlPanel.UpdateLayout(); + } + + #region Private methods + + /// + /// Loads the tree of document samples + /// + private void LoadSamples() + { + var showcaseRoot = AddRootNode("HTML Renderer", SamplesLoader.ShowcaseSamples); + AddRootNode("Test Samples", SamplesLoader.TestSamples); + if (SamplesLoader.PerformanceSamples.Count > 0) + AddRootNode(PerformanceSamplesTreeNodeName, SamplesLoader.PerformanceSamples); + + showcaseRoot.IsExpanded = true; + if (showcaseRoot.Children.Count > 0) + { + _samplesTreeView.SelectedNode = showcaseRoot.Children[0]; + ShowSample(showcaseRoot.Children[0]); + } + } + + private TreeViewNode AddRootNode(string name, List samples) + { + var root = new TreeViewNode { Content = name }; + _samplesTreeView.RootNodes.Add(root); + foreach (var sample in samples) + { + var node = new TreeViewNode { Content = sample.Name }; + _nodeToSample[node] = sample; + root.Children.Add(node); + } + return root; + } + + /// + /// On tree view node click load the html to the html panel and html editor. + /// + private void OnSamplesTreeView_SelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + { + if (args.AddedItems.Count > 0 && args.AddedItems[0] is TreeViewNode node) + ShowSample(node); + } + + private void ShowSample(TreeViewNode node) + { + if (!_nodeToSample.TryGetValue(node, out var sample)) + return; + + UpdateLock = true; + + // Coloring the large performance samples takes too long + SetEditorText(sample.Html, !Equals(node.Parent?.Content, PerformanceSamplesTreeNodeName) && _coloredCheckBox.IsChecked == true); + + try + { + _htmlPanel.AvoidImagesLateLoading = !sample.FullName.Contains("Many images"); + _htmlPanel.Text = sample.Html; + UpdateFixedHtmlView(); + } + catch (Exception ex) + { + SetFixedHtmlText(string.Empty, false); + _ = WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to render HTML"); + } + + UpdateLock = false; + + UpdateWebBrowserHtml(); + } + + /// + /// On text change in the html editor update + /// + private void OnHtmlEditor_TextChanged(object sender, RoutedEventArgs e) + { + // TextChanged is raised later, also for the text set by the code + if (!UpdateLock && GetHtmlEditorText() != _setEditorText) + { + _updateHtmlTimer.Stop(); + _updateHtmlTimer.Start(); + } + } + + /// + /// Update the html renderer with text from html editor. + /// + private void OnUpdateHtmlTimerTick(DispatcherQueueTimer sender, object args) + { + UpdateLock = true; + + try + { + _htmlPanel.Text = GetHtmlEditorText(); + UpdateFixedHtmlView(); + } + catch (Exception ex) + { + SetFixedHtmlText(string.Empty, false); + _ = WindowHelper.ShowMessageAsync(XamlRoot, ex.ToString(), "Failed to render HTML"); + } + + UpdateWebBrowserHtml(); + + UpdateLock = false; + } + + /// + /// Fix the raw html by replacing the resource keys of images and stylesheets with their data, + /// a page shown from a string can't load local files. + /// + /// fixed html + private string GetFixedHtml() + { + var html = GetHtmlEditorText(); + + html = Regex.Replace(html, @"src=\""(\w.*?)\""", match => + { + var dataUri = HtmlRenderingHelper.TryGetResourceImageDataUri(match.Groups[1].Value); + return dataUri != null ? string.Format("src=\"{0}\"", dataUri) : match.Value; + }, RegexOptions.IgnoreCase); + + html = Regex.Replace(html, @"href=\""(\w.*?)\""", match => + { + var stylesheet = DemoUtils.GetStylesheet(match.Groups[1].Value); + return stylesheet != null + ? string.Format("href=\"data:text/css;base64,{0}\"", Convert.ToBase64String(Encoding.UTF8.GetBytes(stylesheet))) + : match.Value; + }, RegexOptions.IgnoreCase); + + return html; + } + + /// + /// Update the fixed html shown in the read-only editor. + /// + private void UpdateFixedHtmlView() + { + SetFixedHtmlText(HtmlPrettyPrinter.Format(_htmlPanel.GetHtml() ?? string.Empty), _coloredCheckBox.IsChecked == true); + } + + /// + /// Set formatted HTML text in the fixed HTML viewer. + /// + private void SetFixedHtmlText(string text, bool color) + { + // The document of a read-only box can't be changed + _fixedHtmlEditor.IsReadOnly = false; + SetRtf(_fixedHtmlEditor, text ?? string.Empty, color); + _fixedHtmlEditor.IsReadOnly = true; + } + + /// + /// Reload the html shown in the html editor by running coloring again. + /// + private void OnRefresh_Click(object sender, RoutedEventArgs e) + { + SetEditorText(GetHtmlEditorText(), true); + } + + /// + /// Toggle if the html syntax is colored. + /// + private void OnColored_Click(object sender, RoutedEventArgs e) + { + SetEditorText(GetHtmlEditorText(), _coloredCheckBox.IsChecked == true); + UpdateFixedHtmlView(); + } + + /// + /// Show error raised from html renderer. + /// + private void OnRenderError(object sender, HtmlRenderErrorEventArgs args) + { + _ = WindowHelper.ShowMessageAsync(XamlRoot, args.Message + (args.Exception != null ? "\r\n" + args.Exception : null), "Error in Html Renderer"); + } + + /// + /// On specific link click handle it here. + /// + private void OnLinkClicked(object sender, HtmlLinkClickedEventArgs args) + { + if (args.Link == "SayHello") + { + _ = WindowHelper.ShowMessageAsync(XamlRoot, "Hello you!", "Hello"); + args.Handled = true; + } + else if (args.Link == "ShowSampleForm") + { + new SampleWindow().Activate(); + args.Handled = true; + } + } + + /// + /// Set the html in the editor without triggering an update of the rendered html. + /// + private void SetEditorText(string text, bool color) + { + SetRtf(_htmlEditor, text, color); + _setEditorText = GetHtmlEditorText(); + } + + /// + /// Set html syntax colored text, uncolored text goes through the highlighter as well to escape it for RTF. + /// + private static void SetRtf(RichEditBox editor, string text, bool color) + { + var black = System.Drawing.Color.Black; + var rtf = color ? HtmlSyntaxHighlighter.Process(text) : HtmlSyntaxHighlighter.Process(text, black, black, black, black, black, black); + + // RichEditBox shows the RTF as text without a version and charset in the header + if (rtf.StartsWith(@"{\rtf{", StringComparison.Ordinal)) + rtf = @"{\rtf1\ansi" + rtf.Substring(5); + + editor.Document.SetText(TextSetOptions.FormatRtf, rtf); + } + + /// + /// Get the html text from the html editor control. + /// + private string GetHtmlEditorText() + { + _htmlEditor.Document.GetText(TextGetOptions.UseCrlf, out var text); + return text; + } + + #endregion + } +} diff --git a/Source/Demo/WinUI/MainWindow.xaml b/Source/Demo/WinUI/MainWindow.xaml index 44b3b4aea..59792b615 100644 --- a/Source/Demo/WinUI/MainWindow.xaml +++ b/Source/Demo/WinUI/MainWindow.xaml @@ -2,19 +2,29 @@ x:Class="TheArtOfDev.HtmlRenderer.Demo.WinUI.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:winui="using:TheArtOfDev.HtmlRenderer.WinUI"> - + xmlns:local="using:TheArtOfDev.HtmlRenderer.Demo.WinUI"> - - - - - - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/Demo/WinUI/MainWindow.xaml.cs b/Source/Demo/WinUI/MainWindow.xaml.cs index 8fb7304d4..853a6572d 100644 --- a/Source/Demo/WinUI/MainWindow.xaml.cs +++ b/Source/Demo/WinUI/MainWindow.xaml.cs @@ -11,176 +11,118 @@ // "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 PdfSharp; using TheArtOfDev.HtmlRenderer.Demo.Common; -using Windows.Storage.Streams; +using TheArtOfDev.HtmlRenderer.PdfSharp; +using Windows.ApplicationModel.DataTransfer; 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"; - - // Unlike the WPF/WinForms demo windows, a WinUI 3 window does not pick up the executable - // icon stamped by the csproj ApplicationIcon property, so the title bar and taskbar show the - // generic default until AppWindow is pointed at the .ico explicitly. BaseDirectory rather - // than a relative path: SetIcon resolves against the working directory, which is not the app - // directory when the demo is launched from elsewhere. - AppWindow.SetIcon(Path.Combine(AppContext.BaseDirectory, "html.ico")); - - 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(); + WindowHelper.Setup(this, "HTML Renderer WinUI Demo", 0.7, 0.8); } - private void LoadSamples() + /// + /// Open sample window. + /// + private void OnOpenSampleWindow_Click(object sender, RoutedEventArgs e) { - 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]); - } + new SampleWindow().Activate(); } - private void AddSampleNode(TreeViewNode root, HtmlSample sample) + /// + /// Toggle if to show split view of HtmlPanel and WebView2 control. + /// + private void OnShowBrowserView_Click(object sender, RoutedEventArgs e) { - var node = new TreeViewNode { Content = sample.Name }; - _nodeToSample[node] = sample; - root.Children.Add(node); + _mainControl.ShowWebBrowserView(_showBrowserView.IsChecked == true); } - private void OnSamplesTreeViewSelectionChanged(TreeView sender, TreeViewSelectionChangedEventArgs args) + /// + /// Open the current html in the default browser. + /// + private void OnOpenExternal_Click(object sender, RoutedEventArgs e) { - foreach (var node in sender.SelectedNodes) - { - if (_nodeToSample.TryGetValue(node, out var sample)) - { - ShowSample(sample); - break; - } - } + var tmpFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".htm"); + File.WriteAllText(tmpFile, _mainControl.GetHtml()); + Process.Start(new ProcessStartInfo(tmpFile) { UseShellExecute = true }); } - private void ShowSample(HtmlSample sample) + /// + /// Toggle the use generated html button state. + /// + private void OnUseGeneratedHtml_Click(object sender, RoutedEventArgs e) { - try - { - RendererPanel.AvoidImagesLateLoading = !sample.FullName.Contains("Many images"); - RendererPanel.Text = sample.Html; - } - catch (Exception ex) - { - Debug.WriteLine(ex); - } + _mainControl.UseGeneratedHtml = _useGeneratedHtml.IsChecked == true; + _mainControl.UpdateWebBrowserHtml(); } /// - /// 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. + /// Open generate image window for the current html. /// - private void OnImageLoad(object sender, HtmlImageLoadEventArgs e) + private void OnGenerateImage_Click(object sender, RoutedEventArgs e) { - var stream = DemoUtils.GetImageStream(e.Src); - if (stream == null) - return; - - e.Handled = true; - _ = LoadIconAsync(e, stream); + new GenerateImageWindow(_mainControl.GetHtml()).Activate(); } - private static async Task LoadIconAsync(HtmlImageLoadEventArgs e, Stream stream) + /// + /// Create PDF using PdfSharp project, save to file and open that file. + /// + private async void OnGeneratePdf_Click(object sender, RoutedEventArgs e) { try { - byte[] bytes; - using (stream) - using (var memory = new MemoryStream()) - { - await stream.CopyToAsync(memory).ConfigureAwait(false); - bytes = memory.ToArray(); - } + var config = new PdfGenerateConfig(); + config.PageSize = PageSize.A4; + config.SetMargins(20); - using var randomAccessStream = new InMemoryRandomAccessStream(); - using (var writer = new DataWriter(randomAccessStream.GetOutputStreamAt(0))) + var html = _mainControl.GetHtml(); + + // Off the UI thread, as the image conversion blocks on Win2D + var pdfFile = await Task.Run(async () => { - 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); + var doc = await PdfGenerator.GeneratePdf(html, config, null, DemoUtils.OnStylesheetLoad, HtmlRenderingHelper.OnImageLoadPdfSharp); + var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".pdf"); + doc.Save(file); + return file; + }); + + Process.Start(new ProcessStartInfo(pdfFile) { UseShellExecute = true }); } catch (Exception ex) { - Debug.WriteLine(ex); - e.Callback(); + await WindowHelper.ShowMessageAsync(Content.XamlRoot, ex.ToString(), "Failed to generate PDF"); } } + + /// + /// Execute performance test by setting all sample HTMLs in a loop. + /// + private async void OnRunPerformance_Click(object sender, RoutedEventArgs e) + { + _mainControl.UpdateLock = true; + _toolBar.IsEnabled = false; + + // There's no paint in between, unlike WinForms and WPF, WinUI has no way to pump the messages + var msg = DemoUtils.RunSamplesPerformanceTest(_mainControl.SetHtml); + + var dataPackage = new DataPackage(); + dataPackage.SetText(msg); + Clipboard.SetContent(dataPackage); + + await WindowHelper.ShowMessageAsync(Content.XamlRoot, msg, "Test run results"); + + _mainControl.UpdateLock = false; + _toolBar.IsEnabled = true; + } } } diff --git a/Source/Demo/WinUI/SampleWindow.xaml b/Source/Demo/WinUI/SampleWindow.xaml new file mode 100644 index 000000000..8582913cf --- /dev/null +++ b/Source/Demo/WinUI/SampleWindow.xaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +