[WPF][WebView2] Save line charts as images
Tags: csharp, wpf, webview2, c3js
Published: 2022-02-05 03:55:02

## Intro
In this time, I will save line charts as images with WPF and WebView2.

* [【.NET 5】【WPF】Try WebView2](https://dev.to/masanori_msl/net-5-wpf-try-webview2-41kn)
* [【.NET 5】【WPF】Try MVVM](https://dev.to/masanori_msl/net-5-wpf-try-mvvm-3m0e)
* [[C3.js][TypeScript] Draw line charts 1](https://dev.to/masanori_msl/c3jstypescript-draw-line-charts-1-1g0)
* [[C3.js][TypeScript] Draw line charts 2](https://dev.to/masanori_msl/c3jstypescript-draw-line-charts-2-a31)
* [[C3.js][TypeScript] Save C3.js charts as images](https://dev.to/masanori_msl/c3jstypescript-save-c3js-charts-as-images-1b3f)

### Environments
* .NET ver.6.0.101
* NLog ver.4.7.13
* Microsoft.Extensions.DependencyInjection ver.6.0.0
* Microsoft.Xaml.Behaviors.Wpf ver.1.1.39
* Newtonsoft.Json ver.13.0.1
* Microsoft.Web.WebView2 ver.1.0.1072.54

## Call WPF from client side(TypeScript)

### chrome.webview.postMessage
I can call WPF codes from client side(TypeScript) by "chrome.webview.postMessage".

#### [Client] main.html
```html
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Chart Image Generator</title>
        <meta charset="utf-8">
        <link rel="stylesheet" href="css/c3.css" />
        <link rel="stylesheet" href="css/chart.page.css" />
    </head>
    <body>
        <div id="chart_root"></div>
        <a id="download_target"></a>
        <script src="js/main.page.js"></script>
    </body>
</html>
```

#### [Client] main.page.ts
```ts
export function generate(): void {
    // Call WPF codes.
    chrome.webview.postMessage("hello world!");
}
```

#### [WPF] MainWindow.xaml.cs
```cs
using System.Windows;
using ChartImageGenerator.Main;
using Microsoft.Web.WebView2.Core;

namespace ChartImageGenerator
{
    public partial class MainWindow : Window
    {
        private readonly MainViewModel viewModel;
        public MainWindow(MainViewModel viewModel)
        {
            DataContext = viewModel;
            this.viewModel = viewModel;
            InitializeComponent();
            InitializeAsync();
        }
        private async void InitializeAsync()
        {
            await webView.EnsureCoreWebView2Async(null);           
            webView.NavigationCompleted += this.OnNavigationCompleted;
            webView.CoreWebView2.WebMessageReceived += this.viewModel.OnMessageReceived;
        }
        private async void OnNavigationCompleted(object? sender,
            CoreWebView2NavigationCompletedEventArgs args)
        {
            await webView.CoreWebView2.ExecuteScriptAsync("Page.generate()");
        }
    }
}
```
#### [WPF] MainViewModel.cs
```cs
using Microsoft.Web.WebView2.Core;
using NLog;

namespace ChartImageGenerator.Main;
public class MainViewModel
{
    public string PageSrc { get; set; }

    private readonly Logger logger;
    public MainViewModel()
    {
        this.logger = LogManager.GetCurrentClassLogger();
        this.PageSrc = "C:/Users/example/OneDrive/Documents/workspace/ChartImageGenerator/Client/main.html";
    }
    public void OnMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
    {
        this.logger.Debug(args.TryGetWebMessageAsString());
    }
}
```

#### Add types

One problem is a compiling error in TypeScript code because "window" doesn't have "chrome" by default.
Although I install "@types/chrome", "chrome" also doesn't have "webview".

So I add types by manually.

#### types/global.d.ts
```ts
declare namespace chrome {
    export const webview: WebView;
}
interface WebView {
    postMessage: (message: string) => void,
}
```

* [Get started with WebView2 in WPF apps - Microsoft Docs](https://docs.microsoft.com/en-us/microsoft-edge/webview2/get-started/wpf)
* [CoreWebView2.WebMessageReceived Event - Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2.webmessagereceived?view=webview2-dotnet-1.0.1072.54)
* [Chromium WebView2 Control and .NET to JavaScript Interop - Part 2 - Rick Strahl's Web Log](https://weblog.west-wind.com/posts/2021/Jan/26/Chromium-WebView2-Control-and-NET-to-JavaScript-Interop-Part-2)
* [[WebView2] ScriptからHostObjectを呼び出す / How to call HostObject from Script - Qiita](https://qiita.com/trumpet_developer/items/f1c8649a5bbb24dbd6a9)

### Handle download events
WPF can control downloading from the client side.

In this time, I create an image like below.

* [[C3.js][TypeScript] Save C3.js charts as images](https://dev.to/masanori_msl/c3jstypescript-save-c3js-charts-as-images-1b3f)

One problem is I can't get "cssRules" because the client side codes aren't worked on web server.

```
Uncaught DOMException: Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules
    at ChartViewer.setLineStyles (webpack://Page/./ts/charts/chartViewer.ts?:134:40)
    at ChartViewer.saveImage (webpack://Page/./ts/charts/chartViewer.ts?:72:14)
    at eval (webpack://Page/./ts/main.page.ts?:20:14)
```

So I must set CSS value by myself.

#### [Client] main.page.ts
```ts
import { LineType } from "./charts/chart.type";
import { ChartViewer } from "./charts/chartViewer";

let view: ChartViewer;
export function generate(): void {
    const chartRoot = document.getElementById("chart_root") as HTMLElement;
    view = new ChartViewer(chartRoot);
    view.draw({
        type: LineType.default,
        values: [{ x: 0.1, y: 0 },
            { x: 1, y: 1.2 },
            { x: 3.2, y: 2.5 },
            { x: 5.6, y: 6.7 },
            { x: 7, y: 7.8 },
            { x: 8.0 , y: 9 }]
    });
    setTimeout(() => {
        view.saveImage();
    }, 100);
}
```
#### [Client] chart.type.ts
```ts
export enum LineType {
    default = 0,
    dashedLine,
}
export type ChartValues = {
    type: LineType,
    values: readonly SampleValue[],
};
export type SampleValue = {
    x: number,
    y: number,
};
```

#### [Client] chartViewer.ts
```ts
import c3 from "c3";
import { ChartValues } from "./chart.type";

export class ChartViewer {
    private chartElement: HTMLElement;
    
    public constructor(root: HTMLElement) {
        this.chartElement = document.createElement("div");
        root.appendChild(this.chartElement);
    }
    public draw(value: ChartValues): void {
        const valueXList = this.getValueX(0, 10);
        const ticksX = this.getTicks(valueXList);
        const valueYList = value.values.map(v => v.y);
        const gridLineY = valueYList.map(t => this.generateGridLine(t));
        const gridLines = valueXList.map(t => this.generateGridLine(t));
        c3.generate({
            bindto: this.chartElement,
            data: {
                x: "x",
                columns: [
                    ["data1", ...value.values.map(v => v.y)],
                    ["x", ...value.values.map(v => v.x)],
                ],
                types: {
                    data1: "line"
                },
            },
            axis: {
                x: {
                    min: 0,
                    max: 10,
                    tick: {
                        values: [...ticksX],
                        outer: false,
                    },
                    padding: { left: 0, }
                },
                y: {
                    min: 0,
                    padding: { bottom: 0, }
                }
            },
            grid: {
                x: {
                    show: false,
                    lines: [...gridLines],
                },
                y: {
                    show: false,
                    lines: [...gridLineY],
                }
            },
            interaction: {
                enabled: false,
            },
        });
    }
    public saveImage(): void {
        const svg = this.getSvgRoot();
        if(svg == null) {
            chrome.webview.postMessage("svg was null");            
            return;
        }
        this.setLineStyles(svg, "solid_line", false);
        this.setLineStyles(svg, "dashed_line", true);

        const chartPaths = svg.querySelectorAll(".c3-chart path");
        for(let i = 0; i < chartPaths.length; i++) {
            const path: any = chartPaths[i];
            if(this.hasStyle(path)) {
                path.style.fill = "none";
                path.style.stroke = "green";
            }
        }
        const lines = svg.querySelectorAll(".c3-axis line");
        const nodes = svg.querySelectorAll(".c3-axis path");
        const gridLines = Array.from(nodes).concat(Array.from(lines));
        for(let i = 0; i < gridLines.length; i++) {
            const line: any = gridLines[i];
            if(this.hasStyle(line)) {
                line.style.fill = "none";
                line.style.stroke = "red";
            }
        }
        const serializedImage = new XMLSerializer().serializeToString(svg);
        const image = new Image();
        image.onload = () => {
            const canvas = document.createElement("canvas");
            canvas.width = this.chartElement.clientWidth;
            canvas.height = this.chartElement.clientHeight;
            const ctx = canvas.getContext("2d");
            if(ctx == null) {
                chrome.webview.postMessage("ctx was null");
                return;
            }
            ctx.drawImage(image, 0, 0);
            canvas.toBlob((b) => {
                if(b == null) {
                    chrome.webview.postMessage("faild creating blob");
                    return;
                }
                const downloadTarget = document.getElementById("download_target") as HTMLAnchorElement;
                downloadTarget.href = URL.createObjectURL(b);
                downloadTarget.download = "sample.png";
                downloadTarget.click();
            }, "image/png");
        };
        image.src = "data:image/svg+xml;charset=utf-8;base64," + window.btoa(serializedImage);
    }
    private setLineStyles(svg: SVGElement, targetName: string, dashedLine: boolean): void {
        const lineElements = svg.querySelectorAll(`.c3 .${targetName} line`);
        if(lineElements.length <= 0) {
            return;
        }
        for(let k = 0; k < lineElements.length; k++) {
            const line = lineElements[k] as any;
            if(line == null) {
                continue;
            }
            if(this.hasStyle(line)) {
                line.style.stroke = "#000000";
                line.style.strokeDasharray = (dashedLine)? "2 5": "1 0";
                line.style.strokeLinecap = "round";
            }
        }
    }
    private getValueX(from: number, to: number): readonly number[] {
        const results: number[] = [];
        for(let i = from; i <= to; i++) {
            if(i < to) {
                for(let j = 0.0; j < 1.0; j += 0.1) {
                    results.push(i + j);
                }
            }
        }
        return results;
    }
    private getTicks(values: readonly number[]): readonly string[] {
        const results: string[] = [];
        for(const v of values) {
            if(v === (Math.trunc(v))) {
                results.push(v.toString());
            } else {
                results.push("");
            }    
        }
        return results;
    }
    private generateGridLine(value: number): { value: string, class: string } {
        let lineClass = "";
        if(value === (Math.trunc(value))) {
            lineClass = "solid_line";
        } else {
            lineClass = "dashed_line";
        }
        return {
            value: value.toString(),
            class: lineClass,
        };
    }
    private getSvgRoot(): SVGElement|null {
        for(let i = 0; i < this.chartElement.children.length; i++) {
            if(this.chartElement.children[0] == null) {
                continue;
            }
            if(this.chartElement.children[0].tagName === "svg") {
                return this.chartElement.children[0] as SVGElement;
            }            
        }
        return null;
    }
    private hasStyle(obj: any): obj is { style: CSSStyleDeclaration } {
        if((obj instanceof Object &&
            "style" in obj) === false ) {
            return false;
        }
        return (obj.style instanceof CSSStyleDeclaration);
    }
}
```

#### [WPF] MainWindow.xaml.cs
```cs
...
namespace ChartImageGenerator
{
    public partial class MainWindow : Window
    {
        private readonly MainViewModel viewModel;
        public MainWindow(MainViewModel viewModel)
        {
            DataContext = viewModel;
            this.viewModel = viewModel;
            InitializeComponent();
            InitializeAsync();
        }
        private async void InitializeAsync()
        {
            await webView.EnsureCoreWebView2Async(null);
            
            webView.NavigationCompleted += this.OnNavigationCompleted;
            webView.CoreWebView2.WebMessageReceived += this.viewModel.OnMessageReceived;
            webView.CoreWebView2.DownloadStarting += this.viewModel.OnDownloadStarted;
        }
        private async void OnNavigationCompleted(object? sender,
            CoreWebView2NavigationCompletedEventArgs args)
        {
            await webView.CoreWebView2.ExecuteScriptAsync("Page.generate()");
        }       
    }
}
```

#### [WPF] MainViewModel.cs
```cs
using Microsoft.Web.WebView2.Core;
using NLog;

namespace ChartImageGenerator.Main;
public class MainViewModel
{
    public string PageSrc { get; set; }

    private readonly Logger logger;
    public MainViewModel()
    {
        this.logger = LogManager.GetCurrentClassLogger();
        this.PageSrc = "C:/Users/example/OneDrive/Documents/workspace/ChartImageGenerator/Client/main.html";
    }
    public void OnMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
    {
        this.logger.Debug(args.TryGetWebMessageAsString());
    }
    public void OnDownloadStarted(object? sender,
            CoreWebView2DownloadStartingEventArgs args)
    {
        // I can't use "/" to separate directory.
        args.ResultFilePath = @"C:\Users\example\OneDrive\Documents\workspace\sample.png";
        var operation = args.DownloadOperation;
        operation.StateChanged += (sender, args) =>
        {
            // Completed | InProgress | Interrupted
            this.logger.Debug(operation.State);
        };
    }
}
```

* [CoreWebView2DownloadOperation Class - Microsoft Docs](https://docs.microsoft.com/en-us/microsoft-edge/webview2/reference/winrt/microsoft_web_webview2_core/corewebview2downloadoperation?view=webview2-winrt-1.0.1072.54)
* [WebView2 Download progress - Stack Overflow](https://stackoverflow.com/questions/67537998/webview2-download-progress)

### Multiple download
When I downloaded only one file, I didn't have any problem.
But if I tried two or more files, the webview2 show a confirm dialog.

#### [Client] main.page.ts
```ts
import { LineType } from "./charts/chart.type";
import { ChartViewer } from "./charts/chartViewer";

let index = 0;
export function generate(): void {
    const chartRoot = document.getElementById("chart_root") as HTMLElement;
    generateImage(chartRoot);
}
function generateImage(root: HTMLElement) {
    const view = new ChartViewer(root);
    view.draw({
        type: LineType.default,
        values: [{ x: 0.1, y: 0 },
            { x: 1, y: 1.2 },
            { x: 3.2, y: 2.5 },
            { x: 5.6, y: 6.7 },
            { x: 7, y: 7.8 },
            { x: 8.0 , y: 9 }]
    });
    setTimeout(() => {
        view.saveImage();
        if(index < 3) {
            index += 1;
            generateImage(root);
        }
    },
    100);
}
```

#### Confirm
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w2tcfhhnrhiqfyd95enq.png)

I think this is because the security of Web browser.

So I send the data as binary through "postMessage".

#### [Client] chartViewer.ts
```ts
...
export class ChartViewer {
...
    public saveImage(index: number): void {
...
        const serializedImage = new XMLSerializer().serializeToString(svg);
        const image = new Image();
        image.onload = () => {
            const canvas = document.createElement("canvas");
            canvas.width = this.chartElement.clientWidth;
            canvas.height = this.chartElement.clientHeight;
            const ctx = canvas.getContext("2d");
            if(ctx == null) {
                chrome.webview.postMessage("ctx was null");
                return;
            }
            ctx.drawImage(image, 0, 0);
            canvas.toBlob(async (b) => {
                if(b == null) {
                    chrome.webview.postMessage("faild creating blob");
                    return;
                }
                const buffer = new Uint8Array(await b.arrayBuffer());
                // send image data as binary data
                chrome.webview.postMessage(`sample_${index}.png|${buffer.toString()}`);
            }, "image/png");
        };
        image.src = "data:image/svg+xml;charset=utf-8;base64," + window.btoa(serializedImage);
    }    
...
}
```

#### [WPF] MainViewModel.cs
```cs
using System.IO;
using Microsoft.Web.WebView2.Core;
using NLog;

namespace ChartImageGenerator.Main;
public class MainViewModel
{
...
    public void OnMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args)
    {
        var splittedNameData = args.TryGetWebMessageAsString().Split("|");
        if(splittedNameData.Length < 2)
        {
            this.logger.Debug(splittedNameData[0]);
            return;
        }
        // file data is like "137,80,78,71,13,10,..."
        var splittedAsNumbers = splittedNameData[1].Split(",");
        var fileData = new byte[splittedAsNumbers.Length];
        for(var i = 0; i < splittedAsNumbers.Length; i++)
        {
            if(byte.TryParse(splittedAsNumbers[i], out var parsedData))
            {
                fileData[i] = parsedData;
            }
            else
            {
                this.logger.Error("Failed parsing file data");
                return;
            }
        }
        if(fileData.Length <= 0)
        {
            return;
        }
        using(var stream = new FileStream(splittedNameData[0], FileMode.Create))
        {
            stream.Write(fileData, 0, fileData.Length);
        }
    }
...
}
```