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.

Creating the app

We begin by initializing the project:

mkdir image
cd image
npm init -y
npm install node-zigar
mkdir src img zig

Then 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:

Sample image

Save it in img.

Now we're ready to go:

You should see something like this:

Mint Linux

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:

Mint Linux

Creating an image filter

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.

Color transform

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:

Mint Linux

Configuring the app for deployment

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 build

The app can now be packaged for distribution.

Source code

You can find the complete source code for this example here.

Conclusion

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.


Additional examples

⚠️ **GitHub.com Fallback** ⚠️