DEV Community

Masui Masanori
Masui Masanori

Posted on

1 1

[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

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

Top comments (0)

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay