Masui Masanori
Posted on February 5, 2022
Intro
In this time, I will save line charts as images with WPF and WebView2.
- 【.NET 5】【WPF】Try WebView2
- 【.NET 5】【WPF】Try MVVM
- [C3.js][TypeScript] Draw line charts 1
- [C3.js][TypeScript] Draw line charts 2
- [C3.js][TypeScript] Save C3.js charts as images
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>
[Client] main.page.ts
export function generate(): void {
// Call WPF codes.
chrome.webview.postMessage("hello world!");
}
[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()");
}
}
}
[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());
}
}
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,
}
- Get started with WebView2 in WPF apps - Microsoft Docs
- CoreWebView2.WebMessageReceived Event - Microsoft Docs
- Chromium WebView2 Control and .NET to JavaScript Interop - Part 2 - Rick Strahl's Web Log
- [WebView2] ScriptからHostObjectを呼び出す / How to call HostObject from Script - Qiita
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)
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);
}
[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,
};
[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);
}
}
[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()");
}
}
}
[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);
};
}
}
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);
}
Confirm
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);
}
...
}
[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);
}
}
...
}
Posted on February 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.