DEV Community

Masui Masanori
Masui Masanori

Posted on

[WPF][WebView2] Save line charts as images

Intro

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

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

<!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>
Enter fullscreen mode Exit fullscreen mode

[Client] main.page.ts

export function generate(): void {
    // Call WPF codes.
    chrome.webview.postMessage("hello world!");
}
Enter fullscreen mode Exit fullscreen mode

[WPF] MainWindow.xaml.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()");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

[WPF] MainViewModel.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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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

declare namespace chrome {
    export const webview: WebView;
}
interface WebView {
    postMessage: (message: string) => void,
}
Enter fullscreen mode Exit fullscreen mode

Handle download events

WPF can control downloading from the client side.

In this time, I create an image like below.

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)
Enter fullscreen mode Exit fullscreen mode

So I must set CSS value by myself.

[Client] main.page.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);
}
Enter fullscreen mode Exit fullscreen mode

[Client] chart.type.ts

export enum LineType {
    default = 0,
    dashedLine,
}
export type ChartValues = {
    type: LineType,
    values: readonly SampleValue[],
};
export type SampleValue = {
    x: number,
    y: number,
};
Enter fullscreen mode Exit fullscreen mode

[Client] chartViewer.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);
    }
}
Enter fullscreen mode Exit fullscreen mode

[WPF] MainWindow.xaml.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()");
        }       
    }
}
Enter fullscreen mode Exit fullscreen mode

[WPF] MainViewModel.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);
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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);
}
Enter fullscreen mode Exit fullscreen mode

Confirm

Image description

I think this is because the security of Web browser.

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

[Client] chartViewer.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);
    }    
...
}
Enter fullscreen mode Exit fullscreen mode

[WPF] MainViewModel.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);
        }
    }
...
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)