Image processing (NW.js) - chung-leong/zigar GitHub Wiki
JavaScript | PHP
In this example we're going to build a dekstop app that resizes an image and applies a filter it. We'll first implement just the user interface and the image loading/saving functionalities. After getting the basics working, we'll bring in our Zig code.
We begin by initializing the project:
mkdir image
cd image
npm init -y
npm install node-zigar
mkdir src img zigThen we add index.html to the src directory:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Image filter</title>
<link rel="stylesheet" href="index.css">
</head>
<body>
<div class="App">
<input id="fileOpen" type="file" class="hidden" accept="image/*">
<input id="fileSave" type="file" class="hidden" accept="image/*" nwsaveas>
<div class="contents">
<div class="pane align-right">
<canvas id="srcCanvas"></canvas>
<div class="controls">
Size: <input id="sizeInput" type="range" min="0.1" max="2" step="0.001" value="0.5">
</div>
</div>
<div class="pane align-left">
<canvas id="dstCanvas"></canvas>
</div>
</div>
</div>
</body>
</html>Basically, we have two HTML canvases in our app, one for displaying the original image and the other the outcome. There's also a range input for changing the size of the resulting image.
There're also two hidden file input. We use them to open the "Open" and "Save" file selection dialog box.
Save the app code to index.js:
const { writeFile } = require('fs/promises');
const { resolve } = require('path');
const { pathToFileURL } = require('url');
const isMac = process.platform === 'darwin'
nw.Window.open('./src/index.html', { width: 800, height: 600, x: 10, y: 10 }, (browser) => {
// handle menu click
const onOpenClick = () => {
const { window: { document } } = browser;
document.getElementById('fileOpen').click();
};
const onSaveClick = () => {
const { window: { document } } = browser;
document.getElementById('fileSave').click();
};
const onCloseClick = () => {
browser.close();
};
// create menu bar
const menuBar = new nw.Menu({ type: 'menubar' });
const fileMenu = new nw.Menu();
fileMenu.append(new nw.MenuItem({ label: 'Open', click: onOpenClick }));
fileMenu.append(new nw.MenuItem({ label: 'Save', click: onSaveClick }));
fileMenu.append(new nw.MenuItem({ type: 'separator' }));
fileMenu.append(new nw.MenuItem({ label: (isMac) ? 'Close' : 'Quit', click: onCloseClick }));
menuBar.append(new nw.MenuItem({ label: 'File', submenu: fileMenu }));
browser.menu = menuBar;
browser.window.onload = async () => {
// find page elements
const { window: { document } } = browser;
const fileOpen = document.getElementById('fileOpen');
const fileSave = document.getElementById('fileSave');
const srcCanvas = document.getElementById('srcCanvas');
const dstCanvas = document.getElementById('dstCanvas');
const sizeInput = document.getElementById('sizeInput');
// attach event handlers
fileOpen.onchange = async (evt) => {
const { target: { files: [ file ] } } = evt;
if (file) {
await loadImage(file.path);
}
};
fileSave.onchange = async (evt) => {
const { target: { files: [ file ] } } = evt;
if (file) {
await saveImage(file.path, file.type);
// clear value so onchange is fired again when the same file is selected
evt.target.value = '';
}
};
sizeInput.oninput = () => changeImage();
// load sample image
const path = resolve(__dirname, './img/sample.png');
await loadImage(path);
async function loadImage(path) {
const url = pathToFileURL(path);
const img = new Image;
img.src = url;
// img.decode() doesn't work for some reason
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const bitmap = await createImageBitmap(img);
srcCanvas.width = bitmap.width;
srcCanvas.height = bitmap.height;
const ctx = srcCanvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(bitmap, 0, 0);
changeImage();
}
function changeImage() {
const srcCTX = srcCanvas.getContext('2d', { willReadFrequently: true });
const srcImageData = srcCTX.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
const dstImageData = srcImageData;
dstCanvas.width = dstImageData.width;
dstCanvas.height = dstImageData.height;
const dstCTX = dstCanvas.getContext('2d');
dstCTX.putImageData(dstImageData, 0, 0);
}
async function saveImage(path, type) {
const blob = await new Promise((resolve, reject) => {
const callback = (result) => {
if (result) {
resolve(result);
} else {
reject(new Error('Unable to encode image'));
}
};
dstCanvas.toBlob(callback, type)
});
const buffer = await blob.arrayBuffer();
await writeFile(path, new DataView(buffer));
}
};
});The code above should be largely self-explanatory if you've worked with HTML canvas before.
Create index.css in src:
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
display: flex;
flex-direction: column;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
flex: 1 1 100%;
width: 100%;
}
.App {
display: flex;
position: relative;
flex-direction: column;
width: 100%;
height: 100%;
}
.App .nav {
position: fixed;
width: 100%;
color: #000000;
background-color: #999999;
font-weight: bold;
flex: 0 0 auto;
padding: 2px 2px 1px 2px;
}
.App .nav .button {
padding: 2px;
cursor: pointer;
}
.App .nav .button:hover {
color: #ffffff;
background-color: #000000;
padding: 2px 10px 2px 10px;
}
.App .contents {
display: flex;
width: 100%;
margin-top: 1em;
}
.App .contents .pane {
flex: 1 1 50%;
padding: 5px 5px 5px 5px;
}
.App .contents .pane CANVAS {
border: 1px dotted rgba(255, 255, 255, 0.10);
max-width: 100%;
max-height: 90vh;
}
.App .contents .pane .controls INPUT {
vertical-align: middle;
width: 50%;
}
@media screen and (max-width: 600px) {
.App .contents {
flex-direction: column;
}
.App .contents .pane {
padding: 1px 2px 1px 2px;
}
.App .contents .pane .controls {
padding-left: 4px;
}
}
.hidden {
position: absolute;
visibility: hidden;
z-index: -1;
}
.align-left {
text-align: left;
}
.align-right {
text-align: right;
}We need to adjust main in package.json:
"main": "src/index.js",We also need a sample image. Either download the following or choose one of your own:

Save it in img.
Now we're ready to go:
You should see something like this:

Moving the slider won't do anything but image loading and saving should work.
Okay, we'll now put in our Zig code. In the sub-directory zig, create process.zig:
const std = @import("std");
const zigar = @import("zigar");
pub fn scale(image_in: zigar.image.Any(.ro), image_out: zigar.image.Any(.rw)) void {
const Pixel = @Vector(4, f32);
inline for (zigar.image.formats) |tag| {
if (image_in == tag and image_out == tag) {
const in = image_in.getField(tag);
const out = image_out.getField(tag);
const x_adv: f32 = in.getWidthAsFloat() / out.getWidthAsFloat();
const y_adv: f32 = in.getHeightAsFloat() / out.getHeightAsFloat();
var coord: @Vector(2, f32) = undefined;
coord[1] = 0.5;
for (0..out.getHeight()) |y| {
coord[0] = 0.5;
for (0..out.getWidth()) |x| {
const pixel = in.sampleLinear(Pixel, coord);
out.setPixel(Pixel, x, y, pixel);
coord[0] += x_adv;
}
coord[1] += y_adv;
}
}
}
}The code above is a function that enlarges or shrinks an image. zigar.image.Any is a parametric union type that can accommodate either a PHP GD image or a JavaScript ImageData object. An inline loop is used here to generate different binaries for different formats from the same source code.
Now we go back to the JavaScript side. In index.js, add these require statements at the top:
require('node-zigar/cjs');
const { scale } = require('../zig/process.zig');Change changeImage() so that uses the imported function to create the output image data:
function changeImage() {
const srcCTX = srcCanvas.getContext('2d', { willReadFrequently: true });
const srcImageData = srcCTX.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
const size = parseFloat(sizeInput.value);
const dstImageData = new ImageData(srcImageData.width * size, srcImageData.height * size);
scale(srcImageData, dstImageData);
dstCanvas.width = dstImageData.width;
dstCanvas.height = dstImageData.height;
const dstCTX = dstCanvas.getContext('2d');
dstCTX.putImageData(dstImageData, 0, 0);
}When you start the app again, a message will appear informing you that the Node-API addon and the module "process" are being built. After a minute or so you should see this:

In this section, we're going create a function that apply a sepia effect on an image. In the
process.zig, append the following code:
pub fn sepia(image_in: zigar.image.Any(.ro), image_out: zigar.image.Any(.rw), intensity: f32) void {
const Pixel = @Vector(4, f32);
inline for (zigar.image.formats) |tag| {
if (image_in == tag and image_out == tag) {
const in = image_in.getField(tag);
const out = image_out.getField(tag);
var coord: @Vector(2, f32) = undefined;
coord[1] = 0.5;
for (0..out.getHeight()) |y| {
coord[0] = 0.5;
for (0..out.getWidth()) |x| {
const yiq_matrix: [4]@Vector(4, f32) = .{
.{ 0.299, 0.596, 0.212, 0.0 },
.{ 0.587, -0.275, -0.523, 0.0 },
.{ 0.114, -0.321, 0.311, 0.0 },
.{ 0.0, 0.0, 0.0, 1.0 },
};
const inverse_yiq: [4]@Vector(4, f32) = .{
.{ 1.0, 1.0, 1.0, 0.0 },
.{ 0.956, -0.272, -1.1, 0.0 },
.{ 0.621, -0.647, 1.7, 0.0 },
.{ 0.0, 0.0, 0.0, 1.0 },
};
const rgba_color = in.sampleNearest(@Vector(4, f32), coord);
var yiqa_color = @"M * V"(yiq_matrix, rgba_color);
yiqa_color[1] = intensity;
yiqa_color[2] = 0.0;
const pixel = @"M * V"(inverse_yiq, yiqa_color);
out.setPixel(Pixel, x, y, pixel);
coord[0] += 1;
}
coord[1] += 1;
}
}
}
}
fn @"M * V"(m1: anytype, v2: anytype) @TypeOf(v2) {
const ar = @typeInfo(@TypeOf(m1)).array;
var t1: @TypeOf(m1) = undefined;
inline for (m1, 0..) |column, c| {
inline for (0..ar.len) |r| {
t1[r][c] = column[r];
}
}
var result: @TypeOf(v2) = undefined;
inline for (t1, 0..) |column, c| {
result[c] = @reduce(.Add, column * v2);
}
return result;
}The working principle of the filter is quite simple. We transform each pixel of the image from RGB color space into YIQ, the color space used for NTSC broadcast. Acting like an old television set, we toss away the chromatic components (I and Q). Then we add a yellowish tint by assigning a specific value to I.

As you can see in the picture above depicting the YIQ color space at Y = 0.5, the I channel represents the reddish orange color:
The @"M * V" function might look odd to you. Zig allows identifiers to contain whitespaces and
special characters using the @"..." escape sequence. We're taking advantage of that here to
clearly indicate what the function does, namely multipling a matrix with a vector.
Loops are unrolled using the inline keyword to enhance performance.
In index.html, add a slider for controlling the intensity of the effect:
<div class="controls">
Intensity: <input id="intensityInput" type="range" min="0" max="1" step="0.0001" value="0.3">
</div>In index.js, attach a change handler to the input control:
const intensityInput = document.getElementById('intensityInput');
intensityInput.oninput = () => changeImage();Import the new Zig function:
const { scale, sepia } = require('../zig/process.zig');And call it in changeImage():
function changeImage() {
const srcCTX = srcCanvas.getContext('2d', { willReadFrequently: true });
const srcImageData = srcCTX.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
const size = parseFloat(sizeInput.value);
const intensity = parseFloat(intensityInput.value);
const dstImageData = new ImageData(srcImageData.width * size, srcImageData.height * size);
scale(srcImageData, dstImageData);
sepia(dstImageData, dstImageData, intensity);
dstCanvas.width = dstImageData.width;
dstCanvas.height = dstImageData.height;
const dstCTX = dstCanvas.getContext('2d');
dstCTX.putImageData(dstImageData, 0, 0);
}Voila! The final result:

We're going to follow the same steps as described in the hello world tutorial. First, we'll alter the require statement so it references a node-zigar module instead of a Zig file:
const { scale, sepia } = require(`../lib/process.zigar`);Then we add node-zigar.config.json to the app's root directory:
{
"optimize": "ReleaseFast",
"modules": {
"lib/process.zigar": {
"source": "zig/process.zig"
}
},
"targets": [
{ "platform": "win32", "arch": "x64" },
{ "platform": "win32", "arch": "arm64" },
{ "platform": "win32", "arch": "ia32" },
{ "platform": "linux", "arch": "x64" },
{ "platform": "linux", "arch": "arm64" },
{ "platform": "darwin", "arch": "x64" },
{ "platform": "darwin", "arch": "arm64" }
]
}We build the library files:
npx zigar buildThe app can now be packaged for distribution.
You can find the complete source code for this example here.
One of the key advantages that NW.js has over Electron is how UI code and "backend" code are housed in the same thread. There's is no need to transfer data from one side to another. This makes this example significantly simpler than the Electron counterpart. Deployment of Electron apps is far easier though. Overall, it might still be a more attractive platform.
The image filter employed for this example is very rudimentary. Check out pb2zig's project page to see more advanced code.
That's it for now. I hope this tutorial is enough to get you started with using Zigar.