浏览代码

Merge branch 'j433866-image-operations'

n1474335 6 年之前
父节点
当前提交
45c1c23e09

+ 6 - 0
src/core/Ingredient.mjs

@@ -27,6 +27,9 @@ class Ingredient {
         this.toggleValues = [];
         this.target = null;
         this.defaultIndex = 0;
+        this.min = null;
+        this.max = null;
+        this.step = 1;
 
         if (ingredientConfig) {
             this._parseConfig(ingredientConfig);
@@ -50,6 +53,9 @@ class Ingredient {
         this.toggleValues = ingredientConfig.toggleValues;
         this.target = typeof ingredientConfig.target !== "undefined" ? ingredientConfig.target : null;
         this.defaultIndex = typeof ingredientConfig.defaultIndex !== "undefined" ? ingredientConfig.defaultIndex : 0;
+        this.min = ingredientConfig.min;
+        this.max = ingredientConfig.max;
+        this.step = ingredientConfig.step;
     }
 
 

+ 3 - 0
src/core/Operation.mjs

@@ -184,6 +184,9 @@ class Operation {
             if (ing.disabled) conf.disabled = ing.disabled;
             if (ing.target) conf.target = ing.target;
             if (ing.defaultIndex) conf.defaultIndex = ing.defaultIndex;
+            if (typeof ing.min === "number") conf.min = ing.min;
+            if (typeof ing.max === "number") conf.max = ing.max;
+            if (ing.step) conf.step = ing.step;
             return conf;
         });
     }

+ 14 - 1
src/core/config/Categories.json

@@ -361,7 +361,20 @@
             "Play Media",
             "Remove EXIF",
             "Extract EXIF",
-            "Split Colour Channels"
+            "Split Colour Channels",
+            "Rotate Image",
+            "Resize Image",
+            "Blur Image",
+            "Dither Image",
+            "Invert Image",
+            "Flip Image",
+            "Crop Image",
+            "Image Brightness / Contrast",
+            "Image Opacity",
+            "Image Filter",
+            "Contain Image",
+            "Cover Image",
+            "Image Hue/Saturation/Lightness"
         ]
     },
     {

+ 102 - 0
src/core/operations/BlurImage.mjs

@@ -0,0 +1,102 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Blur Image operation
+ */
+class BlurImage extends Operation {
+
+    /**
+     * BlurImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Blur Image";
+        this.module = "Image";
+        this.description = "Applies a blur effect to the image.<br><br>Gaussian blur is much slower than fast blur, but produces better results.";
+        this.infoURL = "https://wikipedia.org/wiki/Gaussian_blur";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Amount",
+                type: "number",
+                value: 5,
+                min: 1
+            },
+            {
+                name: "Type",
+                type: "option",
+                value: ["Fast", "Gaussian"]
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [blurAmount, blurType] = args;
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            switch (blurType){
+                case "Fast":
+                    image.blur(blurAmount);
+                    break;
+                case "Gaussian":
+                    if (ENVIRONMENT_IS_WORKER())
+                        self.sendStatusMessage("Gaussian blurring image. This may take a while...");
+                    image.gaussian(blurAmount);
+                    break;
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error blurring image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the blurred image using HTML for web apps
+     *
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default BlurImage;

+ 143 - 0
src/core/operations/ContainImage.mjs

@@ -0,0 +1,143 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Contain Image operation
+ */
+class ContainImage extends Operation {
+
+    /**
+     * ContainImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Contain Image";
+        this.module = "Image";
+        this.description = "Scales an image to the specified width and height, maintaining the aspect ratio. The image may be letterboxed.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Width",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Height",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Horizontal align",
+                type: "option",
+                value: [
+                    "Left",
+                    "Center",
+                    "Right"
+                ],
+                defaultIndex: 1
+            },
+            {
+                name: "Vertical align",
+                type: "option",
+                value: [
+                    "Top",
+                    "Middle",
+                    "Bottom"
+                ],
+                defaultIndex: 1
+            },
+            {
+                name: "Resizing algorithm",
+                type: "option",
+                value: [
+                    "Nearest Neighbour",
+                    "Bilinear",
+                    "Bicubic",
+                    "Hermite",
+                    "Bezier"
+                ],
+                defaultIndex: 1
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [width, height, hAlign, vAlign, alg] = args;
+
+        const resizeMap = {
+            "Nearest Neighbour": jimp.RESIZE_NEAREST_NEIGHBOR,
+            "Bilinear": jimp.RESIZE_BILINEAR,
+            "Bicubic": jimp.RESIZE_BICUBIC,
+            "Hermite": jimp.RESIZE_HERMITE,
+            "Bezier": jimp.RESIZE_BEZIER
+        };
+
+        const alignMap = {
+            "Left": jimp.HORIZONTAL_ALIGN_LEFT,
+            "Center": jimp.HORIZONTAL_ALIGN_CENTER,
+            "Right": jimp.HORIZONTAL_ALIGN_RIGHT,
+            "Top": jimp.VERTICAL_ALIGN_TOP,
+            "Middle": jimp.VERTICAL_ALIGN_MIDDLE,
+            "Bottom": jimp.VERTICAL_ALIGN_BOTTOM
+        };
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Containing image...");
+            image.contain(width, height, alignMap[hAlign] | alignMap[vAlign], resizeMap[alg]);
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error containing image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the contained image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default ContainImage;

+ 143 - 0
src/core/operations/CoverImage.mjs

@@ -0,0 +1,143 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Cover Image operation
+ */
+class CoverImage extends Operation {
+
+    /**
+     * CoverImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Cover Image";
+        this.module = "Image";
+        this.description = "Scales the image to the given width and height, keeping the aspect ratio. The image may be clipped.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Width",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Height",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Horizontal align",
+                type: "option",
+                value: [
+                    "Left",
+                    "Center",
+                    "Right"
+                ],
+                defaultIndex: 1
+            },
+            {
+                name: "Vertical align",
+                type: "option",
+                value: [
+                    "Top",
+                    "Middle",
+                    "Bottom"
+                ],
+                defaultIndex: 1
+            },
+            {
+                name: "Resizing algorithm",
+                type: "option",
+                value: [
+                    "Nearest Neighbour",
+                    "Bilinear",
+                    "Bicubic",
+                    "Hermite",
+                    "Bezier"
+                ],
+                defaultIndex: 1
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [width, height, hAlign, vAlign, alg] = args;
+
+        const resizeMap = {
+            "Nearest Neighbour": jimp.RESIZE_NEAREST_NEIGHBOR,
+            "Bilinear": jimp.RESIZE_BILINEAR,
+            "Bicubic": jimp.RESIZE_BICUBIC,
+            "Hermite": jimp.RESIZE_HERMITE,
+            "Bezier": jimp.RESIZE_BEZIER
+        };
+
+        const alignMap = {
+            "Left": jimp.HORIZONTAL_ALIGN_LEFT,
+            "Center": jimp.HORIZONTAL_ALIGN_CENTER,
+            "Right": jimp.HORIZONTAL_ALIGN_RIGHT,
+            "Top": jimp.VERTICAL_ALIGN_TOP,
+            "Middle": jimp.VERTICAL_ALIGN_MIDDLE,
+            "Bottom": jimp.VERTICAL_ALIGN_BOTTOM
+        };
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Covering image...");
+            image.cover(width, height, alignMap[hAlign] | alignMap[vAlign], resizeMap[alg]);
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error covering image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the covered image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default CoverImage;

+ 144 - 0
src/core/operations/CropImage.mjs

@@ -0,0 +1,144 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Crop Image operation
+ */
+class CropImage extends Operation {
+
+    /**
+     * CropImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Crop Image";
+        this.module = "Image";
+        this.description = "Crops an image to the specified region, or automatically crops edges.<br><br><b><u>Autocrop</u></b><br>Automatically crops same-colour borders from the image.<br><br><u>Autocrop tolerance</u><br>A percentage value for the tolerance of colour difference between pixels.<br><br><u>Only autocrop frames</u><br>Only crop real frames (all sides must have the same border)<br><br><u>Symmetric autocrop</u><br>Force autocrop to be symmetric (top/bottom and left/right are cropped by the same amount)<br><br><u>Autocrop keep border</u><br>The number of pixels of border to leave around the image.";
+        this.infoURL = "https://wikipedia.org/wiki/Cropping_(image)";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "X Position",
+                type: "number",
+                value: 0,
+                min: 0
+            },
+            {
+                name: "Y Position",
+                type: "number",
+                value: 0,
+                min: 0
+            },
+            {
+                name: "Width",
+                type: "number",
+                value: 10,
+                min: 1
+            },
+            {
+                name: "Height",
+                type: "number",
+                value: 10,
+                min: 1
+            },
+            {
+                name: "Autocrop",
+                type: "boolean",
+                value: false
+            },
+            {
+                name: "Autocrop tolerance (%)",
+                type: "number",
+                value: 0.02,
+                min: 0,
+                max: 100,
+                step: 0.01
+            },
+            {
+                name: "Only autocrop frames",
+                type: "boolean",
+                value: true
+            },
+            {
+                name: "Symmetric autocrop",
+                type: "boolean",
+                value: false
+            },
+            {
+                name: "Autocrop keep border (px)",
+                type: "number",
+                value: 0,
+                min: 0
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [xPos, yPos, width, height, autocrop, autoTolerance, autoFrames, autoSymmetric, autoBorder] = args;
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Cropping image...");
+            if (autocrop) {
+                image.autocrop({
+                    tolerance: (autoTolerance / 100),
+                    cropOnlyFrames: autoFrames,
+                    cropSymmetric: autoSymmetric,
+                    leaveBorder: autoBorder
+                });
+            } else {
+                image.crop(xPos, yPos, width, height);
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error cropping image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the cropped image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default CropImage;

+ 79 - 0
src/core/operations/DitherImage.mjs

@@ -0,0 +1,79 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Image Dither operation
+ */
+class DitherImage extends Operation {
+
+    /**
+     * DitherImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Dither Image";
+        this.module = "Image";
+        this.description = "Apply a dither effect to an image.";
+        this.infoURL = "https://wikipedia.org/wiki/Dither";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Applying dither to image...");
+            image.dither565();
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error applying dither to image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the dithered image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default DitherImage;

+ 94 - 0
src/core/operations/FlipImage.mjs

@@ -0,0 +1,94 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Flip Image operation
+ */
+class FlipImage extends Operation {
+
+    /**
+     * FlipImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Flip Image";
+        this.module = "Image";
+        this.description = "Flips an image along its X or Y axis.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Axis",
+                type: "option",
+                value: ["Horizontal", "Vertical"]
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [flipAxis] = args;
+        if (!isImage(input)) {
+            throw new OperationError("Invalid input file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Flipping image...");
+            switch (flipAxis){
+                case "Horizontal":
+                    image.flip(true, false);
+                    break;
+                case "Vertical":
+                    image.flip(false, true);
+                    break;
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error flipping image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the flipped image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default FlipImage;

+ 103 - 0
src/core/operations/ImageBrightnessContrast.mjs

@@ -0,0 +1,103 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Image Brightness / Contrast operation
+ */
+class ImageBrightnessContrast extends Operation {
+
+    /**
+     * ImageBrightnessContrast constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Image Brightness / Contrast";
+        this.module = "Image";
+        this.description = "Adjust the brightness or contrast of an image.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Brightness",
+                type: "number",
+                value: 0,
+                min: -100,
+                max: 100
+            },
+            {
+                name: "Contrast",
+                type: "number",
+                value: 0,
+                min: -100,
+                max: 100
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [brightness, contrast] = args;
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (brightness !== 0) {
+                if (ENVIRONMENT_IS_WORKER())
+                    self.sendStatusMessage("Changing image brightness...");
+                image.brightness(brightness / 100);
+            }
+            if (contrast !== 0) {
+                if (ENVIRONMENT_IS_WORKER())
+                    self.sendStatusMessage("Changing image contrast...");
+                image.contrast(contrast / 100);
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error adjusting image brightness or contrast. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default ImageBrightnessContrast;

+ 94 - 0
src/core/operations/ImageFilter.mjs

@@ -0,0 +1,94 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Image Filter operation
+ */
+class ImageFilter extends Operation {
+
+    /**
+     * ImageFilter constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Image Filter";
+        this.module = "Image";
+        this.description = "Applies a greyscale or sepia filter to an image.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Filter type",
+                type: "option",
+                value: [
+                    "Greyscale",
+                    "Sepia"
+                ]
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [filterType] = args;
+        if (!isImage(input)){
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Applying " + filterType.toLowerCase() + " filter to image...");
+            if (filterType === "Greyscale") {
+                image.greyscale();
+            } else {
+                image.sepia();
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error applying filter to image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the blurred image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default ImageFilter;

+ 129 - 0
src/core/operations/ImageHueSaturationLightness.mjs

@@ -0,0 +1,129 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Image Hue/Saturation/Lightness operation
+ */
+class ImageHueSaturationLightness extends Operation {
+
+    /**
+     * ImageHueSaturationLightness constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Image Hue/Saturation/Lightness";
+        this.module = "Image";
+        this.description = "Adjusts the hue / saturation / lightness (HSL) values of an image.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Hue",
+                type: "number",
+                value: 0,
+                min: -360,
+                max: 360
+            },
+            {
+                name: "Saturation",
+                type: "number",
+                value: 0,
+                min: -100,
+                max: 100
+            },
+            {
+                name: "Lightness",
+                type: "number",
+                value: 0,
+                min: -100,
+                max: 100
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [hue, saturation, lightness] = args;
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (hue !== 0) {
+                if (ENVIRONMENT_IS_WORKER())
+                    self.sendStatusMessage("Changing image hue...");
+                image.colour([
+                    {
+                        apply: "hue",
+                        params: [hue]
+                    }
+                ]);
+            }
+            if (saturation !== 0) {
+                if (ENVIRONMENT_IS_WORKER())
+                    self.sendStatusMessage("Changing image saturation...");
+                image.colour([
+                    {
+                        apply: "saturate",
+                        params: [saturation]
+                    }
+                ]);
+            }
+            if (lightness !== 0) {
+                if (ENVIRONMENT_IS_WORKER())
+                    self.sendStatusMessage("Changing image lightness...");
+                image.colour([
+                    {
+                        apply: "lighten",
+                        params: [lightness]
+                    }
+                ]);
+            }
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error adjusting image hue / saturation / lightness. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+}
+
+export default ImageHueSaturationLightness;

+ 89 - 0
src/core/operations/ImageOpacity.mjs

@@ -0,0 +1,89 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Image Opacity operation
+ */
+class ImageOpacity extends Operation {
+
+    /**
+     * ImageOpacity constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Image Opacity";
+        this.module = "Image";
+        this.description = "Adjust the opacity of an image.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Opacity (%)",
+                type: "number",
+                value: 100,
+                min: 0,
+                max: 100
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [opacity] = args;
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Changing image opacity...");
+            image.opacity(opacity / 100);
+
+            const imageBuffer = await image.getBufferAsync(jimp.MIME_PNG);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error changing image opacity. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default ImageOpacity;

+ 79 - 0
src/core/operations/InvertImage.mjs

@@ -0,0 +1,79 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Invert Image operation
+ */
+class InvertImage extends Operation {
+
+    /**
+     * InvertImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Invert Image";
+        this.module = "Image";
+        this.description = "Invert the colours of an image.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        if (!isImage(input)) {
+            throw new OperationError("Invalid input file format.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Inverting image...");
+            image.invert();
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error inverting image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the inverted image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default InvertImage;

+ 70 - 0
src/core/operations/NormaliseImage.mjs

@@ -0,0 +1,70 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Normalise Image operation
+ */
+class NormaliseImage extends Operation {
+
+    /**
+     * NormaliseImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Normalise Image";
+        this.module = "Image";
+        this.description = "Normalise the image colours.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType=  "html";
+        this.args = [];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        const image = await jimp.read(Buffer.from(input));
+
+        image.normalize();
+
+        const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+        return [...imageBuffer];
+    }
+
+    /**
+     * Displays the normalised image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default NormaliseImage;

+ 138 - 0
src/core/operations/ResizeImage.mjs

@@ -0,0 +1,138 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2019
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64.mjs";
+import jimp from "jimp";
+
+/**
+ * Resize Image operation
+ */
+class ResizeImage extends Operation {
+
+    /**
+     * ResizeImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Resize Image";
+        this.module = "Image";
+        this.description = "Resizes an image to the specified width and height values.";
+        this.infoURL = "https://wikipedia.org/wiki/Image_scaling";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Width",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Height",
+                type: "number",
+                value: 100,
+                min: 1
+            },
+            {
+                name: "Unit type",
+                type: "option",
+                value: ["Pixels", "Percent"]
+            },
+            {
+                name: "Maintain aspect ratio",
+                type: "boolean",
+                value: false
+            },
+            {
+                name: "Resizing algorithm",
+                type: "option",
+                value: [
+                    "Nearest Neighbour",
+                    "Bilinear",
+                    "Bicubic",
+                    "Hermite",
+                    "Bezier"
+                ],
+                defaultIndex: 1
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        let width = args[0],
+            height = args[1];
+        const unit = args[2],
+            aspect = args[3],
+            resizeAlg = args[4];
+
+        const resizeMap = {
+            "Nearest Neighbour": jimp.RESIZE_NEAREST_NEIGHBOR,
+            "Bilinear": jimp.RESIZE_BILINEAR,
+            "Bicubic": jimp.RESIZE_BICUBIC,
+            "Hermite": jimp.RESIZE_HERMITE,
+            "Bezier": jimp.RESIZE_BEZIER
+        };
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (unit === "Percent") {
+                width = image.getWidth() * (width / 100);
+                height = image.getHeight() * (height / 100);
+            }
+
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Resizing image...");
+            if (aspect) {
+                image.scaleToFit(width, height, resizeMap[resizeAlg]);
+            } else {
+                image.resize(width, height, resizeMap[resizeAlg]);
+            }
+
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error resizing image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the resized image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default ResizeImage;

+ 87 - 0
src/core/operations/RotateImage.mjs

@@ -0,0 +1,87 @@
+/**
+ * @author j433866 [j433866@gmail.com]
+ * @copyright Crown Copyright 2018
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation";
+import OperationError from "../errors/OperationError";
+import { isImage } from "../lib/FileType";
+import { toBase64 } from "../lib/Base64";
+import jimp from "jimp";
+
+/**
+ * Rotate Image operation
+ */
+class RotateImage extends Operation {
+
+    /**
+     * RotateImage constructor
+     */
+    constructor() {
+        super();
+
+        this.name = "Rotate Image";
+        this.module = "Image";
+        this.description = "Rotates an image by the specified number of degrees.";
+        this.infoURL = "";
+        this.inputType = "byteArray";
+        this.outputType = "byteArray";
+        this.presentType = "html";
+        this.args = [
+            {
+                name: "Rotation amount (degrees)",
+                type: "number",
+                value: 90
+            }
+        ];
+    }
+
+    /**
+     * @param {byteArray} input
+     * @param {Object[]} args
+     * @returns {byteArray}
+     */
+    async run(input, args) {
+        const [degrees] = args;
+
+        if (!isImage(input)) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        let image;
+        try {
+            image = await jimp.read(Buffer.from(input));
+        } catch (err) {
+            throw new OperationError(`Error loading image. (${err})`);
+        }
+        try {
+            if (ENVIRONMENT_IS_WORKER())
+                self.sendStatusMessage("Rotating image...");
+            image.rotate(degrees);
+            const imageBuffer = await image.getBufferAsync(jimp.AUTO);
+            return [...imageBuffer];
+        } catch (err) {
+            throw new OperationError(`Error rotating image. (${err})`);
+        }
+    }
+
+    /**
+     * Displays the rotated image using HTML for web apps
+     * @param {byteArray} data
+     * @returns {html}
+     */
+    present(data) {
+        if (!data.length) return "";
+
+        const type = isImage(data);
+        if (!type) {
+            throw new OperationError("Invalid file type.");
+        }
+
+        return `<img src="data:${type};base64,${toBase64(data)}">`;
+    }
+
+}
+
+export default RotateImage;

+ 6 - 0
src/web/HTMLIngredient.mjs

@@ -32,6 +32,9 @@ class HTMLIngredient {
         this.defaultIndex = config.defaultIndex || 0;
         this.toggleValues = config.toggleValues;
         this.id = "ing-" + this.app.nextIngId();
+        this.min = (typeof config.min === "number") ? config.min : "";
+        this.max = (typeof config.max === "number") ? config.max : "";
+        this.step = config.step || 1;
     }
 
 
@@ -103,6 +106,9 @@ class HTMLIngredient {
                         id="${this.id}"
                         arg-name="${this.name}"
                         value="${this.value}"
+                        min="${this.min}"
+                        max="${this.max}"
+                        step="${this.step}"
                         ${this.disabled ? "disabled" : ""}>
                     ${this.hint ? "<span class='bmd-help'>" + this.hint + "</span>" : ""}
                 </div>`;