From fa89713f194e820e5cb4d4996f2a00cc80208853 Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 30 May 2017 14:50:56 -0400 Subject: [PATCH 001/687] Add d3 as a dependency --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 4ad37565..09a08036 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,8 @@ "bootstrap-switch": "^3.3.4", "crypto-api": "^0.6.2", "crypto-js": "^3.1.9-1", + "d3": "^4.9.1", + "d3-hexbin": "^0.2.2", "diff": "^3.2.0", "escodegen": "^1.8.1", "esmangle": "^1.0.1", From 281d558111c5094b828583109fe8417f94abfb1c Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 30 May 2017 14:53:32 -0400 Subject: [PATCH 002/687] Add hex density chart --- src/core/Utils.js | 1 + src/core/config/OperationConfig.js | 39 ++++++ src/core/operations/Charts.js | 205 +++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100755 src/core/operations/Charts.js diff --git a/src/core/Utils.js b/src/core/Utils.js index 9b0d2a30..bb05ec3d 100755 --- a/src/core/Utils.js +++ b/src/core/Utils.js @@ -1021,6 +1021,7 @@ const Utils = { "Comma": ",", "Semi-colon": ";", "Colon": ":", + "Tab": "\t", "Line feed": "\n", "CRLF": "\r\n", "Forward slash": "/", diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index 5fd5a9ee..f11809ad 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -5,6 +5,7 @@ import Base64 from "../operations/Base64.js"; import BitwiseOp from "../operations/BitwiseOp.js"; import ByteRepr from "../operations/ByteRepr.js"; import CharEnc from "../operations/CharEnc.js"; +import Charts from "../operations/Charts.js"; import Checksum from "../operations/Checksum.js"; import Cipher from "../operations/Cipher.js"; import Code from "../operations/Code.js"; @@ -3388,6 +3389,44 @@ const OperationConfig = { } ] }, + "Hex Density chart": { + description: [].join("\n"), + run: Charts.runHexDensityChart, + inputType: "string", + outputType: "html", + args: [ + { + name: "Record delimiter", + type: "option", + value: Charts.RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: Charts.FIELD_DELIMITER_OPTIONS, + }, + { + name: "Radius", + type: "number", + value: 25, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + ] + } }; export default OperationConfig; diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js new file mode 100755 index 00000000..a1ab9725 --- /dev/null +++ b/src/core/operations/Charts.js @@ -0,0 +1,205 @@ +import * as d3 from "d3"; +import {hexbin as d3hexbin} from "d3-hexbin"; +import Utils from "../Utils.js"; + +/** + * Charting operations. + * + * @author tlwr [toby@toby.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + * + * @namespace + */ +const Charts = { + /** + * @constant + * @default + */ + RECORD_DELIMITER_OPTIONS: ["Line feed", "CRLF"], + + + /** + * @constant + * @default + */ + FIELD_DELIMITER_OPTIONS: ["Space", "Comma", "Semi-colon", "Colon", "Tab"], + + + /** + * Gets values from input for a scatter plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ + _getScatterValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let headings; + const values = []; + + input + .split(recordDelimiter) + .forEach((row, rowIndex) => { + let split = row.split(fieldDelimiter); + + if (split.length !== 2) throw "Each row must have length 2."; + + if (columnHeadingsAreIncluded && rowIndex === 0) { + headings = {}; + headings.x = split[0]; + headings.y = split[1]; + } else { + let x = split[0], + y = split[1]; + + x = parseFloat(x, 10); + if (Number.isNaN(x)) throw "Values must be numbers in base 10."; + + y = parseFloat(y, 10); + if (Number.isNaN(y)) throw "Values must be numbers in base 10."; + + values.push([x, y]); + } + }); + + return { headings, values}; + }, + + + /** + * Hex Bin chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runHexDensityChart: function (input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + radius = args[2], + columnHeadingsAreIncluded = args[3], + dimension = 500; + + let xLabel = args[4], + yLabel = args[5], + { headings, values } = Charts._getScatterValues( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + let margin = { + top: 0, + right: 0, + bottom: 30, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + let hexbin = d3hexbin() + .radius(radius) + .extent([0, 0], [width, height]); + + let hexPoints = hexbin(values), + maxCount = Math.max(...hexPoints.map(b => b.length)); + + let xExtent = d3.extent(hexPoints, d => d.x), + yExtent = d3.extent(hexPoints, d => d.y); + xExtent[0] -= 2 * radius; + xExtent[1] += 2 * radius; + yExtent[0] -= 2 * radius; + yExtent[1] += 2 * radius; + + let xAxis = d3.scaleLinear() + .domain(xExtent) + .range([0, width]); + let yAxis = d3.scaleLinear() + .domain(yExtent) + .range([height, 0]); + + let color = d3.scaleSequential(d3.interpolateLab("white", "steelblue")) + .domain([0, maxCount]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "hexagon") + .attr("clip-path", "url(#clip)") + .selectAll("path") + .data(hexPoints) + .enter() + .append("path") + .attr("d", d => { + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(radius * 0.75)}`; + }) + .attr("fill", (d) => color(d.length)) + .append("title") + .text(d => { + let count = d.length, + perc = 100.0 * d.length / values.length, + CX = d.x, + CY = d.y, + xMin = Math.min(...d.map(d => d[0])), + xMax = Math.max(...d.map(d => d[0])), + yMin = Math.min(...d.map(d => d[1])), + yMax = Math.max(...d.map(d => d[1])), + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + Center: ${CX.toFixed(2)}, ${CY.toFixed(2)}\n + Min X: ${xMin.toFixed(2)}\n + Max X: ${xMax.toFixed(2)}\n + Min Y: ${yMin.toFixed(2)}\n + Max Y: ${yMax.toFixed(2)} + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + }, +}; + +export default Charts; From 6cdc7d3966e19443c5d4d595ff1218eb04e6fc79 Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 30 May 2017 15:24:23 -0400 Subject: [PATCH 003/687] Hex density: split radius into draw & pack radii --- src/core/config/OperationConfig.js | 7 ++++++- src/core/operations/Charts.js | 21 +++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index f11809ad..db7f5837 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3406,10 +3406,15 @@ const OperationConfig = { value: Charts.FIELD_DELIMITER_OPTIONS, }, { - name: "Radius", + name: "Pack radius", type: "number", value: 25, }, + { + name: "Draw radius", + type: "number", + value: 15, + }, { name: "Use column headers as labels", type: "boolean", diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index a1ab9725..1c026fb7 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -78,12 +78,13 @@ const Charts = { runHexDensityChart: function (input, args) { const recordDelimiter = Utils.charRep[args[0]], fieldDelimiter = Utils.charRep[args[1]], - radius = args[2], - columnHeadingsAreIncluded = args[3], + packRadius = args[2], + drawRadius = args[3], + columnHeadingsAreIncluded = args[4], dimension = 500; - let xLabel = args[4], - yLabel = args[5], + let xLabel = args[5], + yLabel = args[6], { headings, values } = Charts._getScatterValues( input, recordDelimiter, @@ -114,7 +115,7 @@ const Charts = { .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); let hexbin = d3hexbin() - .radius(radius) + .radius(packRadius) .extent([0, 0], [width, height]); let hexPoints = hexbin(values), @@ -122,10 +123,10 @@ const Charts = { let xExtent = d3.extent(hexPoints, d => d.x), yExtent = d3.extent(hexPoints, d => d.y); - xExtent[0] -= 2 * radius; - xExtent[1] += 2 * radius; - yExtent[0] -= 2 * radius; - yExtent[1] += 2 * radius; + xExtent[0] -= 2 * packRadius; + xExtent[1] += 2 * packRadius; + yExtent[0] -= 2 * packRadius; + yExtent[1] += 2 * packRadius; let xAxis = d3.scaleLinear() .domain(xExtent) @@ -151,7 +152,7 @@ const Charts = { .enter() .append("path") .attr("d", d => { - return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(radius * 0.75)}`; + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; }) .attr("fill", (d) => color(d.length)) .append("title") From dc642be1f53b270f8107b09405f79e5ecd012ef2 Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 30 May 2017 15:49:22 -0400 Subject: [PATCH 004/687] Hex plot: add edge drawing & changing colour opts --- src/core/config/OperationConfig.js | 15 +++++++++++++++ src/core/operations/Charts.js | 22 ++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index db7f5837..ffb75a07 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3430,6 +3430,21 @@ const OperationConfig = { type: "string", value: "", }, + { + name: "Draw hexagon edges", + type: "boolean", + value: false, + }, + { + name: "Min colour value", + type: "string", + value: Charts.COLOURS.min, + }, + { + name: "Max colour value", + type: "string", + value: Charts.COLOURS.max, + }, ] } }; diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index 1c026fb7..eb8c7efe 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -68,6 +68,19 @@ const Charts = { }, + /** + * Default from colour + * + * @constant + * @default + */ + COLOURS: { + min: "white", + max: "black", + }, + + + /** * Hex Bin chart operation. * @@ -81,6 +94,9 @@ const Charts = { packRadius = args[2], drawRadius = args[3], columnHeadingsAreIncluded = args[4], + drawEdges = args[7], + minColour = args[8], + maxColour = args[9], dimension = 500; let xLabel = args[5], @@ -135,7 +151,7 @@ const Charts = { .domain(yExtent) .range([height, 0]); - let color = d3.scaleSequential(d3.interpolateLab("white", "steelblue")) + let colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) .domain([0, maxCount]); marginedSpace.append("clipPath") @@ -154,7 +170,9 @@ const Charts = { .attr("d", d => { return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; }) - .attr("fill", (d) => color(d.length)) + .attr("fill", (d) => colour(d.length)) + .attr("stroke", drawEdges ? "black" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") .append("title") .text(d => { let count = d.length, From b4188db671ec1451c089b0f5416a9aeaf13805ec Mon Sep 17 00:00:00 2001 From: toby Date: Wed, 31 May 2017 14:56:03 -0400 Subject: [PATCH 005/687] Hexagon density: allow dense plotting of hexagons --- src/core/config/OperationConfig.js | 5 +++ src/core/operations/Charts.js | 56 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index ffb75a07..ab38b7cf 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3445,6 +3445,11 @@ const OperationConfig = { type: "string", value: Charts.COLOURS.max, }, + { + name: "Draw empty hexagons within data boundaries", + type: "boolean", + value: false, + }, ] } }; diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index eb8c7efe..447d47b2 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -80,6 +80,36 @@ const Charts = { }, + /** + * Hex Bin chart operation. + * + * @param {Object[]} - centres + * @param {number} - radius + * @returns {Object[]} + */ + _getEmptyHexagons(centres, radius) { + const emptyCentres = []; + let boundingRect = [d3.extent(centres, d => d.x), d3.extent(centres, d => d.y)], + indent = false, + hexagonCenterToEdge = Math.cos(2 * Math.PI / 12) * radius, + hexagonEdgeLength = Math.sin(2 * Math.PI / 12) * radius; + + for (let y = boundingRect[1][0]; y <= boundingRect[1][1] + radius; y += hexagonEdgeLength + radius) { + for (let x = boundingRect[0][0]; x <= boundingRect[0][1] + radius; x += 2 * hexagonCenterToEdge) { + let cx = x, + cy = y; + + if (indent && x >= boundingRect[0][1]) break; + if (indent) cx += hexagonCenterToEdge; + + emptyCentres.push({x: cx, y: cy}); + } + indent = !indent; + } + + return emptyCentres; + }, + /** * Hex Bin chart operation. @@ -97,6 +127,7 @@ const Charts = { drawEdges = args[7], minColour = args[8], maxColour = args[9], + drawEmptyHexagons = args[10], dimension = 500; let xLabel = args[5], @@ -160,6 +191,31 @@ const Charts = { .attr("width", width) .attr("height", height); + if (drawEmptyHexagons) { + marginedSpace.append("g") + .attr("class", "empty-hexagon") + .selectAll("path") + .data(Charts._getEmptyHexagons(hexPoints, packRadius)) + .enter() + .append("path") + .attr("d", d => { + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; + }) + .attr("fill", (d) => colour(0)) + .attr("stroke", drawEdges ? "black" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") + .append("title") + .text(d => { + let count = 0, + perc = 0, + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + Center: ${d.x.toFixed(2)}, ${d.y.toFixed(2)}\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + } + marginedSpace.append("g") .attr("class", "hexagon") .attr("clip-path", "url(#clip)") From 1c87707a76652642b544ed993a6289b2fc9a4053 Mon Sep 17 00:00:00 2001 From: toby Date: Mon, 5 Jun 2017 10:24:06 -0400 Subject: [PATCH 006/687] Add heatmap chart operation --- src/core/config/OperationConfig.js | 58 ++++++++++ src/core/operations/Charts.js | 176 +++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index ab38b7cf..62ba46e5 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3451,6 +3451,64 @@ const OperationConfig = { value: false, }, ] + }, + "Heatmap chart": { + description: [].join("\n"), + run: Charts.runHeatmapChart, + inputType: "string", + outputType: "html", + args: [ + { + name: "Record delimiter", + type: "option", + value: Charts.RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: Charts.FIELD_DELIMITER_OPTIONS, + }, + { + name: "Number of vertical bins", + type: "number", + value: 25, + }, + { + name: "Number of horizontal bins", + type: "number", + value: 25, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + { + name: "Draw bin edges", + type: "boolean", + value: false, + }, + { + name: "Min colour value", + type: "string", + value: Charts.COLOURS.min, + }, + { + name: "Max colour value", + type: "string", + value: Charts.COLOURS.max, + }, + ] } }; diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index 447d47b2..5a927ce3 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -275,6 +275,182 @@ const Charts = { return svg._groups[0][0].outerHTML; }, + + + /** + * Packs a list of x, y coordinates into a number of bins for use in a heatmap. + * + * @param {Object[]} points + * @param {number} number of vertical bins + * @param {number} number of horizontal bins + * @returns {Object[]} a list of bins (each bin is an Array) with x y coordinates, filled with the points + */ + _getHeatmapPacking(values, vBins, hBins) { + const xBounds = d3.extent(values, d => d[0]), + yBounds = d3.extent(values, d => d[1]), + bins = []; + + if (xBounds[0] === xBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum X coordinate."; + if (yBounds[0] === yBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum Y coordinate."; + + for (let y = 0; y < vBins; y++) { + bins.push([]); + for (let x = 0; x < hBins; x++) { + let item = []; + item.y = y; + item.x = x; + + bins[y].push(item); + } // x + } // y + + let epsilon = 0.000000001; // This is to clamp values that are exactly the maximum; + + values.forEach(v => { + let fractionOfY = (v[1] - yBounds[0]) / ((yBounds[1] + epsilon) - yBounds[0]), + fractionOfX = (v[0] - xBounds[0]) / ((xBounds[1] + epsilon) - xBounds[0]); + let y = Math.floor(vBins * fractionOfY), + x = Math.floor(hBins * fractionOfX); + + bins[y][x].push({x: v[0], y: v[1]}); + }); + + return bins; + }, + + + /** + * Heatmap chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runHeatmapChart: function (input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + vBins = args[2], + hBins = args[3], + columnHeadingsAreIncluded = args[4], + drawEdges = args[7], + minColour = args[8], + maxColour = args[9], + dimension = 500; + + if (vBins <= 0) throw "Number of vertical bins must be greater than 0"; + if (hBins <= 0) throw "Number of horizontal bins must be greater than 0"; + + let xLabel = args[5], + yLabel = args[6], + { headings, values } = Charts._getScatterValues( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + let margin = { + top: 10, + right: 0, + bottom: 40, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + binWidth = width / hBins, + binHeight = height/ vBins, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + let bins = Charts._getHeatmapPacking(values, vBins, hBins), + maxCount = Math.max(...bins.map(row => { + let lengths = row.map(cell => cell.length); + return Math.max(...lengths); + })); + + let xExtent = d3.extent(values, d => d[0]), + yExtent = d3.extent(values, d => d[1]); + + let xAxis = d3.scaleLinear() + .domain(xExtent) + .range([0, width]); + let yAxis = d3.scaleLinear() + .domain(yExtent) + .range([height, 0]); + + let colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) + .domain([0, maxCount]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "bins") + .attr("clip-path", "url(#clip)") + .selectAll("g") + .data(bins) + .enter() + .append("g") + .selectAll("rect") + .data(d => d) + .enter() + .append("rect") + .attr("x", (d) => binWidth * d.x) + .attr("y", (d) => (height - binHeight * (d.y + 1))) + .attr("width", binWidth) + .attr("height", binHeight) + .attr("fill", (d) => colour(d.length)) + .attr("stroke", drawEdges ? "rgba(0, 0, 0, 0.5)" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") + .append("title") + .text(d => { + let count = d.length, + perc = 100.0 * d.length / values.length, + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + }, }; export default Charts; From 594456856592d936711f52a5a6cde5cd937694d5 Mon Sep 17 00:00:00 2001 From: toby Date: Mon, 5 Jun 2017 10:24:15 -0400 Subject: [PATCH 007/687] Change margins in hex density chart --- src/core/operations/Charts.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index 5a927ce3..2202e0f1 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -151,9 +151,9 @@ const Charts = { .attr("viewBox", `0 0 ${dimension} ${dimension}`); let margin = { - top: 0, + top: 10, right: 0, - bottom: 30, + bottom: 40, left: 30, }, width = dimension - margin.left - margin.right, From 247e9bfbdeaa113b37ff1bea35c1db624a71a720 Mon Sep 17 00:00:00 2001 From: toby Date: Mon, 5 Jun 2017 21:47:32 -0400 Subject: [PATCH 008/687] Add "HTML to Text" operation --- src/core/config/OperationConfig.js | 8 ++++++++ src/core/operations/HTML.js | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index 62ba46e5..cf8363f8 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3509,6 +3509,14 @@ const OperationConfig = { value: Charts.COLOURS.max, }, ] + }, + "HTML to Text": { + description: [].join("\n"), + run: HTML.runHTMLToText, + inputType: "html", + outputType: "string", + args: [ + ] } }; diff --git a/src/core/operations/HTML.js b/src/core/operations/HTML.js index 601d6102..457124be 100755 --- a/src/core/operations/HTML.js +++ b/src/core/operations/HTML.js @@ -851,6 +851,16 @@ const HTML = { "diams" : 9830, }, + /** + * HTML to text operation + * + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + runHTMLToText(input, args) { + return input; + }, }; export default HTML; From 49ea532cdc36cb6a7a52ede3cc04b40e771a3d24 Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 6 Jun 2017 09:46:46 -0400 Subject: [PATCH 009/687] Tweak extent of hex density charts --- src/core/operations/Charts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index 2202e0f1..e47d26e2 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -171,7 +171,7 @@ const Charts = { let xExtent = d3.extent(hexPoints, d => d.x), yExtent = d3.extent(hexPoints, d => d.y); xExtent[0] -= 2 * packRadius; - xExtent[1] += 2 * packRadius; + xExtent[1] += 3 * packRadius; yExtent[0] -= 2 * packRadius; yExtent[1] += 2 * packRadius; From 39ab60088774f5375206209d59cadfbf2e2a84e8 Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 6 Jun 2017 14:01:23 -0400 Subject: [PATCH 010/687] Add scatter plot operation --- src/core/config/OperationConfig.js | 48 ++++++ src/core/operations/Charts.js | 241 +++++++++++++++++++++++++---- 2 files changed, 258 insertions(+), 31 deletions(-) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index cf8363f8..d0565e7c 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3510,6 +3510,54 @@ const OperationConfig = { }, ] }, + "Scatter chart": { + description: [].join("\n"), + run: Charts.runScatterChart, + inputType: "string", + outputType: "html", + args: [ + { + name: "Record delimiter", + type: "option", + value: Charts.RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: Charts.FIELD_DELIMITER_OPTIONS, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + { + name: "Colour", + type: "string", + value: Charts.COLOURS.max, + }, + { + name: "Point radius", + type: "number", + value: 10, + }, + { + name: "Use colour from third column", + type: "boolean", + value: false, + }, + ] + }, "HTML to Text": { description: [].join("\n"), run: HTML.runHTMLToText, diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index e47d26e2..06a3cb62 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -26,6 +26,49 @@ const Charts = { FIELD_DELIMITER_OPTIONS: ["Space", "Comma", "Semi-colon", "Colon", "Tab"], + /** + * Default from colour + * + * @constant + * @default + */ + COLOURS: { + min: "white", + max: "black", + }, + + + /** + * Gets values from input for a plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ + _getValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded, length) { + let headings; + const values = []; + + input + .split(recordDelimiter) + .forEach((row, rowIndex) => { + let split = row.split(fieldDelimiter); + + if (split.length !== length) throw `Each row must have length ${length}.`; + + if (columnHeadingsAreIncluded && rowIndex === 0) { + headings = split; + } else { + values.push(split); + } + }); + + return { headings, values}; + }, + + /** * Gets values from input for a scatter plot. * @@ -36,47 +79,64 @@ const Charts = { * @returns {Object[]} */ _getScatterValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { - let headings; - const values = []; + let { headings, values } = Charts._getValues( + input, + recordDelimiter, fieldDelimiter, + columnHeadingsAreIncluded, + 2 + ); - input - .split(recordDelimiter) - .forEach((row, rowIndex) => { - let split = row.split(fieldDelimiter); + if (headings) { + headings = {x: headings[0], y: headings[1]}; + } - if (split.length !== 2) throw "Each row must have length 2."; + values = values.map(row => { + let x = parseFloat(row[0], 10), + y = parseFloat(row[1], 10); - if (columnHeadingsAreIncluded && rowIndex === 0) { - headings = {}; - headings.x = split[0]; - headings.y = split[1]; - } else { - let x = split[0], - y = split[1]; + if (Number.isNaN(x)) throw "Values must be numbers in base 10."; + if (Number.isNaN(y)) throw "Values must be numbers in base 10."; - x = parseFloat(x, 10); - if (Number.isNaN(x)) throw "Values must be numbers in base 10."; + return [x, y]; + }); - y = parseFloat(y, 10); - if (Number.isNaN(y)) throw "Values must be numbers in base 10."; - - values.push([x, y]); - } - }); - - return { headings, values}; + return { headings, values }; }, - + /** - * Default from colour + * Gets values from input for a scatter plot with colour from the third column. * - * @constant - * @default + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} */ - COLOURS: { - min: "white", - max: "black", + _getScatterValuesWithColour(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let { headings, values } = Charts._getValues( + input, + recordDelimiter, fieldDelimiter, + columnHeadingsAreIncluded, + 3 + ); + + if (headings) { + headings = {x: headings[0], y: headings[1]}; + } + + values = values.map(row => { + let x = parseFloat(row[0], 10), + y = parseFloat(row[1], 10), + colour = row[2]; + + if (Number.isNaN(x)) throw "Values must be numbers in base 10."; + if (Number.isNaN(y)) throw "Values must be numbers in base 10."; + + return [x, y, colour]; + }); + + return { headings, values }; }, @@ -451,6 +511,125 @@ const Charts = { return svg._groups[0][0].outerHTML; }, + + + /** + * Scatter chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runScatterChart: function (input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + columnHeadingsAreIncluded = args[2], + fillColour = args[5], + radius = args[6], + colourInInput = args[7], + dimension = 500; + + let xLabel = args[3], + yLabel = args[4]; + + let dataFunction = colourInInput ? Charts._getScatterValuesWithColour : Charts._getScatterValues; + + let { headings, values } = dataFunction( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + let margin = { + top: 10, + right: 0, + bottom: 40, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + let xExtent = d3.extent(values, d => d[0]), + xDelta = xExtent[1] - xExtent[0], + yExtent = d3.extent(values, d => d[1]), + yDelta = yExtent[1] - yExtent[0], + xAxis = d3.scaleLinear() + .domain([xExtent[0] - (0.1 * xDelta), xExtent[1] + (0.1 * xDelta)]) + .range([0, width]), + yAxis = d3.scaleLinear() + .domain([yExtent[0] - (0.1 * yDelta), yExtent[1] + (0.1 * yDelta)]) + .range([height, 0]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "points") + .attr("clip-path", "url(#clip)") + .selectAll("circle") + .data(values) + .enter() + .append("circle") + .attr("cx", (d) => xAxis(d[0])) + .attr("cy", (d) => yAxis(d[1])) + .attr("r", d => radius) + .attr("fill", d => { + return colourInInput ? d[2] : fillColour; + }) + .attr("stroke", "rgba(0, 0, 0, 0.5)") + .attr("stroke-width", "0.5") + .append("title") + .text(d => { + let x = d[0], + y = d[1], + tooltip = `X: ${x}\n + Y: ${y}\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + }, }; export default Charts; From 6784a1c0276c83e7e35020f3953a6b839c67239d Mon Sep 17 00:00:00 2001 From: toby Date: Tue, 20 Jun 2017 15:25:16 -0400 Subject: [PATCH 011/687] Add Series chart operation --- src/core/config/OperationConfig.js | 33 +++++ src/core/operations/Charts.js | 208 ++++++++++++++++++++++++++++- 2 files changed, 240 insertions(+), 1 deletion(-) diff --git a/src/core/config/OperationConfig.js b/src/core/config/OperationConfig.js index d0565e7c..f9b5937d 100755 --- a/src/core/config/OperationConfig.js +++ b/src/core/config/OperationConfig.js @@ -3558,6 +3558,39 @@ const OperationConfig = { }, ] }, + "Series chart": { + description: [].join("\n"), + run: Charts.runSeriesChart, + inputType: "string", + outputType: "html", + args: [ + { + name: "Record delimiter", + type: "option", + value: Charts.RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: Charts.FIELD_DELIMITER_OPTIONS, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Point radius", + type: "number", + value: 1, + }, + { + name: "Series colours", + type: "string", + value: "mediumseagreen, dodgerblue, tomato", + }, + ] + }, "HTML to Text": { description: [].join("\n"), run: HTML.runHTMLToText, diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js index 06a3cb62..2ce084d0 100755 --- a/src/core/operations/Charts.js +++ b/src/core/operations/Charts.js @@ -103,7 +103,7 @@ const Charts = { return { headings, values }; }, - + /** * Gets values from input for a scatter plot with colour from the third column. * @@ -140,6 +140,50 @@ const Charts = { }, + /** + * Gets values from input for a time series plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ + _getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let { headings, values } = Charts._getValues( + input, + recordDelimiter, fieldDelimiter, + false, + 3 + ); + + let xValues = new Set(), + series = {}; + + values = values.forEach(row => { + let serie = row[0], + xVal = row[1], + val = parseFloat(row[2], 10); + + if (Number.isNaN(val)) throw "Values must be numbers in base 10."; + + xValues.add(xVal); + if (typeof series[serie] === "undefined") series[serie] = {}; + series[serie][xVal] = val; + }); + + xValues = new Array(...xValues); + + const seriesList = []; + for (let seriesName in series) { + let serie = series[seriesName]; + seriesList.push({name: seriesName, data: serie}); + } + + return { xValues, series: seriesList }; + }, + + /** * Hex Bin chart operation. * @@ -630,6 +674,168 @@ const Charts = { return svg._groups[0][0].outerHTML; }, + + + /** + * Series chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runSeriesChart(input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + xLabel = args[2], + pipRadius = args[3], + seriesColours = args[4].split(","), + svgWidth = 500, + interSeriesPadding = 20, + xAxisHeight = 50, + seriesLabelWidth = 50, + seriesHeight = 100, + seriesWidth = svgWidth - seriesLabelWidth - interSeriesPadding; + + let { xValues, series } = Charts._getSeriesValues(input, recordDelimiter, fieldDelimiter), + allSeriesHeight = Object.keys(series).length * (interSeriesPadding + seriesHeight), + svgHeight = allSeriesHeight + xAxisHeight + interSeriesPadding; + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`); + + let xAxis = d3.scalePoint() + .domain(xValues) + .range([0, seriesWidth]); + + svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`) + .call( + d3.axisTop(xAxis).tickValues(xValues.filter((x, i) => { + return [0, Math.round(xValues.length / 2), xValues.length -1].indexOf(i) >= 0; + })) + ); + + svg.append("text") + .attr("x", svgWidth / 2) + .attr("y", xAxisHeight / 2) + .style("text-anchor", "middle") + .text(xLabel); + + let tooltipText = {}, + tooltipAreaWidth = seriesWidth / xValues.length; + + xValues.forEach(x => { + let tooltip = []; + + series.forEach(serie => { + let y = serie.data[x]; + if (typeof y === "undefined") return; + + tooltip.push(`${serie.name}: ${y}`); + }); + + tooltipText[x] = tooltip.join("\n"); + }); + + let chartArea = svg.append("g") + .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`); + + chartArea + .append("g") + .selectAll("rect") + .data(xValues) + .enter() + .append("rect") + .attr("x", x => { + return xAxis(x) - (tooltipAreaWidth / 2); + }) + .attr("y", 0) + .attr("width", tooltipAreaWidth) + .attr("height", allSeriesHeight) + .attr("stroke", "none") + .attr("fill", "transparent") + .append("title") + .text(x => { + return `${x}\n + --\n + ${tooltipText[x]}\n + `.replace(/\s{2,}/g, "\n"); + }); + + let yAxesArea = svg.append("g") + .attr("transform", `translate(0, ${xAxisHeight})`); + + series.forEach((serie, seriesIndex) => { + let yExtent = d3.extent(Object.values(serie.data)), + yAxis = d3.scaleLinear() + .domain(yExtent) + .range([seriesHeight, 0]); + + let seriesGroup = chartArea + .append("g") + .attr("transform", `translate(0, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`); + + let path = ""; + xValues.forEach((x, xIndex) => { + let nextX = xValues[xIndex + 1], + y = serie.data[x], + nextY= serie.data[nextX]; + + if (typeof y === "undefined" || typeof nextY === "undefined") return; + + x = xAxis(x); nextX = xAxis(nextX); + y = yAxis(y); nextY = yAxis(nextY); + + path += `M ${x} ${y} L ${nextX} ${nextY} z `; + }); + + seriesGroup + .append("path") + .attr("d", path) + .attr("fill", "none") + .attr("stroke", seriesColours[seriesIndex % seriesColours.length]) + .attr("stroke-width", "1"); + + xValues.forEach(x => { + let y = serie.data[x]; + if (typeof y === "undefined") return; + + seriesGroup + .append("circle") + .attr("cx", xAxis(x)) + .attr("cy", yAxis(y)) + .attr("r", pipRadius) + .attr("fill", seriesColours[seriesIndex % seriesColours.length]) + .append("title") + .text(d => { + return `${x}\n + --\n + ${tooltipText[x]}\n + `.replace(/\s{2,}/g, "\n"); + }); + }); + + yAxesArea + .append("g") + .attr("transform", `translate(${seriesLabelWidth - interSeriesPadding}, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).ticks(5)); + + yAxesArea + .append("g") + .attr("transform", `translate(0, ${seriesHeight / 2 + seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) + .append("text") + .style("text-anchor", "middle") + .attr("transform", "rotate(-90)") + .text(serie.name); + }); + + return svg._groups[0][0].outerHTML; + }, }; export default Charts; From 59877b51389da0471c126dce242cd01c19688c31 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 13 Apr 2018 12:14:40 +0100 Subject: [PATCH 012/687] Exporing options with API. --- package.json | 3 +- src/core/Dish.mjs | 20 +++++++ src/core/operations/ToBase32.mjs | 2 - src/node/Wrapper.mjs | 99 ++++++++++++++++++++++++++++++++ src/node/index.mjs | 42 +++++++++----- 5 files changed, 149 insertions(+), 17 deletions(-) create mode 100644 src/node/Wrapper.mjs diff --git a/package.json b/package.json index c44a3a1f..ff567519 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "build": "grunt prod", "test": "grunt test", "docs": "grunt docs", - "lint": "grunt lint" + "lint": "grunt lint", + "build-node": "grunt node" } } diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 6aeaf3e9..08c7206a 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -240,6 +240,26 @@ class Dish { } } + /** + * + */ + findType() { + if (!this.value) { + throw "Dish has no value"; + } + + const types = [Dish.BYTE_ARRAY, Dish.STRING, Dish.HTML, Dish.NUMBER, Dish.ARRAY_BUFFER, Dish.BIG_NUMBER, Dish.LIST_FILE]; + + types.find((type) => { + this.type = type; + if (this.valid()) { + return true; + } + }); + + return this.type; + } + /** * Determines how much space the Dish takes up. diff --git a/src/core/operations/ToBase32.mjs b/src/core/operations/ToBase32.mjs index 1b217a34..632d93e4 100644 --- a/src/core/operations/ToBase32.mjs +++ b/src/core/operations/ToBase32.mjs @@ -45,7 +45,6 @@ class ToBase32 extends Operation { chr1, chr2, chr3, chr4, chr5, enc1, enc2, enc3, enc4, enc5, enc6, enc7, enc8, i = 0; - while (i < input.length) { chr1 = input[i++]; chr2 = input[i++]; @@ -76,7 +75,6 @@ class ToBase32 extends Operation { alphabet.charAt(enc4) + alphabet.charAt(enc5) + alphabet.charAt(enc6) + alphabet.charAt(enc7) + alphabet.charAt(enc8); } - return output; } diff --git a/src/node/Wrapper.mjs b/src/node/Wrapper.mjs new file mode 100644 index 00000000..33f34b7b --- /dev/null +++ b/src/node/Wrapper.mjs @@ -0,0 +1,99 @@ +/** + * Wrap operations in a + * + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import Dish from "../core/Dish"; +import log from "loglevel"; + +/** + * + */ +export default class Wrapper { + + /** + * + * @param arg + */ + extractArg(arg) { + if (arg.type === "option" || arg.type === "editableOption") { + return arg.value[0]; + } + + return arg.value; + } + + /** + * + */ + wrap(operation) { + this.operation = new operation(); + // This for just exposing run function: + // return this.run.bind(this); + + /** + * + * @param input + * @param args + */ + const _run = async(input, args=null) => { + const dish = new Dish(input); + + try { + dish.findType(); + } catch (e) { + log.debug(e); + } + + if (!args) { + args = this.operation.args.map(this.extractArg); + } else { + // Allows single arg ops to have arg defined not in array + if (!(args instanceof Array)) { + args = [args]; + } + } + const transformedInput = await dish.get(Dish.typeEnum(this.operation.inputType)); + return this.operation.innerRun(transformedInput, args); + }; + + // There's got to be a nicer way to do this! + this.operation.innerRun = this.operation.run; + this.operation.run = _run; + + return this.operation; + } + + /** + * + * @param input + */ + async run(input, args = null) { + const dish = new Dish(input); + + try { + dish.findType(); + } catch (e) { + log.debug(e); + } + + if (!args) { + args = this.operation.args.map(this.extractArg); + } else { + // Allows single arg ops to have arg defined not in array + if (!(args instanceof Array)) { + args = [args]; + } + } + + const transformedInput = await dish.get(Dish.typeEnum(this.operation.inputType)); + return this.operation.run(transformedInput, args); + + + + + } +} diff --git a/src/node/index.mjs b/src/node/index.mjs index c6e86c68..3dfda7d7 100644 --- a/src/node/index.mjs +++ b/src/node/index.mjs @@ -18,22 +18,36 @@ global.ENVIRONMENT_IS_WEB = function() { return typeof window === "object"; }; -import Chef from "../core/Chef"; +// import Chef from "../core/Chef"; -const CyberChef = { +// const CyberChef = { - bake: function(input, recipeConfig) { - this.chef = new Chef(); - return this.chef.bake( - input, - recipeConfig, - {}, - 0, - false - ); +// bake: function(input, recipeConfig) { +// this.chef = new Chef(); +// return this.chef.bake( +// input, +// recipeConfig, +// {}, +// 0, +// false +// ); +// } + +// }; + +// export default CyberChef; +// export {CyberChef}; + +import Wrapper from "./Wrapper"; + +import * as operations from "../core/operations/index"; + +const cyberChef = { + base32: { + from: new Wrapper().wrap(operations.FromBase32), + to: new Wrapper().wrap(operations.ToBase32), } - }; -export default CyberChef; -export {CyberChef}; +export default cyberChef; +export {cyberChef}; From fca4ed70131c57915e0119539c7adcd86986dbf7 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 20 Apr 2018 10:55:17 +0100 Subject: [PATCH 013/687] simplified API --- src/node/Wrapper.mjs | 85 +++++++++++--------------------------------- src/node/index.mjs | 45 +++++++++-------------- 2 files changed, 37 insertions(+), 93 deletions(-) diff --git a/src/node/Wrapper.mjs b/src/node/Wrapper.mjs index 33f34b7b..66876295 100644 --- a/src/node/Wrapper.mjs +++ b/src/node/Wrapper.mjs @@ -10,68 +10,28 @@ import Dish from "../core/Dish"; import log from "loglevel"; /** - * + * Extract default arg value from operation argument + * @param {Object} arg - an arg from an operation */ -export default class Wrapper { - - /** - * - * @param arg - */ - extractArg(arg) { - if (arg.type === "option" || arg.type === "editableOption") { - return arg.value[0]; - } - - return arg.value; +function extractArg(arg) { + if (arg.type === "option" || arg.type === "editableOption") { + return arg.value[0]; } + return arg.value; +} + +/** + * Wrap an operation to be consumed by node API. + * new Operation().run() becomes operation() + * @param Operation + */ +export default function wrap(Operation) { /** * */ - wrap(operation) { - this.operation = new operation(); - // This for just exposing run function: - // return this.run.bind(this); - - /** - * - * @param input - * @param args - */ - const _run = async(input, args=null) => { - const dish = new Dish(input); - - try { - dish.findType(); - } catch (e) { - log.debug(e); - } - - if (!args) { - args = this.operation.args.map(this.extractArg); - } else { - // Allows single arg ops to have arg defined not in array - if (!(args instanceof Array)) { - args = [args]; - } - } - const transformedInput = await dish.get(Dish.typeEnum(this.operation.inputType)); - return this.operation.innerRun(transformedInput, args); - }; - - // There's got to be a nicer way to do this! - this.operation.innerRun = this.operation.run; - this.operation.run = _run; - - return this.operation; - } - - /** - * - * @param input - */ - async run(input, args = null) { + return async (input, args=null) => { + const operation = new Operation(); const dish = new Dish(input); try { @@ -81,19 +41,14 @@ export default class Wrapper { } if (!args) { - args = this.operation.args.map(this.extractArg); + args = operation.args.map(extractArg); } else { // Allows single arg ops to have arg defined not in array if (!(args instanceof Array)) { args = [args]; } } - - const transformedInput = await dish.get(Dish.typeEnum(this.operation.inputType)); - return this.operation.run(transformedInput, args); - - - - - } + const transformedInput = await dish.get(Dish.typeEnum(operation.inputType)); + return operation.run(transformedInput, args); + }; } diff --git a/src/node/index.mjs b/src/node/index.mjs index 3dfda7d7..8900fbd4 100644 --- a/src/node/index.mjs +++ b/src/node/index.mjs @@ -18,36 +18,25 @@ global.ENVIRONMENT_IS_WEB = function() { return typeof window === "object"; }; -// import Chef from "../core/Chef"; -// const CyberChef = { - -// bake: function(input, recipeConfig) { -// this.chef = new Chef(); -// return this.chef.bake( -// input, -// recipeConfig, -// {}, -// 0, -// false -// ); -// } - -// }; - -// export default CyberChef; -// export {CyberChef}; - -import Wrapper from "./Wrapper"; +import wrap from "./Wrapper"; import * as operations from "../core/operations/index"; -const cyberChef = { - base32: { - from: new Wrapper().wrap(operations.FromBase32), - to: new Wrapper().wrap(operations.ToBase32), - } -}; +/** + * + * @param name + */ +function decapitalise(name) { + return `${name.charAt(0).toLowerCase()}${name.substr(1)}`; +} -export default cyberChef; -export {cyberChef}; + +// console.log(operations); +const chef = {}; +Object.keys(operations).forEach(op => + chef[decapitalise(op)] = wrap(operations[op])); + + +export default chef; +export {chef}; From b8b98358d0643abe42dc7b5c0b4aafd3120c18e4 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 20 Apr 2018 12:23:20 +0100 Subject: [PATCH 014/687] function tidy, add comments --- src/node/Wrapper.mjs | 54 -------------------- src/node/apiUtils.mjs | 112 ++++++++++++++++++++++++++++++++++++++++++ src/node/index.mjs | 22 +++------ 3 files changed, 119 insertions(+), 69 deletions(-) delete mode 100644 src/node/Wrapper.mjs create mode 100644 src/node/apiUtils.mjs diff --git a/src/node/Wrapper.mjs b/src/node/Wrapper.mjs deleted file mode 100644 index 66876295..00000000 --- a/src/node/Wrapper.mjs +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Wrap operations in a - * - * @author d98762625 [d98762625@gmail.com] - * @copyright Crown Copyright 2018 - * @license Apache-2.0 - */ - -import Dish from "../core/Dish"; -import log from "loglevel"; - -/** - * Extract default arg value from operation argument - * @param {Object} arg - an arg from an operation - */ -function extractArg(arg) { - if (arg.type === "option" || arg.type === "editableOption") { - return arg.value[0]; - } - - return arg.value; -} - -/** - * Wrap an operation to be consumed by node API. - * new Operation().run() becomes operation() - * @param Operation - */ -export default function wrap(Operation) { - /** - * - */ - return async (input, args=null) => { - const operation = new Operation(); - const dish = new Dish(input); - - try { - dish.findType(); - } catch (e) { - log.debug(e); - } - - if (!args) { - args = operation.args.map(extractArg); - } else { - // Allows single arg ops to have arg defined not in array - if (!(args instanceof Array)) { - args = [args]; - } - } - const transformedInput = await dish.get(Dish.typeEnum(operation.inputType)); - return operation.run(transformedInput, args); - }; -} diff --git a/src/node/apiUtils.mjs b/src/node/apiUtils.mjs new file mode 100644 index 00000000..f8457247 --- /dev/null +++ b/src/node/apiUtils.mjs @@ -0,0 +1,112 @@ +/** + * Wrap operations for consumption in Node + * + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import Dish from "../core/Dish"; +import log from "loglevel"; + +/** + * Extract default arg value from operation argument + * @param {Object} arg - an arg from an operation + */ +function extractArg(arg) { + if (arg.type === "option" || arg.type === "editableOption") { + return arg.value[0]; + } + + return arg.value; +} + +/** + * Wrap an operation to be consumed by node API. + * new Operation().run() becomes operation() + * Perform type conversion on input + * @param {Operation} Operation + * @returns {Function} The operation's run function, wrapped in + * some type conversion logic + */ +export function wrap(Operation) { + /** + * Wrapped operation run function + */ + return async (input, args=null) => { + const operation = new Operation(); + const dish = new Dish(input); + + try { + dish.findType(); + } catch (e) { + log.debug(e); + } + + if (!args) { + args = operation.args.map(extractArg); + } else { + // Allows single arg ops to have arg defined not in array + if (!(args instanceof Array)) { + args = [args]; + } + } + const transformedInput = await dish.get(Dish.typeEnum(operation.inputType)); + return operation.run(transformedInput, args); + }; +} + +/** + * + * @param searchTerm + */ +export function search(searchTerm) { + +} + + +/** + * Extract properties from an operation by instantiating it and + * returning some of its properties for reference. + * @param {Operation} Operation - the operation to extract info from + * @returns {Object} operation properties + */ +function extractOperationInfo(Operation) { + const operation = new Operation(); + return { + name: operation.name, + module: operation.module, + description: operation.description, + inputType: operation.inputType, + outputType: operation.outputType, + args: Object.assign([], operation.args), + }; +} + + +/** + * @param {Object} operations - an object filled with operations. + * @param {String} searchTerm - the name of the operation to get help for. + * Case and whitespace are ignored in search. + * @returns {Object} listing properties of function + */ +export function help(operations, searchTerm) { + if (typeof searchTerm === "string") { + const operation = operations[Object.keys(operations).find(o => + o.toLowerCase() === searchTerm.replace(/ /g, "").toLowerCase())]; + if (operation) { + return extractOperationInfo(operation); + } + } + return null; +} + + +/** + * SomeName => someName + * @param {String} name - string to be altered + * @returns {String} decapitalised + */ +export function decapitalise(name) { + return `${name.charAt(0).toLowerCase()}${name.substr(1)}`; +} diff --git a/src/node/index.mjs b/src/node/index.mjs index 8900fbd4..1125685a 100644 --- a/src/node/index.mjs +++ b/src/node/index.mjs @@ -2,11 +2,14 @@ * Node view for CyberChef. * * @author n1474335 [n1474335@gmail.com] - * @copyright Crown Copyright 2017 + * @copyright Crown Copyright 2018 * @license Apache-2.0 */ import "babel-polyfill"; +import {wrap, help, decapitalise} from "./apiUtils"; +import * as operations from "../core/operations/index"; + // Define global environment functions global.ENVIRONMENT_IS_WORKER = function() { return typeof importScripts === "function"; @@ -19,24 +22,13 @@ global.ENVIRONMENT_IS_WEB = function() { }; -import wrap from "./Wrapper"; - -import * as operations from "../core/operations/index"; - -/** - * - * @param name - */ -function decapitalise(name) { - return `${name.charAt(0).toLowerCase()}${name.substr(1)}`; -} - - -// console.log(operations); const chef = {}; + +// Add in wrapped operations with camelCase names Object.keys(operations).forEach(op => chef[decapitalise(op)] = wrap(operations[op])); +chef.help = help.bind(null, operations); export default chef; export {chef}; From d5b5443a840bad6d5dcfb290eb6a00e123baea71 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 27 Apr 2018 09:01:45 +0100 Subject: [PATCH 015/687] update readme --- .github/CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6064121c..e90196e5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -22,7 +22,7 @@ Before your contributions can be accepted, you must: * Line endings: UNIX style (\n) -## Design Principals +## Design Principles 1. If at all possible, all operations and features should be client-side and not rely on connections to an external server. This increases the utility of CyberChef on closed networks and in virtual machines that are not connected to the Internet. Calls to external APIs may be accepted if there is no other option, but not for critical components. 2. Latency should be kept to a minimum to enhance the user experience. This means that all operation code should sit on the client, rather than being loaded dynamically from a server. @@ -30,7 +30,7 @@ Before your contributions can be accepted, you must: 4. Minimise the use of large libraries, especially for niche operations that won't be used very often - these will be downloaded by everyone using the app, whether they use that operation or not (due to principal 2). -With these principals in mind, any changes or additions to CyberChef should keep it: +With these principles in mind, any changes or additions to CyberChef should keep it: - Standalone - Efficient From fbec0a1c7d5da7db3257dc8de6507284663c332d Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sat, 21 Apr 2018 12:25:48 +0100 Subject: [PATCH 016/687] The raw, unpresented dish is now returned to the app after baking, where it can be retrieved as various different data types. --- src/core/Chef.mjs | 22 ++++++- src/core/ChefWorker.js | 19 ++++++ src/core/Dish.mjs | 17 +++-- src/core/FlowControl.js | 4 +- src/core/Recipe.mjs | 26 +++++--- src/core/Utils.mjs | 37 ++++------- src/core/config/scripts/generateConfig.mjs | 2 +- src/web/HighlighterWaiter.js | 4 +- src/web/Manager.js | 1 - src/web/OutputWaiter.js | 75 ++++++++++++++-------- src/web/WorkerWaiter.js | 28 ++++++++ src/web/stylesheets/utils/_overrides.css | 1 + 12 files changed, 163 insertions(+), 73 deletions(-) diff --git a/src/core/Chef.mjs b/src/core/Chef.mjs index a935d75c..79172479 100755 --- a/src/core/Chef.mjs +++ b/src/core/Chef.mjs @@ -89,7 +89,14 @@ class Chef { const threshold = (options.ioDisplayThreshold || 1024) * 1024; const returnType = this.dish.size > threshold ? Dish.ARRAY_BUFFER : Dish.STRING; + // Create a raw version of the dish, unpresented + const rawDish = new Dish(this.dish); + + // Present the raw result + await recipe.present(this.dish); + return { + dish: rawDish, result: this.dish.type === Dish.HTML ? await this.dish.get(Dish.HTML, notUTF8) : await this.dish.get(returnType, notUTF8), @@ -123,7 +130,7 @@ class Chef { const startTime = new Date().getTime(), recipe = new Recipe(recipeConfig), - dish = new Dish("", Dish.STRING); + dish = new Dish(); try { recipe.execute(dish); @@ -167,6 +174,19 @@ class Chef { }; } + + /** + * Translates the dish to a specified type and returns it. + * + * @param {Dish} dish + * @param {string} type + * @returns {Dish} + */ + async getDishAs(dish, type) { + const newDish = new Dish(dish); + return await newDish.get(type); + } + } export default Chef; diff --git a/src/core/ChefWorker.js b/src/core/ChefWorker.js index 604189e7..dbbda126 100644 --- a/src/core/ChefWorker.js +++ b/src/core/ChefWorker.js @@ -60,6 +60,9 @@ self.addEventListener("message", function(e) { case "silentBake": silentBake(r.data); break; + case "getDishAs": + getDishAs(r.data); + break; case "docURL": // Used to set the URL of the current document so that scripts can be // imported into an inline worker. @@ -125,6 +128,22 @@ function silentBake(data) { } +/** + * Translates the dish to a given type. + */ +async function getDishAs(data) { + const value = await self.chef.getDishAs(data.dish, data.type); + + self.postMessage({ + action: "dishReturned", + data: { + value: value, + id: data.id + } + }); +} + + /** * Checks that all required modules are loaded and loads them if not. * diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 08c7206a..888432da 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -17,14 +17,17 @@ class Dish { /** * Dish constructor * - * @param {byteArray|string|number|ArrayBuffer|BigNumber} [value=null] - * - The value of the input data. - * @param {number} [type=Dish.BYTE_ARRAY] - * - The data type of value, see Dish enums. + * @param {Dish} [dish=null] - A dish to clone */ - constructor(value=null, type=Dish.BYTE_ARRAY) { - this.value = value; - this.type = type; + constructor(dish=null) { + this.value = []; + this.type = Dish.BYTE_ARRAY; + + if (dish && + dish.hasOwnProperty("value") && + dish.hasOwnProperty("type")) { + this.set(dish.value, dish.type); + } } diff --git a/src/core/FlowControl.js b/src/core/FlowControl.js index 92440c49..f1bb8dab 100755 --- a/src/core/FlowControl.js +++ b/src/core/FlowControl.js @@ -68,7 +68,9 @@ const FlowControl = { op.ingValues = JSON.parse(JSON.stringify(ingValues[i])); }); - const dish = new Dish(inputs[i], inputType); + const dish = new Dish(); + dish.set(inputs[i], inputType); + try { progress = await recipe.execute(dish, 0, state); } catch (err) { diff --git a/src/core/Recipe.mjs b/src/core/Recipe.mjs index 006e431c..95dab22b 100755 --- a/src/core/Recipe.mjs +++ b/src/core/Recipe.mjs @@ -130,10 +130,12 @@ class Recipe { * - The final progress through the recipe */ async execute(dish, startFrom=0, forkState={}) { - let op, input, output, lastRunOp, + let op, input, output, numJumps = 0, numRegisters = forkState.numRegisters || 0; + if (startFrom === 0) this.lastRunOp = null; + log.debug(`[*] Executing recipe of ${this.opList.length} operations, starting at ${startFrom}`); for (let i = startFrom; i < this.opList.length; i++) { @@ -169,7 +171,7 @@ class Recipe { numRegisters = state.numRegisters; } else { output = await op.run(input, op.ingValues); - lastRunOp = op; + this.lastRunOp = op; dish.set(output, op.outputType); } } catch (err) { @@ -188,18 +190,24 @@ class Recipe { } } - // Present the results of the final operation - if (lastRunOp) { - // TODO try/catch - output = await lastRunOp.present(output); - dish.set(output, lastRunOp.presentType); - } - log.debug("Recipe complete"); return this.opList.length; } + /** + * Present the results of the final operation. + * + * @param {Dish} dish + */ + async present(dish) { + if (!this.lastRunOp) return; + + const output = await this.lastRunOp.present(await dish.get(this.lastRunOp.outputType)); + dish.set(output, this.lastRunOp.presentType); + } + + /** * Returns the recipe configuration in string format. * diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index 88cfa52e..f90bc394 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -7,7 +7,7 @@ import utf8 from "utf8"; import moment from "moment-timezone"; import {fromBase64} from "./lib/Base64"; -import {toHexFast, fromHex} from "./lib/Hex"; +import {fromHex} from "./lib/Hex"; /** @@ -833,39 +833,24 @@ class Utils { const formatFile = async function(file, i) { const buff = await Utils.readFile(file); - const fileStr = Utils.arrayBufferToStr(buff.buffer); const blob = new Blob( [buff], {type: "octet/stream"} ); - const blobUrl = URL.createObjectURL(blob); - - const viewFileElem = ``; - - const downloadFileElem = `💾`; - - const hexFileData = toHexFast(buff); - - const switchToInputElem = ``; const html = `
-
+
+ +
diff --git a/src/web/static/images/bombe.svg b/src/web/static/images/bombe.svg index 40857bdf..1fdca842 100644 --- a/src/web/static/images/bombe.svg +++ b/src/web/static/images/bombe.svg @@ -56,13 +56,13 @@ - + diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 0a1e4ec4..d4af353f 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -80,11 +80,13 @@ transition: all 0.5s ease; } -#output-loader .loader { +#output-loader-animation { + display: block; + position: absolute; width: 60%; height: 60%; - left: unset; top: 10%; + transition: all 0.5s ease; } #output-loader .loading-msg { diff --git a/src/web/stylesheets/preloader.css b/src/web/stylesheets/preloader.css index bce0cd03..690fe5c1 100755 --- a/src/web/stylesheets/preloader.css +++ b/src/web/stylesheets/preloader.css @@ -16,25 +16,54 @@ background-color: var(--secondary-border-colour); } -#loader-wrapper div { - animation: fadeIn 1s ease-in 0s; -} - .loader { display: block; + position: relative; + left: 50%; + top: 50%; + width: 150px; + height: 150px; + margin: -75px 0 0 -75px; + + border: 3px solid transparent; + border-top-color: #3498db; + border-radius: 50%; + + animation: spin 2s linear infinite; +} + +.loader:before, +.loader:after { + content: ""; position: absolute; - left: calc(50% - 200px); - top: calc(50% - 160px); - width: 400px; - height: 260px; + border: 3px solid transparent; + border-radius: 50%; +} + +.loader:before { + top: 5px; + left: 5px; + right: 5px; + bottom: 5px; + border-top-color: #e74c3c; + animation: spin 3s linear infinite; +} + +.loader:after { + top: 13px; + left: 13px; + right: 13px; + bottom: 13px; + border-top-color: #f9c922; + animation: spin 1.5s linear infinite; } .loading-msg { display: block; - position: absolute; + position: relative; width: 400px; left: calc(50% - 200px); - top: calc(50% + 110px); + top: calc(50% + 50px); text-align: center; opacity: 0; font-size: 18px; @@ -116,6 +145,15 @@ /* Animations */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + @keyframes bump { from { opacity: 0; From 237f792fb4b1d7c414586665375df9f7d6fccf6f Mon Sep 17 00:00:00 2001 From: j433866 Date: Fri, 18 Jan 2019 11:19:06 +0000 Subject: [PATCH 128/687] Add new Show on map operation --- src/core/operations/ShowOnMap.mjs | 111 ++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/core/operations/ShowOnMap.mjs diff --git a/src/core/operations/ShowOnMap.mjs b/src/core/operations/ShowOnMap.mjs new file mode 100644 index 00000000..a30d59f5 --- /dev/null +++ b/src/core/operations/ShowOnMap.mjs @@ -0,0 +1,111 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import {FORMATS, convertCoordinates} from "../lib/ConvertCoordinates"; +import OperationError from "../errors/OperationError"; + +/** + * Show on map operation + */ +class ShowOnMap extends Operation { + + /** + * ShowOnMap constructor + */ + constructor() { + super(); + + this.name = "Show on map"; + this.module = "Hashing"; + this.description = "Displays co-ordinates on an OpenStreetMap slippy map.

Co-ordinates will be converted to decimal degrees before being shown on the map.

Supported formats:
  • Degrees Minutes Seconds (DMS)
  • Degrees Decimal Minutes (DDM)
  • Decimal Degrees (DD)
  • Geohash
  • Military Grid Reference System (MGRS)
  • Ordnance Survey National Grid (OSNG)
  • Universal Transverse Mercator (UTM)

This operation will not work offline."; + this.infoURL = ""; + this.inputType = "string"; + this.outputType = "string"; + this.presentType = "html"; + this.args = [ + { + name: "Zoom Level", + type: "number", + value: 13 + }, + { + name: "Input Format", + type: "option", + value: ["Auto"].concat(FORMATS) + }, + { + name: "Input Delimiter", + type: "option", + value: [ + "Auto", + "Direction Preceding", + "Direction Following", + "\\n", + "Comma", + "Semi-colon", + "Colon" + ] + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + // Pass the input through, don't need to do anything to it here + return input; + } + + /** + * @param {string} data + * @param {Object[]} args + * @returns {string} + */ + async present(data, args) { + if (data.replace(/\s+/g, "") !== "") { + const [zoomLevel, inFormat, inDelim] = args; + let latLong; + try { + latLong = convertCoordinates(data, inFormat, inDelim, "Decimal Degrees", "Comma", "None", 5); + } catch (error) { + throw new OperationError(error); + } + latLong = latLong.replace(/[,]$/, ""); + latLong = latLong.replace(/°/g, ""); + const tileUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + tileAttribution = "© OpenStreetMap contributors", + leafletUrl = "https://unpkg.com/leaflet@1.4.0/dist/leaflet.js", + leafletCssUrl = "https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"; + return ` + +
+`; + } else { + // Don't do anything if there's no input + return ""; + } + } +} + +export default ShowOnMap; From b491b9d77d98658375959dd328f7bd62023c3a4c Mon Sep 17 00:00:00 2001 From: j433866 Date: Fri, 18 Jan 2019 11:31:53 +0000 Subject: [PATCH 129/687] Move conversion of co-ordinates to run() instead of present() --- src/core/operations/ShowOnMap.mjs | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/core/operations/ShowOnMap.mjs b/src/core/operations/ShowOnMap.mjs index a30d59f5..fe0e2484 100644 --- a/src/core/operations/ShowOnMap.mjs +++ b/src/core/operations/ShowOnMap.mjs @@ -59,7 +59,19 @@ class ShowOnMap extends Operation { * @returns {string} */ run(input, args) { - // Pass the input through, don't need to do anything to it here + if (input.replace(/\s+/g, "") !== "") { + const inFormat = args[1], + inDelim = args[2]; + let latLong; + try { + latLong = convertCoordinates(input, inFormat, inDelim, "Decimal Degrees", "Comma", "None", 5); + } catch (error) { + throw new OperationError(error); + } + latLong = latLong.replace(/[,]$/, ""); + latLong = latLong.replace(/°/g, ""); + return latLong; + } return input; } @@ -70,15 +82,7 @@ class ShowOnMap extends Operation { */ async present(data, args) { if (data.replace(/\s+/g, "") !== "") { - const [zoomLevel, inFormat, inDelim] = args; - let latLong; - try { - latLong = convertCoordinates(data, inFormat, inDelim, "Decimal Degrees", "Comma", "None", 5); - } catch (error) { - throw new OperationError(error); - } - latLong = latLong.replace(/[,]$/, ""); - latLong = latLong.replace(/°/g, ""); + const zoomLevel = args[0]; const tileUrl = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", tileAttribution = "© OpenStreetMap contributors", leafletUrl = "https://unpkg.com/leaflet@1.4.0/dist/leaflet.js", @@ -90,13 +94,13 @@ class ShowOnMap extends Operation { var mapscript = document.createElement('script'); document.body.appendChild(mapscript); mapscript.onload = function() { - var presentMap = L.map('presentedMap').setView([${latLong}], ${zoomLevel}); + var presentMap = L.map('presentedMap').setView([${data}], ${zoomLevel}); L.tileLayer('${tileUrl}', { attribution: '${tileAttribution}' }).addTo(presentMap); - L.marker([${latLong}]).addTo(presentMap) - .bindPopup('${latLong}') + L.marker([${data}]).addTo(presentMap) + .bindPopup('${data}') .openPopup(); }; mapscript.src = "${leafletUrl}"; From 7522e5de332fe181489bd2a64dcb373e7c0e304e Mon Sep 17 00:00:00 2001 From: d98762625 Date: Wed, 23 Jan 2019 09:54:52 +0000 Subject: [PATCH 130/687] remove unnecessary operationConfig manipulation --- Gruntfile.js | 9 +-------- src/core/config/scripts/generateOpsIndex.mjs | 2 +- src/node/api.mjs | 2 +- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 3161b646..0f80b86e 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -462,15 +462,8 @@ module.exports = function (grunt) { generateNodeIndex: { command: [ "echo '\n--- Regenerating node index ---'", - // Why copy and wipe OperationConfig? - // OperationConfig.json needs to be empty for build to avoid circular dependency on DetectFileType. - // We copy it to node dir so that we can use it as a search corpus in chef.help. - "echo 'Copying OperationConfig.json and wiping original'", - "cp src/core/config/OperationConfig.json src/node/config/OperationConfig.json", + // Avoid cyclic dependency "echo 'export default {};\n' > src/core/config/modules/OpModules.mjs", - "echo '[]\n' > src/core/config/OperationConfig.json", - "echo '\n OperationConfig.json copied to src/node/config. Modules wiped.'", - "node --experimental-modules src/node/config/scripts/generateNodeIndex.mjs", "echo '--- Node index generated. ---\n'" ].join(";"), diff --git a/src/core/config/scripts/generateOpsIndex.mjs b/src/core/config/scripts/generateOpsIndex.mjs index 49cd635c..6038656f 100644 --- a/src/core/config/scripts/generateOpsIndex.mjs +++ b/src/core/config/scripts/generateOpsIndex.mjs @@ -24,7 +24,7 @@ if (!fs.existsSync(dir)) { // Find all operation files const opObjs = []; fs.readdirSync(path.join(dir, "../operations")).forEach(file => { - if (!file.endsWith(".mjs") || file === "index.mjs") return; + if (!file.endsWith(".mjs") || file === "index.mjs" || file === "DetectFileType.mjs" || file === "Fork.mjs" || file === "GenerateQRCode.mjs" || file === "Magic.mjs" || file === "ParseQRCode.mjs" || file === "PlayMedia.mjs" || file === "RenderImage.mjs" || file === "ScanForEmbeddedFiles.mjs" || file === "SplitColourChannels.mjs") return; opObjs.push(file.split(".mjs")[0]); }); diff --git a/src/node/api.mjs b/src/node/api.mjs index ac0ac205..b0cd8658 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -10,7 +10,7 @@ import SyncDish from "./SyncDish"; import NodeRecipe from "./NodeRecipe"; -import OperationConfig from "./config/OperationConfig.json"; +import OperationConfig from "../core/config/OperationConfig.json"; import { sanitise, removeSubheadingsFromArray, sentenceToCamelCase } from "./apiUtils"; import ExludedOperationError from "../core/errors/ExcludedOperationError"; From 781ff956e367b37ee2c050470d105488a152e43f Mon Sep 17 00:00:00 2001 From: d98762625 Date: Wed, 23 Jan 2019 10:06:55 +0000 Subject: [PATCH 131/687] add dev builds for node --- Gruntfile.js | 23 ++++++++++++++++++++++- package.json | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Gruntfile.js b/Gruntfile.js index 0f80b86e..412760ec 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -28,7 +28,11 @@ module.exports = function (grunt) { grunt.registerTask("node", "Compiles CyberChef into a single NodeJS module.", - ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:node", "webpack:nodeRepl", "chmod:build"]); + ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:node", "chmod:build"]); + + grunt.registerTask("node-prod", + "Compiles CyberChef into a single NodeJS module.", + ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:nodeProd", "webpack:nodeRepl", "chmod:build"]); grunt.registerTask("test", "A task which runs all the operation tests in the tests directory.", @@ -270,6 +274,23 @@ module.exports = function (grunt) { ] }, node: { + mode: "development", + target: "node", + entry: "./src/node/index.mjs", + externals: [NodeExternals({ + whitelist: ["crypto-api/src/crypto-api"] + })], + output: { + filename: "CyberChef.js", + path: __dirname + "/build/node", + library: "CyberChef", + libraryTarget: "commonjs2" + }, + plugins: [ + new webpack.DefinePlugin(BUILD_CONSTANTS) + ], + }, + nodeProd: { mode: "production", target: "node", entry: "./src/node/index.mjs", diff --git a/package.json b/package.json index 2fdc14c1..1d7a9bac 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,9 @@ }, "scripts": { "start": "grunt dev", + "start-node": "grunt node", "build": "grunt prod", + "build-node": "grunt node-prod", "test": "grunt test", "testui": "grunt testui", "docs": "grunt docs", From 220053c0444c6dfbcc785e61fe6188a5f937566a Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 7 Feb 2019 18:10:16 +0000 Subject: [PATCH 132/687] Typex: add ring setting --- src/core/lib/Typex.mjs | 9 ++++----- src/core/operations/Typex.mjs | 37 +++++++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/core/lib/Typex.mjs b/src/core/lib/Typex.mjs index df6e646b..b4cf297c 100644 --- a/src/core/lib/Typex.mjs +++ b/src/core/lib/Typex.mjs @@ -148,14 +148,13 @@ export class Rotor extends Enigma.Rotor { * @param {string} wiring - A 26 character string of the wiring order. * @param {string} steps - A 0..26 character string of stepping points. * @param {bool} reversed - Whether to reverse the rotor. + * @param {char} ringSetting - Ring setting of the rotor. * @param {char} initialPosition - The initial position of the rotor. */ - constructor(wiring, steps, reversed, initialPos) { - let initialPosMod = initialPos; + constructor(wiring, steps, reversed, ringSetting, initialPos) { let wiringMod = wiring; if (reversed) { - initialPosMod = Enigma.i2a(Utils.mod(26 - Enigma.a2i(initialPos), 26)); - const outMap = new Array(26).fill(); + const outMap = new Array(26); for (let i=0; i<26; i++) { // wiring[i] is the original output // Enigma.LETTERS[i] is the original input @@ -165,7 +164,7 @@ export class Rotor extends Enigma.Rotor { } wiringMod = outMap.join(""); } - super(wiringMod, steps, "A", initialPosMod); + super(wiringMod, steps, ringSetting, initialPos); } } diff --git a/src/core/operations/Typex.mjs b/src/core/operations/Typex.mjs index 79468645..504cb891 100644 --- a/src/core/operations/Typex.mjs +++ b/src/core/operations/Typex.mjs @@ -39,6 +39,11 @@ class Typex extends Operation { type: "boolean", value: false }, + { + name: "1st rotor ring setting", + type: "option", + value: LETTERS + }, { name: "1st rotor initial value", type: "option", @@ -55,6 +60,11 @@ class Typex extends Operation { type: "boolean", value: false }, + { + name: "2nd rotor ring setting", + type: "option", + value: LETTERS + }, { name: "2nd rotor initial value", type: "option", @@ -71,6 +81,11 @@ class Typex extends Operation { type: "boolean", value: false }, + { + name: "3rd rotor ring setting", + type: "option", + value: LETTERS + }, { name: "3rd rotor initial value", type: "option", @@ -87,6 +102,11 @@ class Typex extends Operation { type: "boolean", value: false }, + { + name: "4th rotor ring setting", + type: "option", + value: LETTERS + }, { name: "4th rotor initial value", type: "option", @@ -103,6 +123,11 @@ class Typex extends Operation { type: "boolean", value: false }, + { + name: "5th rotor ring setting", + type: "option", + value: LETTERS + }, { name: "5th rotor initial value", type: "option", @@ -156,14 +181,14 @@ class Typex extends Operation { * @returns {string} */ run(input, args) { - const reflectorstr = args[15]; - const plugboardstr = args[16]; - const typexKeyboard = args[17]; - const removeOther = args[18]; + const reflectorstr = args[20]; + const plugboardstr = args[21]; + const typexKeyboard = args[22]; + const removeOther = args[23]; const rotors = []; for (let i=0; i<5; i++) { - const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*3]); - rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*3 + 1], args[i*3+2])); + const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*4]); + rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*4 + 1], args[i*4+2], args[i*4+3])); } const reflector = new Reflector(reflectorstr); let plugboardstrMod = plugboardstr; From dd51b675b0c2f9bcb7f73e855bf0517e5d7b2e0e Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 8 Feb 2019 14:49:19 +0000 Subject: [PATCH 133/687] removed OpModules bodge from index generation scripts --- Gruntfile.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 412760ec..8bbd9eaa 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -471,9 +471,7 @@ module.exports = function (grunt) { generateConfig: { command: [ "echo '\n--- Regenerating config files. ---'", - // "node --experimental-modules src/core/config/scripts/generateOpsIndex.mjs", "mkdir -p src/core/config/modules", - "echo 'export default {};\n' > src/core/config/modules/OpModules.mjs", "echo '[]\n' > src/core/config/OperationConfig.json", "node --experimental-modules --no-warnings --no-deprecation src/core/config/scripts/generateOpsIndex.mjs", "node --experimental-modules --no-warnings --no-deprecation src/core/config/scripts/generateConfig.mjs", @@ -483,8 +481,6 @@ module.exports = function (grunt) { generateNodeIndex: { command: [ "echo '\n--- Regenerating node index ---'", - // Avoid cyclic dependency - "echo 'export default {};\n' > src/core/config/modules/OpModules.mjs", "node --experimental-modules src/node/config/scripts/generateNodeIndex.mjs", "echo '--- Node index generated. ---\n'" ].join(";"), From 53226c105069b97369e9a81ef23da2c88e0c9441 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Fri, 8 Feb 2019 00:59:56 +0000 Subject: [PATCH 134/687] Added populateMultiOption ingredient type --- .gitignore | 1 + src/core/operations/MultipleBombe.mjs | 94 +++++++----------------- src/web/HTMLIngredient.mjs | 49 +++++++++++- src/web/RecipeWaiter.mjs | 10 +++ tests/operations/tests/MultipleBombe.mjs | 14 ++-- 5 files changed, 92 insertions(+), 76 deletions(-) diff --git a/.gitignore b/.gitignore index edbcf679..b5aad5d0 100755 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ docs/* !docs/*.ico .vscode .*.swp +.DS_Store src/core/config/modules/* src/core/config/OperationConfig.json src/core/operations/index.mjs diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs index dac7a334..a453ca34 100644 --- a/src/core/operations/MultipleBombe.mjs +++ b/src/core/operations/MultipleBombe.mjs @@ -60,95 +60,57 @@ class MultipleBombe extends Operation { this.args = [ { "name": "Standard Enigmas", - "type": "populateOption", + "type": "populateMultiOption", "value": [ { name: "German Service Enigma (First - 3 rotor)", - value: rotorsFormat(ROTORS, 0, 5) + value: [ + rotorsFormat(ROTORS, 0, 5), + "", + rotorsFormat(REFLECTORS, 0, 1) + ] }, { name: "German Service Enigma (Second - 3 rotor)", - value: rotorsFormat(ROTORS, 0, 8) + value: [ + rotorsFormat(ROTORS, 0, 8), + "", + rotorsFormat(REFLECTORS, 0, 2) + ] }, { name: "German Service Enigma (Third - 4 rotor)", - value: rotorsFormat(ROTORS, 0, 8) + value: [ + rotorsFormat(ROTORS, 0, 8), + rotorsFormat(ROTORS_FOURTH, 1, 2), + rotorsFormat(REFLECTORS, 2, 3) + ] }, { name: "German Service Enigma (Fourth - 4 rotor)", - value: rotorsFormat(ROTORS, 0, 8) + value: [ + rotorsFormat(ROTORS, 0, 8), + rotorsFormat(ROTORS_FOURTH, 1, 3), + rotorsFormat(REFLECTORS, 2, 4) + ] }, { name: "User defined", - value: "" + value: ["", "", ""] }, ], - "target": 1 + "target": [1, 2, 3] }, { name: "Main rotors", type: "text", value: "" }, - { - "name": "Standard Enigmas", - "type": "populateOption", - "value": [ - { - name: "German Service Enigma (First - 3 rotor)", - value: "" - }, - { - name: "German Service Enigma (Second - 3 rotor)", - value: "" - }, - { - name: "German Service Enigma (Third - 4 rotor)", - value: rotorsFormat(ROTORS_FOURTH, 1, 2) - }, - { - name: "German Service Enigma (Fourth - 4 rotor)", - value: rotorsFormat(ROTORS_FOURTH, 1, 3) - }, - { - name: "User defined", - value: "" - }, - ], - "target": 3 - }, { name: "4th rotor", type: "text", value: "" }, - { - "name": "Standard Enigmas", - "type": "populateOption", - "value": [ - { - name: "German Service Enigma (First - 3 rotor)", - value: rotorsFormat(REFLECTORS, 0, 1) - }, - { - name: "German Service Enigma (Second - 3 rotor)", - value: rotorsFormat(REFLECTORS, 0, 2) - }, - { - name: "German Service Enigma (Third - 4 rotor)", - value: rotorsFormat(REFLECTORS, 2, 3) - }, - { - name: "German Service Enigma (Fourth - 4 rotor)", - value: rotorsFormat(REFLECTORS, 2, 4) - }, - { - name: "User defined", - value: "" - }, - ], - "target": 5 - }, { name: "Reflectors", type: "text", @@ -217,11 +179,11 @@ class MultipleBombe extends Operation { */ run(input, args) { const mainRotorsStr = args[1]; - const fourthRotorsStr = args[3]; - const reflectorsStr = args[5]; - let crib = args[6]; - const offset = args[7]; - const check = args[8]; + const fourthRotorsStr = args[2]; + const reflectorsStr = args[3]; + let crib = args[4]; + const offset = args[5]; + const check = args[6]; const rotors = []; const fourthRotors = []; const reflectors = []; diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index bb01d7de..c7c024fb 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -39,7 +39,7 @@ class HTMLIngredient { */ toHtml() { let html = "", - i, m; + i, m, eventFn; switch (this.type) { case "string": @@ -142,10 +142,11 @@ class HTMLIngredient { `; break; case "populateOption": + case "populateMultiOption": html += `
${this.hint ? "" + this.hint + "" : ""}
`; - this.manager.addDynamicListener("#" + this.id, "change", this.populateOptionChange, this); + eventFn = this.type === "populateMultiOption" ? + this.populateMultiOptionChange : + this.populateOptionChange; + this.manager.addDynamicListener("#" + this.id, "change", eventFn, this); break; case "editableOption": html += `
@@ -248,6 +255,9 @@ class HTMLIngredient { * @param {event} e */ populateOptionChange(e) { + e.preventDefault(); + e.stopPropagation(); + const el = e.target; const op = el.parentNode.parentNode; const target = op.querySelectorAll(".arg")[this.target]; @@ -260,6 +270,37 @@ class HTMLIngredient { } + /** + * Handler for populate multi option changes. + * Populates the relevant arguments with the specified values. + * + * @param {event} e + */ + populateMultiOptionChange(e) { + e.preventDefault(); + e.stopPropagation(); + + const el = e.target; + const op = el.parentNode.parentNode; + const args = op.querySelectorAll(".arg"); + const targets = this.target.map(i => args[i]); + const vals = JSON.parse(el.childNodes[el.selectedIndex].getAttribute("populate-value")); + const evt = new Event("change"); + + for (let i = 0; i < targets.length; i++) { + targets[i].value = vals[i]; + } + + // Fire change event after all targets have been assigned + this.manager.recipe.ingChange(); + + // Send change event for each target once all have been assigned, to update the label placement. + for (const target of targets) { + target.dispatchEvent(evt); + } + } + + /** * Handler for editable option clicks. * Populates the input box with the selected value. diff --git a/src/web/RecipeWaiter.mjs b/src/web/RecipeWaiter.mjs index a8326b27..4c568c8b 100755 --- a/src/web/RecipeWaiter.mjs +++ b/src/web/RecipeWaiter.mjs @@ -205,6 +205,7 @@ class RecipeWaiter { * @fires Manager#statechange */ ingChange(e) { + if (e && e.target && e.target.classList.contains("no-state-change")) return; window.dispatchEvent(this.manager.statechange); } @@ -392,6 +393,15 @@ class RecipeWaiter { this.buildRecipeOperation(item); document.getElementById("rec-list").appendChild(item); + // Trigger populateOption events + const populateOptions = item.querySelectorAll(".populate-option"); + const evt = new Event("change", {bubbles: true}); + if (populateOptions.length) { + for (const el of populateOptions) { + el.dispatchEvent(evt); + } + } + item.dispatchEvent(this.manager.operationadd); return item; } diff --git a/tests/operations/tests/MultipleBombe.mjs b/tests/operations/tests/MultipleBombe.mjs index f809859c..5c06ece4 100644 --- a/tests/operations/tests/MultipleBombe.mjs +++ b/tests/operations/tests/MultipleBombe.mjs @@ -16,9 +16,10 @@ TestRegister.addTests([ "op": "Multiple Bombe", "args": [ // I, II and III - "User defined", "EKMFLGDQVZNTOWYHXUSPAIBRCJ Date: Fri, 8 Feb 2019 11:53:58 +0000 Subject: [PATCH 135/687] Fixed Bombe svg animation in standalone version --- package-lock.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/package-lock.json b/package-lock.json index 57175720..14b14f1a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12074,6 +12074,28 @@ "resolved": "http://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" }, + "svg-url-loader": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/svg-url-loader/-/svg-url-loader-2.3.2.tgz", + "integrity": "sha1-3YaybBn+O5FPBOoQ7zlZTq3gRGQ=", + "dev": true, + "requires": { + "file-loader": "1.1.11", + "loader-utils": "1.1.0" + }, + "dependencies": { + "file-loader": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz", + "integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "schema-utils": "^0.4.5" + } + } + } + }, "symbol-tree": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz", From 1079080f5c024b13fca415ed85cdfb92ecf3e506 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Fri, 8 Feb 2019 15:21:14 +0000 Subject: [PATCH 136/687] Bombe results are now presented in a table --- src/core/operations/Bombe.mjs | 31 +++++++++++++++---- src/core/operations/MultipleBombe.mjs | 43 +++++++++++++++++++++------ 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs index 34a94e53..6b277a03 100644 --- a/src/core/operations/Bombe.mjs +++ b/src/core/operations/Bombe.mjs @@ -26,7 +26,8 @@ class Bombe extends Operation { this.description = "Emulation of the Bombe machine used to attack Enigma.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops, a large number of incorrect outputs will be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine."; this.infoURL = "https://wikipedia.org/wiki/Bombe"; this.inputType = "string"; - this.outputType = "string"; + this.outputType = "JSON"; + this.presentType = "html"; this.args = [ { name: "1st (right-hand) rotor", @@ -82,7 +83,7 @@ class Bombe extends Operation { * @param {number} progress - Progress (as a float in the range 0..1) */ updateStatus(nLoops, nStops, progress) { - const msg = `Bombe run with ${nLoops} loops in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done`; + const msg = `Bombe run with ${nLoops} loop${nLoops === 1 ? "" : "s"} in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done`; self.sendStatusMessage(msg); } @@ -128,11 +129,29 @@ class Bombe extends Operation { } const bombe = new BombeMachine(rotors, reflector, ciphertext, crib, check, update); const result = bombe.run(); - let msg = `Bombe run on menu with ${bombe.nLoops} loops (2+ desirable). Note: Rotor positions are listed left to right and start at the beginning of the crib, and ignore stepping and the ring setting. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided. Results:\n`; - for (const [setting, stecker, decrypt] of result) { - msg += `Stop: ${setting} (plugboard: ${stecker}): ${decrypt}\n`; + return { + nLoops: bombe.nLoops, + result: result + }; + } + + + /** + * Displays the Bombe results in an HTML table + * + * @param {Object} output + * @param {number} output.nLoops + * @param {Array[]} output.result + * @returns {html} + */ + present(output) { + let html = `Bombe run on menu with ${output.nLoops} loop${output.nLoops === 1 ? "" : "s"} (2+ desirable). Note: Rotor positions are listed left to right and start at the beginning of the crib, and ignore stepping and the ring setting. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided.\n\n`; + html += ""; + for (const [setting, stecker, decrypt] of output.result) { + html += `\n`; } - return msg; + html += "
Rotor stopsPartial plugboardDecryption preview
${setting}${stecker}${decrypt}
"; + return html; } } diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs index a453ca34..7a0ae2fd 100644 --- a/src/core/operations/MultipleBombe.mjs +++ b/src/core/operations/MultipleBombe.mjs @@ -56,7 +56,8 @@ class MultipleBombe extends Operation { this.description = "Emulation of the Bombe machine used to attack Enigma. This version carries out multiple Bombe runs to handle unknown rotor configurations.

You should test your menu on the single Bombe operation before running it here. See the description of the Bombe operation for instructions on choosing a crib."; this.infoURL = "https://wikipedia.org/wiki/Bombe"; this.inputType = "string"; - this.outputType = "string"; + this.outputType = "JSON"; + this.presentType = "html"; this.args = [ { "name": "Standard Enigmas", @@ -146,7 +147,7 @@ class MultipleBombe extends Operation { const hours = Math.floor(remaining / 3600); const minutes = `0${Math.floor((remaining % 3600) / 60)}`.slice(-2); const seconds = `0${Math.floor(remaining % 60)}`.slice(-2); - const msg = `Bombe run with ${nLoops} loops in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done, ${hours}:${minutes}:${seconds} remaining`; + const msg = `Bombe run with ${nLoops} loop${nLoops === 1 ? "" : "s"} in menu (2+ desirable): ${nStops} stops, ${Math.floor(100 * progress)}% done, ${hours}:${minutes}:${seconds} remaining`; self.sendStatusMessage(msg); } @@ -227,7 +228,7 @@ class MultipleBombe extends Operation { update = undefined; } let bombe = undefined; - let msg; + const output = {bombeRuns: []}; // I could use a proper combinatorics algorithm here... but it would be more code to // write one, and we don't seem to have one in our existing libraries, so massively nested // for loop it is @@ -253,7 +254,7 @@ class MultipleBombe extends Operation { } if (bombe === undefined) { bombe = new BombeMachine(runRotors, reflector, ciphertext, crib, check); - msg = `Bombe run on menu with ${bombe.nLoops} loops (2+ desirable). Note: Rotors and rotor positions are listed left to right, ignore stepping and the ring setting, and positions start at the beginning of the crib. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided. Results:\n`; + output.nLoops = bombe.nLoops; } else { bombe.changeRotors(runRotors, reflector); } @@ -263,17 +264,41 @@ class MultipleBombe extends Operation { update(bombe.nLoops, nStops, nRuns / totalRuns, start); } if (result.length > 0) { - msg += `\nRotors: ${runRotors.join(", ")}\nReflector: ${reflector.pairs}\n`; - for (const [setting, stecker, decrypt] of result) { - msg += `Stop: ${setting} (plugboard: ${stecker}): ${decrypt}\n`; - } + output.bombeRuns.push({ + rotors: runRotors, + reflector: reflector.pairs, + result: result + }); } } } } } } - return msg; + return output; + } + + + /** + * Displays the MultiBombe results in an HTML table + * + * @param {Object} output + * @param {number} output.nLoops + * @param {Array[]} output.result + * @returns {html} + */ + present(output) { + let html = `Bombe run on menu with ${output.nLoops} loop${output.nLoops === 1 ? "" : "s"} (2+ desirable). Note: Rotors and rotor positions are listed left to right, ignore stepping and the ring setting, and positions start at the beginning of the crib. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided.\n`; + + for (const run of output.bombeRuns) { + html += `\nRotors: ${run.rotors.join(", ")}\nReflector: ${run.reflector}\n`; + html += ""; + for (const [setting, stecker, decrypt] of run.result) { + html += `\n`; + } + html += "
Rotor stopsPartial plugboardDecryption preview
${setting}${stecker}${decrypt}
\n"; + } + return html; } } From 5a2a8b4c8ecb4b91b3876d31f6260159f5f9f80f Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Fri, 8 Feb 2019 18:08:13 +0000 Subject: [PATCH 137/687] Typex: input wiring is reversed --- src/core/lib/Typex.mjs | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/core/lib/Typex.mjs b/src/core/lib/Typex.mjs index b4cf297c..484a1e6b 100644 --- a/src/core/lib/Typex.mjs +++ b/src/core/lib/Typex.mjs @@ -2,6 +2,7 @@ * Emulation of the Typex machine. * * @author s2224834 + * @author The National Museum of Computing - Bombe Rebuild Project * @copyright Crown Copyright 2019 * @license Apache-2.0 */ @@ -28,7 +29,7 @@ export const ROTORS = [ * An example Typex reflector. Again, randomised. */ export const REFLECTORS = [ - {name: "Standard", value: "AN BC FG IE KD LU MH OR TS VZ WQ XJ YP"}, + {name: "Example", value: "AN BC FG IE KD LU MH OR TS VZ WQ XJ YP"}, ]; // Special character handling on Typex keyboard @@ -172,6 +173,8 @@ export class Rotor extends Enigma.Rotor { * Typex input plugboard. Based on a Rotor, because it allows arbitrary maps, not just switches * like the Enigma plugboard. * Not to be confused with the reflector plugboard. + * This is also where the Typex's backwards input wiring is implemented - it's a bit of a hack, but + * it means everything else continues to work like in the Enigma. */ export class Plugboard extends Enigma.Rotor { /** @@ -180,10 +183,45 @@ export class Plugboard extends Enigma.Rotor { * @param {string} wiring - 26 character string of mappings from A-Z, as per rotors, or "". */ constructor(wiring) { + // Typex input wiring is backwards vs Enigma: that is, letters enter the rotors in a + // clockwise order, vs. Enigma's anticlockwise (or vice versa depending on which side + // you're looking at it from). I'm doing the transform here to avoid having to rewrite + // the Engima crypt() method in Typex as well. + // Note that the wiring for the reflector is the same way around as Enigma, so no + // transformation is necessary on that side. + // We're going to achieve this by mapping the plugboard settings through an additional + // transform that mirrors the alphabet before we pass it to the superclass. + if (!/^[A-Z]{26}$/.test(wiring)) { + throw new OperationError("Plugboard wiring must be 26 unique uppercase letters"); + } + const reversed = "AZYXWVUTSRQPONMLKJIHGFEDCB"; + wiring = wiring.replace(/./g, x => { + return reversed[Enigma.a2i(x)]; + }); try { super(wiring, "", "A", "A"); } catch (err) { throw new OperationError(err.message.replace("Rotor", "Plugboard")); } } + + /** + * Transform a character through this rotor forwards. + * + * @param {number} c - The character. + * @returns {number} + */ + transform(c) { + return Utils.mod(this.map[Utils.mod(c + this.pos, 26)] - this.pos, 26); + } + + /** + * Transform a character through this rotor backwards. + * + * @param {number} c - The character. + * @returns {number} + */ + revTransform(c) { + return Utils.mod(this.revMap[Utils.mod(c + this.pos, 26)] - this.pos, 26); + } } From 5a8255a9f488817f44791bbc33cf23eb6988cc92 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Fri, 8 Feb 2019 19:25:28 +0000 Subject: [PATCH 138/687] Bombe: fix tests after output table patch --- tests/operations/tests/Bombe.mjs | 12 ++++++------ tests/operations/tests/MultipleBombe.mjs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/operations/tests/Bombe.mjs b/tests/operations/tests/Bombe.mjs index fca420d3..0f00f1be 100644 --- a/tests/operations/tests/Bombe.mjs +++ b/tests/operations/tests/Bombe.mjs @@ -11,7 +11,7 @@ TestRegister.addTests([ // Plugboard for this test is BO LC KE GA name: "Bombe: 3 rotor (self-stecker)", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA \(plugboard: SS\): VFISUSGTKSTMPSUNAK/, + expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -30,7 +30,7 @@ TestRegister.addTests([ // This test produces a menu that doesn't use the first letter, which is also a good test name: "Bombe: 3 rotor (other stecker)", input: "JBYALIHDYNUAAVKBYM", - expectedMatch: /LGA \(plugboard: AG\): QFIMUMAFKMQSKMYNGW/, + expectedMatch: /LGA<\/td>AG<\/td>QFIMUMAFKMQSKMYNGW<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -48,7 +48,7 @@ TestRegister.addTests([ { name: "Bombe: crib offset", input: "AAABBYFLTHHYIJQAYBBYS", // first three chars here are faked - expectedMatch: /LGA \(plugboard: SS\): VFISUSGTKSTMPSUNAK/, + expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -66,7 +66,7 @@ TestRegister.addTests([ { name: "Bombe: multiple stops", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA \(plugboard: TT\): VFISUSGTKSTMPSUNAK/, + expectedMatch: /LGA<\/td>TT<\/td>VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -84,7 +84,7 @@ TestRegister.addTests([ { name: "Bombe: checking machine", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /Stop: LGA \(plugboard: TT AG BO CL EK FF HH II JJ SS YY\): THISISATESTMESSAGE/, + expectedMatch: /LGA<\/td>TT AG BO CL EK FF HH II JJ SS YY<\/td>THISISATESTMESSAGE<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -103,7 +103,7 @@ TestRegister.addTests([ { name: "Bombe: 4 rotor", input: "LUOXGJSHGEDSRDOQQX", - expectedMatch: /LHSC \(plugboard: SS\): HHHSSSGQUUQPKSEKWK/, + expectedMatch: /LHSC<\/td>SS<\/td>HHHSSSGQUUQPKSEKWK<\/td>/, recipeConfig: [ { "op": "Bombe", diff --git a/tests/operations/tests/MultipleBombe.mjs b/tests/operations/tests/MultipleBombe.mjs index 5c06ece4..8e2cc685 100644 --- a/tests/operations/tests/MultipleBombe.mjs +++ b/tests/operations/tests/MultipleBombe.mjs @@ -10,7 +10,7 @@ TestRegister.addTests([ { name: "Multi-Bombe: 3 rotor", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA \(plugboard: SS\): VFISUSGTKSTMPSUNAK/, + expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Multiple Bombe", @@ -30,7 +30,7 @@ TestRegister.addTests([ { name: "Multi-Bombe: 4 rotor", input: "LUOXGJSHGEDSRDOQQX", - expectedMatch: /LHSC \(plugboard: SS\): HHHSSSGQUUQPKSEKWK/, + expectedMatch: /LHSC<\/td>SS<\/td>HHHSSSGQUUQPKSEKWK<\/td>/, recipeConfig: [ { "op": "Multiple Bombe", From 61fee3122a5eac968ecaa5fa4ed6107581ba07f8 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Fri, 8 Feb 2019 21:16:42 +0000 Subject: [PATCH 139/687] Bombe: add Rebuild Project to authors --- src/core/lib/Bombe.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/lib/Bombe.mjs b/src/core/lib/Bombe.mjs index 03413350..a4cf24f4 100644 --- a/src/core/lib/Bombe.mjs +++ b/src/core/lib/Bombe.mjs @@ -2,6 +2,7 @@ * Emulation of the Bombe machine. * * @author s2224834 + * @author The National Museum of Computing - Bombe Rebuild Project * @copyright Crown Copyright 2019 * @license Apache-2.0 */ From 069d4956aac93021bb1984eb618379c74062da37 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Sat, 9 Feb 2019 22:57:57 +0000 Subject: [PATCH 140/687] Bombe: Handle boxing stop correctly --- src/core/lib/Bombe.mjs | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/core/lib/Bombe.mjs b/src/core/lib/Bombe.mjs index a4cf24f4..ef796cd0 100644 --- a/src/core/lib/Bombe.mjs +++ b/src/core/lib/Bombe.mjs @@ -651,12 +651,34 @@ export class BombeMachine { // This means our hypothesis for the steckering is correct. steckerPair = this.testInput[1]; } else { - // If this happens a lot it implies the menu isn't good enough. We can't do - // anything useful with it as we don't have a stecker partner, so we'll just drop it - // and move on. This does risk eating the actual stop occasionally, but I've only seen - // this happen when the menu is bad enough we have thousands of stops, so I'm not sure - // it matters. - return undefined; + // This was known as a "boxing stop" - we have a stop but not a single hypothesis. + // If this happens a lot it implies the menu isn't good enough. + // If we have the checking machine enabled, we're going to just check each wire in + // turn. If we get 0 or 1 hit, great. + // If we get multiple hits, or the checking machine is off, the user will just have to + // deal with it. + if (!this.check) { + // We can't draw any conclusions about the steckering (one could maybe suggest + // options in some cases, but too hard to present clearly). + return [this.indicator.getPos(), "??", this.tryDecrypt("")]; + } + let stecker = undefined; + for (let i = 0; i < 26; i++) { + const newStecker = this.checkingMachine(i); + if (newStecker !== "") { + if (stecker !== undefined) { + // Multiple hypotheses can't be ruled out. + return [this.indicator.getPos(), "??", this.tryDecrypt("")]; + } + stecker = newStecker; + } + } + if (stecker === undefined) { + // Checking machine ruled all possibilities out. + return undefined; + } + // If we got here, there was just one possibility allowed by the checking machine. Success. + return [this.indicator.getPos(), stecker, this.tryDecrypt(stecker)]; } let stecker; if (this.check) { From dd9cbbac77ef5a8c8e85b14215b2501aad906b5b Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Sat, 9 Feb 2019 23:01:52 +0000 Subject: [PATCH 141/687] Bombe: add note about rotor step in crib --- src/core/lib/Bombe.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/lib/Bombe.mjs b/src/core/lib/Bombe.mjs index ef796cd0..122edd40 100644 --- a/src/core/lib/Bombe.mjs +++ b/src/core/lib/Bombe.mjs @@ -281,6 +281,14 @@ export class BombeMachine { * ciphertext. It will check that the crib is sane (length is vaguely sensible and there's no * matching characters between crib and ciphertext) but cannot check further - if it's wrong * your results will be wrong! + * + * There is also no handling of rotor stepping - if the target Enigma stepped in the middle of + * your crib, you're out of luck. TODO: Allow specifying a step point - this is fairly easy to + * configure on a real Bombe, but we're not clear on whether it was ever actually done for + * real (there would almost certainly have been better ways of attacking in most situations + * than attempting to exhaust options for the stepping point, but in some circumstances, e.g. + * via Banburismus, the stepping point might have been known). + * * @param {string[]} rotors - list of rotor spec strings (without step points!) * @param {Object} reflector - Reflector object * @param {string} ciphertext - The ciphertext to attack From 4db6199fd947e9a06d6bf59c3ea54070054cd273 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sun, 10 Feb 2019 21:00:36 +0000 Subject: [PATCH 142/687] Fixed timings for Bombe animation fast rotor --- src/web/static/images/bombe.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/static/images/bombe.svg b/src/web/static/images/bombe.svg index 1fdca842..1fd40554 100644 --- a/src/web/static/images/bombe.svg +++ b/src/web/static/images/bombe.svg @@ -23,7 +23,7 @@ const bbox = rotor.getBBox(); const x = bbox.width/2 + bbox.x; const y = bbox.height/2 + bbox.y; - const wait = row === 0 ? speed/26 : row === 1 ? speed : speed*26; + const wait = row === 0 ? speed/26/1.5 : row === 1 ? speed : speed*26; rotor.setAttribute("transform", "rotate(" + startPos + ", " + x + ", " + y + ")"); @@ -50,7 +50,7 @@ break; } } - }, speed/26 - 5); + }, speed/26/1.5 - 5); } // ]]> From c76322c40de79f7c009687eaa894229cc8cd9ed0 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Mon, 11 Feb 2019 17:21:16 +0000 Subject: [PATCH 143/687] force webpack to only emit one file when using dynamic import --- Gruntfile.js | 15 ++++++++++++--- package-lock.json | 5 +++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 48af8ea6..e2de1d9d 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -288,7 +288,10 @@ module.exports = function (grunt) { libraryTarget: "commonjs2" }, plugins: [ - new webpack.DefinePlugin(BUILD_CONSTANTS) + new webpack.DefinePlugin(BUILD_CONSTANTS), + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }) ], }, nodeProd: { @@ -305,7 +308,10 @@ module.exports = function (grunt) { libraryTarget: "commonjs2" }, plugins: [ - new webpack.DefinePlugin(BUILD_CONSTANTS) + new webpack.DefinePlugin(BUILD_CONSTANTS), + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }) ], optimization: { minimizer: [ @@ -333,7 +339,10 @@ module.exports = function (grunt) { libraryTarget: "commonjs2" }, plugins: [ - new webpack.DefinePlugin(BUILD_CONSTANTS) + new webpack.DefinePlugin(BUILD_CONSTANTS), + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 + }) ], optimization: { minimizer: [ diff --git a/package-lock.json b/package-lock.json index c8b30ab1..bba0c2c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -780,6 +780,11 @@ "regenerator-runtime": "^0.12.0" }, "dependencies": { + "core-js": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.4.tgz", + "integrity": "sha512-05qQ5hXShcqGkPZpXEFLIpxayZscVD2kuMBZewxiIPPEagukO4mqgPA9CWhUvFBJfy3ODdK2p9xyHh7FTU9/7A==" + }, "regenerator-runtime": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz", From aafde8986d679f8ee96133fcee75f53cbf25f904 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Wed, 13 Feb 2019 14:48:55 +0000 Subject: [PATCH 144/687] tidy up gruntfile for node --- Gruntfile.js | 30 +++--------------------------- package.json | 4 ++-- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index c80df6de..359723e5 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -28,11 +28,7 @@ module.exports = function (grunt) { grunt.registerTask("node", "Compiles CyberChef into a single NodeJS module.", - ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:node", "chmod:build"]); - - grunt.registerTask("node-prod", - "Compiles CyberChef into a single NodeJS module.", - ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:nodeProd", "webpack:nodeRepl", "chmod:build"]); + ["clean", "exec:generateConfig", "exec:generateNodeIndex", "webpack:node", "webpack:nodeRepl", "chmod:build"]); grunt.registerTask("test", "A task which runs all the operation tests in the tests directory.", @@ -275,27 +271,7 @@ module.exports = function (grunt) { ] }, node: { - mode: "development", - target: "node", - entry: "./src/node/index.mjs", - externals: [NodeExternals({ - whitelist: ["crypto-api/src/crypto-api"] - })], - output: { - filename: "CyberChef.js", - path: __dirname + "/build/node", - library: "CyberChef", - libraryTarget: "commonjs2" - }, - plugins: [ - new webpack.DefinePlugin(BUILD_CONSTANTS), - new webpack.optimize.LimitChunkCountPlugin({ - maxChunks: 1 - }) - ], - }, - nodeProd: { - mode: "production", + mode: process.env.NODE_ENV, target: "node", entry: "./src/node/index.mjs", externals: [NodeExternals({ @@ -326,7 +302,7 @@ module.exports = function (grunt) { } }, nodeRepl: { - mode: "production", + mode: process.env.NODE_ENV, target: "node", entry: "./src/node/repl-index.mjs", externals: [NodeExternals({ diff --git a/package.json b/package.json index d51ace98..9b578388 100644 --- a/package.json +++ b/package.json @@ -140,9 +140,9 @@ }, "scripts": { "start": "grunt dev", - "start-node": "grunt node", "build": "grunt prod", - "build-node": "grunt node-prod", + "node": "NODE_ENV=development grunt node", + "node-prod": "NODE_ENV=production grunt node", "test": "grunt test", "testui": "grunt testui", "docs": "grunt docs", From 04b7f2fa8c1a478d241ddc1c00be1fffd54e3460 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 15 Feb 2019 15:20:05 +0000 Subject: [PATCH 145/687] WIP HAD to move NodeDish out - NONE of it is async! --- Gruntfile.js | 4 +- package-lock.json | 145 +++++++------ package.json | 1 + src/core/Dish.mjs | 2 +- src/core/Utils.mjs | 77 +++++-- src/core/operations/Tar.mjs | 2 + src/core/operations/Untar.mjs | 1 + src/node/File.mjs | 41 ++++ src/node/NodeDish.mjs | 186 +++++++++++++++++ src/node/NodeRecipe.mjs | 14 +- src/node/SyncDish.mjs | 196 ------------------ src/node/api.mjs | 165 ++++++--------- src/node/config/excludedOperations.mjs | 4 +- src/node/config/scripts/generateNodeIndex.mjs | 6 +- src/node/repl-index.mjs | 3 + tests/node/tests/nodeApi.mjs | 6 +- 16 files changed, 442 insertions(+), 411 deletions(-) create mode 100644 src/node/File.mjs create mode 100644 src/node/NodeDish.mjs delete mode 100644 src/node/SyncDish.mjs diff --git a/Gruntfile.js b/Gruntfile.js index 359723e5..3193feca 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -271,7 +271,7 @@ module.exports = function (grunt) { ] }, node: { - mode: process.env.NODE_ENV, + mode: process.env.NODE_ENV === "prodction" ? "production" : "development", target: "node", entry: "./src/node/index.mjs", externals: [NodeExternals({ @@ -302,7 +302,7 @@ module.exports = function (grunt) { } }, nodeRepl: { - mode: process.env.NODE_ENV, + mode: process.env.NODE_ENV === "prodction" ? "production" : "development", target: "node", entry: "./src/node/repl-index.mjs", externals: [NodeExternals({ diff --git a/package-lock.json b/package-lock.json index 4f29f477..b6dc24b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1664,7 +1664,7 @@ "dependencies": { "es6-promisify": { "version": "5.0.0", - "resolved": "http://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", "dev": true, "requires": { @@ -1839,7 +1839,7 @@ }, "array-equal": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", "dev": true }, @@ -2072,7 +2072,7 @@ }, "axios": { "version": "0.18.0", - "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", "dev": true, "requires": { @@ -2550,7 +2550,7 @@ }, "browserify-aes": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "dev": true, "requires": { @@ -2587,7 +2587,7 @@ }, "browserify-rsa": { "version": "4.0.1", - "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "dev": true, "requires": { @@ -2652,7 +2652,7 @@ }, "buffer": { "version": "4.9.1", - "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "dev": true, "requires": { @@ -2791,7 +2791,7 @@ }, "camelcase-keys": { "version": "2.1.0", - "resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", "dev": true, "requires": { @@ -2840,7 +2840,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "requires": { "ansi-styles": "^2.2.1", @@ -3381,7 +3381,7 @@ }, "create-hash": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "dev": true, "requires": { @@ -3394,7 +3394,7 @@ }, "create-hmac": { "version": "1.1.7", - "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "dev": true, "requires": { @@ -3494,7 +3494,7 @@ }, "json5": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/json5/-/json5-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz", "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==", "dev": true, "requires": { @@ -3547,7 +3547,7 @@ }, "css-select": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", "dev": true, "requires": { @@ -3570,7 +3570,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", "dev": true }, @@ -3748,7 +3748,7 @@ }, "deep-eql": { "version": "0.1.3", - "resolved": "http://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", "dev": true, "requires": { @@ -3928,7 +3928,7 @@ }, "diffie-hellman": { "version": "5.0.3", - "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "dev": true, "requires": { @@ -3992,7 +3992,7 @@ "dependencies": { "domelementtype": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", "dev": true }, @@ -4051,7 +4051,7 @@ }, "duplexer": { "version": "0.1.1", - "resolved": "http://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz", "integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=", "dev": true }, @@ -4197,7 +4197,7 @@ }, "entities": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/entities/-/entities-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", "dev": true }, @@ -4628,7 +4628,7 @@ }, "eventemitter2": { "version": "0.4.14", - "resolved": "http://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", "integrity": "sha1-j2G3XN4BKy6esoTUVFWDtWQ7Yas=", "dev": true }, @@ -4640,7 +4640,7 @@ }, "events": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", "dev": true }, @@ -5351,7 +5351,7 @@ }, "fs-extra": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", "dev": true, "requires": { @@ -6086,7 +6086,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -6228,7 +6228,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -6337,7 +6337,7 @@ }, "grunt-cli": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz", "integrity": "sha1-VisRnrsGndtGSs4oRVAb6Xs1tqg=", "dev": true, "requires": { @@ -6437,7 +6437,7 @@ "dependencies": { "shelljs": { "version": "0.5.3", - "resolved": "http://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", "integrity": "sha1-xUmCuZbHbvDB5rWfvcWCX1txMRM=", "dev": true } @@ -6457,7 +6457,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -6502,7 +6502,7 @@ }, "grunt-contrib-jshint": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-1.1.0.tgz", "integrity": "sha1-Np2QmyWTxA6L55lAshNAhQx5Oaw=", "dev": true, "requires": { @@ -6842,7 +6842,7 @@ }, "html-webpack-plugin": { "version": "3.2.0", - "resolved": "http://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-3.2.0.tgz", "integrity": "sha1-sBq71yOsqqeze2r0SS69oD2d03s=", "dev": true, "requires": { @@ -6890,7 +6890,7 @@ }, "htmlparser2": { "version": "3.8.3", - "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", "dev": true, "requires": { @@ -6909,7 +6909,7 @@ }, "http-errors": { "version": "1.6.3", - "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", "dev": true, "requires": { @@ -6967,7 +6967,7 @@ }, "http-proxy-middleware": { "version": "0.18.0", - "resolved": "http://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", "integrity": "sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q==", "dev": true, "requires": { @@ -7407,7 +7407,7 @@ }, "is-builtin-module": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", "dev": true, "requires": { @@ -7987,7 +7987,7 @@ }, "jsonfile": { "version": "2.4.0", - "resolved": "http://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", "dev": true, "requires": { @@ -8098,7 +8098,7 @@ }, "kew": { "version": "0.7.0", - "resolved": "http://registry.npmjs.org/kew/-/kew-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz", "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=", "dev": true }, @@ -8217,7 +8217,7 @@ }, "load-json-file": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "dev": true, "requires": { @@ -8230,7 +8230,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -8588,7 +8588,7 @@ }, "media-typer": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "dev": true }, @@ -8647,7 +8647,7 @@ }, "meow": { "version": "3.7.0", - "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "dev": true, "requires": { @@ -8707,10 +8707,9 @@ } }, "mime": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.3.1.tgz", - "integrity": "sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg==", - "dev": true + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.4.0.tgz", + "integrity": "sha512-ikBcWwyqXQSHKtciCcctu9YfPbFYZ4+gbHEmE0Q8jzcTYQg5dHCr3g2wwAZjPoJfQVXZq6KXAjpXOTf5/cjT7w==" }, "mime-db": { "version": "1.37.0", @@ -8794,7 +8793,7 @@ }, "minimist": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" }, "mississippi": { @@ -8868,7 +8867,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -8876,7 +8875,7 @@ "dependencies": { "minimist": { "version": "0.0.8", - "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" } } @@ -8909,7 +8908,7 @@ "dependencies": { "commander": { "version": "2.15.1", - "resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", "dev": true, "optional": true @@ -9078,7 +9077,7 @@ }, "ncp": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/ncp/-/ncp-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-1.0.1.tgz", "integrity": "sha1-0VNn5cuHQyuhF9K/gP30Wuz7QkY=", "dev": true }, @@ -9177,7 +9176,7 @@ "dependencies": { "semver": { "version": "5.3.0", - "resolved": "http://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true } @@ -9358,7 +9357,7 @@ "dependencies": { "colors": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=" }, "underscore": { @@ -9640,13 +9639,13 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, "os-locale": { "version": "1.4.0", - "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "dev": true, "requires": { @@ -9655,7 +9654,7 @@ }, "os-tmpdir": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true }, @@ -9879,7 +9878,7 @@ }, "parse-asn1": { "version": "5.1.1", - "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", "dev": true, "requires": { @@ -9965,7 +9964,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, @@ -10006,7 +10005,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -10177,7 +10176,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -10548,7 +10547,7 @@ }, "progress": { "version": "1.1.8", - "resolved": "http://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=" }, "promise-inflight": { @@ -10816,7 +10815,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -11005,7 +11004,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", "dev": true } @@ -11056,7 +11055,7 @@ }, "htmlparser2": { "version": "3.3.0", - "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz", "integrity": "sha1-zHDQWln2VC5D8OaFyYLhTJJKnv4=", "dev": true, "requires": { @@ -11068,7 +11067,7 @@ }, "readable-stream": { "version": "1.0.34", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "dev": true, "requires": { @@ -11349,7 +11348,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "requires": { @@ -11681,7 +11680,7 @@ }, "sha.js": { "version": "2.4.11", - "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "dev": true, "requires": { @@ -11725,7 +11724,7 @@ }, "shelljs": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", "dev": true }, @@ -12436,7 +12435,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -12453,7 +12452,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", "dev": true }, @@ -12554,7 +12553,7 @@ }, "tar": { "version": "2.2.1", - "resolved": "http://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", "dev": true, "requires": { @@ -12712,7 +12711,7 @@ }, "through": { "version": "2.3.8", - "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, @@ -13507,7 +13506,7 @@ "dependencies": { "async": { "version": "0.9.2", - "resolved": "http://registry.npmjs.org/async/-/async-0.9.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=", "dev": true }, @@ -13533,7 +13532,7 @@ }, "valid-data-url": { "version": "0.1.6", - "resolved": "http://registry.npmjs.org/valid-data-url/-/valid-data-url-0.1.6.tgz", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-0.1.6.tgz", "integrity": "sha512-FXg2qXMzfAhZc0y2HzELNfUeiOjPr+52hU1DNBWiJJ2luXD+dD1R9NA48Ug5aj0ibbxroeGDc/RJv6ThiGgkDw==", "dev": true }, @@ -13549,7 +13548,7 @@ }, "validator": { "version": "9.4.1", - "resolved": "http://registry.npmjs.org/validator/-/validator-9.4.1.tgz", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.1.tgz", "integrity": "sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==", "dev": true }, @@ -14081,7 +14080,7 @@ }, "webpack-node-externals": { "version": "1.7.2", - "resolved": "http://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz", "integrity": "sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg==", "dev": true }, @@ -14244,7 +14243,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "dev": true, "requires": { diff --git a/package.json b/package.json index 9b578388..caf10d64 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "lodash": "^4.17.11", "loglevel": "^1.6.1", "loglevel-message-prefix": "^3.0.0", + "mime": "^2.4.0", "moment": "^2.23.0", "moment-timezone": "^0.5.23", "ngeohash": "^0.6.3", diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 6b5c9fc2..30b8621d 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -74,6 +74,7 @@ class Dish { case "list": return Dish.LIST_FILE; default: + console.log(typeStr); throw new DishError("Invalid data type string. No matching enum."); } } @@ -383,7 +384,6 @@ class Dish { return newDish; } - } diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index f70e2941..efd57acd 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -9,6 +9,7 @@ import {fromBase64, toBase64} from "./lib/Base64"; import {fromHex} from "./lib/Hex"; import {fromDecimal} from "./lib/Decimal"; import {fromBinary} from "./lib/Binary"; +import { fstat } from "fs"; /** @@ -919,7 +920,7 @@ class Utils { /** * Reads a File and returns the data as a Uint8Array. * - * @param {File} file + * @param {File | for node: array|arrayBuffer|buffer|string} file * @returns {Uint8Array} * * @example @@ -927,33 +928,49 @@ class Utils { * await Utils.readFile(new File(["hello"], "test")) */ static readFile(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - const data = new Uint8Array(file.size); - let offset = 0; - const CHUNK_SIZE = 10485760; // 10MiB + if (Utils.isBrowser()) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + const data = new Uint8Array(file.size); + let offset = 0; + const CHUNK_SIZE = 10485760; // 10MiB - const seek = function() { - if (offset >= file.size) { - resolve(data); - return; - } - const slice = file.slice(offset, offset + CHUNK_SIZE); - reader.readAsArrayBuffer(slice); - }; + const seek = function() { + if (offset >= file.size) { + resolve(data); + return; + } + const slice = file.slice(offset, offset + CHUNK_SIZE); + reader.readAsArrayBuffer(slice); + }; + + reader.onload = function(e) { + data.set(new Uint8Array(reader.result), offset); + offset += CHUNK_SIZE; + seek(); + }; + + reader.onerror = function(e) { + reject(reader.error.message); + }; - reader.onload = function(e) { - data.set(new Uint8Array(reader.result), offset); - offset += CHUNK_SIZE; seek(); - }; + }); - reader.onerror = function(e) { - reject(reader.error.message); - }; + } else if (Utils.isNode()) { + return Buffer.from(file).buffer; + } - seek(); - }); + throw new Error("Unkown environment!"); + } + + /** */ + static readFileSync(file) { + if (Utils.isBrowser()) { + throw new TypeError("Browser environment cannot support readFileSync"); + } + + return Buffer.from(file).buffer; } @@ -1050,6 +1067,20 @@ class Utils { }[token]; } + /** + * Check if code is running in a browser environment + */ + static isBrowser() { + return typeof window !== "undefined" && typeof window.document !== "undefined"; + } + + /** + * Check if code is running in a Node environment + */ + static isNode() { + return typeof process !== "undefined" && process.versions != null && process.versions.node != null; + } + } export default Utils; diff --git a/src/core/operations/Tar.mjs b/src/core/operations/Tar.mjs index 84674bff..6e2334b8 100644 --- a/src/core/operations/Tar.mjs +++ b/src/core/operations/Tar.mjs @@ -132,6 +132,8 @@ class Tar extends Operation { tarball.writeBytes(input); tarball.writeEndBlocks(); + console.log("here"); + return new File([new Uint8Array(tarball.bytes)], args[0]); } diff --git a/src/core/operations/Untar.mjs b/src/core/operations/Untar.mjs index af029184..c69aa771 100644 --- a/src/core/operations/Untar.mjs +++ b/src/core/operations/Untar.mjs @@ -131,6 +131,7 @@ class Untar extends Operation { * @returns {html} */ async present(files) { + console.log("err...."); return await Utils.displayFilesAsHTML(files); } diff --git a/src/node/File.mjs b/src/node/File.mjs new file mode 100644 index 00000000..768c46a8 --- /dev/null +++ b/src/node/File.mjs @@ -0,0 +1,41 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import mime from "mime"; + +/** + * FileShim + * + * Create a class that behaves like the File object in the Browser so that + * operations that use the File object still work. + * + * File doesn't write to disk, but it would be easy to do so with e.gfs.writeFile. + */ +class File { + + /** + * Constructor + * + * @param {String|Array|ArrayBuffer|Buffer} bits - file content + * @param {String} name (optional) - file name + * @param {Object} stats (optional) - file stats e.g. lastModified + */ + constructor(data, name="", stats={}) { + this.data = Buffer.from(data); + this.name = name; + this.lastModified = stats.lastModified || Date.now(); + this.type = stats.type || mime.getType(this.name); + } + + /** + * size property + */ + get size() { + return this.data.length; + } +} + +export default File; diff --git a/src/node/NodeDish.mjs b/src/node/NodeDish.mjs new file mode 100644 index 00000000..246defbf --- /dev/null +++ b/src/node/NodeDish.mjs @@ -0,0 +1,186 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import util from "util"; +import Dish from "../core/Dish"; +import Utils from "../core/Utils"; +import DishError from "../core/errors/DishError"; +import BigNumber from "bignumber.js"; + + +/** + * Subclass of Dish where `get` and `_translate` are synchronous. + * Also define functions to improve coercion behaviour. + */ +class NodeDish extends Dish { + + /** + * Create a Dish + * @param {any} inputOrDish - The dish input + * @param {String|Number} - The dish type, as enum or string + */ + constructor(inputOrDish=null, type=null) { + + // Allow `fs` file input: + // Any node fs Buffers transformed to array buffer + // NOT Buffer.buff, as this makes a buffer of the whole object. + if (Buffer.isBuffer(inputOrDish)) { + inputOrDish = new Uint8Array(inputOrDish).buffer; + } + + super(inputOrDish, type); + } + + /** + * Returns the value of the data in the type format specified. + * + * @param {number} type - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + * @returns {*} - The value of the output data. + */ + get(type, notUTF8=false) { + if (typeof type === "string") { + type = Dish.typeEnum(type); + } + if (this.type !== type) { + this._translate(type, notUTF8); + } + return this.value; + } + + /** + * alias for get + * @param args see get args + */ + to(...args) { + return this.get(...args); + } + + /** + * Avoid coercion to a String primitive. + */ + toString() { + return this.get(Dish.typeEnum("string")); + } + + /** + * What we want to log to the console. + */ + [util.inspect.custom](depth, options) { + return this.get(Dish.typeEnum("string")); + } + + /** + * Backwards compatibility for node v6 + * Log only the value to the console in node. + */ + inspect() { + return this.get(Dish.typeEnum("string")); + } + + /** + * Avoid coercion to a Number primitive. + */ + valueOf() { + return this.get(Dish.typeEnum("number")); + } + + /** + * Translates the data to the given type format. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ + _translate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Convert data to intermediate byteArray type + try { + switch (this.type) { + case Dish.STRING: + this.value = this.value ? Utils.strToByteArray(this.value) : []; + break; + case Dish.NUMBER: + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + break; + case Dish.HTML: + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + break; + case Dish.ARRAY_BUFFER: + // Array.from() would be nicer here, but it's slightly slower + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + break; + case Dish.BIG_NUMBER: + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + break; + case Dish.JSON: + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + break; + case Dish.FILE: + this.value = Utils.readFileSync(this.value); + this.value = Array.prototype.slice.call(this.value); + break; + case Dish.LIST_FILE: + this.value = this.value.map(f => Utils.readFileSync(f)); + this.value = this.value.map(b => Array.prototype.slice.call(b)); + this.value = [].concat.apply([], this.value); + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + + this.type = Dish.BYTE_ARRAY; + + // Convert from byteArray to toType + try { + switch (toType) { + case Dish.STRING: + case Dish.HTML: + this.value = this.value ? byteArrayToStr(this.value) : ""; + this.type = Dish.STRING; + break; + case Dish.NUMBER: + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + this.type = Dish.NUMBER; + break; + case Dish.ARRAY_BUFFER: + this.value = new Uint8Array(this.value).buffer; + this.type = Dish.ARRAY_BUFFER; + break; + case Dish.BIG_NUMBER: + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + this.type = Dish.BIG_NUMBER; + break; + case Dish.JSON: + this.value = JSON.parse(byteArrayToStr(this.value)); + this.type = Dish.JSON; + break; + case Dish.FILE: + this.value = new File(this.value, "unknown"); + break; + case Dish.LIST_FILE: + this.value = [new File(this.value, "unknown")]; + this.type = Dish.LIST_FILE; + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } + } + +} + +export default NodeDish; diff --git a/src/node/NodeRecipe.mjs b/src/node/NodeRecipe.mjs index b49fb9f9..aa72fa6b 100644 --- a/src/node/NodeRecipe.mjs +++ b/src/node/NodeRecipe.mjs @@ -9,7 +9,7 @@ import { sanitise } from "./apiUtils"; /** * Similar to core/Recipe, Recipe controls a list of operations and - * the SyncDish the operate on. However, this Recipe is for the node + * the NodeDish the operate on. However, this Recipe is for the node * environment. */ class NodeRecipe { @@ -73,17 +73,17 @@ class NodeRecipe { /** * Run the dish through each operation, one at a time. - * @param {SyncDish} dish - * @returns {SyncDish} + * @param {NodeDish} dish + * @returns {NodeDish} */ - execute(dish) { - return this.opList.reduce((prev, curr) => { + async execute(dish) { + return await this.opList.reduce(async (prev, curr) => { // CASE where opLis item is op and args if (curr.hasOwnProperty("op") && curr.hasOwnProperty("args")) { - return curr.op(prev, curr.args); + return await curr.op(prev, curr.args); } // CASE opList item is just op. - return curr(prev); + return await curr(prev); }, dish); } } diff --git a/src/node/SyncDish.mjs b/src/node/SyncDish.mjs deleted file mode 100644 index b1667198..00000000 --- a/src/node/SyncDish.mjs +++ /dev/null @@ -1,196 +0,0 @@ -/** - * @author d98762625 [d98762625@gmail.com] - * @copyright Crown Copyright 2018 - * @license Apache-2.0 - */ - -import util from "util"; -import Utils from "../core/Utils"; -import Dish from "../core/Dish"; -import BigNumber from "bignumber.js"; -import log from "loglevel"; - -/** - * Subclass of Dish where `get` and `_translate` are synchronous. - * Also define functions to improve coercion behaviour. - */ -class SyncDish extends Dish { - - /** - * Create a Dish - * @param {any} inputOrDish - The dish input - * @param {String|Number} - The dish type, as enum or string - */ - constructor(inputOrDish=null, type=null) { - - // Allow `fs` file input: - // Any node fs Buffers transformed to array buffer - // NOT Buffer.buff, as this makes a buffer of the whole object. - if (Buffer.isBuffer(inputOrDish)) { - inputOrDish = new Uint8Array(inputOrDish).buffer; - } - - super(inputOrDish, type); - } - - /** - * Apply the inputted operation to the dish. - * - * @param {WrappedOperation} operation the operation to perform - * @param {*} args - any arguments for the operation - * @returns {Dish} a new dish with the result of the operation. - */ - apply(operation, args=null) { - return operation(this.value, args); - } - - /** - * Synchronously returns the value of the data in the type format specified. - * - * @param {number} type - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - * @returns {*} - The value of the output data. - */ - get(type, notUTF8=false) { - if (typeof type === "string") { - type = Dish.typeEnum(type); - } - if (this.type !== type) { - this._translate(type, notUTF8); - } - return this.value; - } - - /** - * alias for get - * @param args see get args - */ - to(...args) { - return this.get(...args); - } - - /** - * Avoid coercion to a String primitive. - */ - toString() { - return this.get(Dish.typeEnum("string")); - } - - /** - * What we want to log to the console. - */ - [util.inspect.custom](depth, options) { - return this.get(Dish.typeEnum("string")); - } - - /** - * Backwards compatibility for node v6 - * Log only the value to the console in node. - */ - inspect() { - return this.get(Dish.typeEnum("string")); - } - - /** - * Avoid coercion to a Number primitive. - */ - valueOf() { - return this.get(Dish.typeEnum("number")); - } - - /** - * Synchronously translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ - _translate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = this.value instanceof BigNumber ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value)) : []; - break; - case Dish.BUFFER: - this.value = this.value instanceof Buffer ? this.value.buffer : []; - break; - // No such API in Node.js. - // case Dish.FILE: - // this.value = Utils.readFileSync(this.value); - // this.value = Array.prototype.slice.call(this.value); - // break; - // case Dish.LIST_FILE: - // this.value = this.value.map(f => Utils.readFileSync(f)); - // this.value = this.value.map(b => Array.prototype.slice.call(b)); - // this.value = [].concat.apply([], this.value); - // break; - default: - break; - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.BUFFER: - this.value = Buffer.from(new Uint8Array(this.value)); - this.type = Dish.BUFFER; - break; - // No such API in Node.js. - // case Dish.FILE: - // this.value = new File(this.value, "unknown"); - // break; - // case Dish.LIST_FILE: - // this.value = [new File(this.value, "unknown")]; - // this.type = Dish.LIST_FILE; - // break; - default: - break; - } - } -} - -export default SyncDish; diff --git a/src/node/api.mjs b/src/node/api.mjs index b0cd8658..35d5bf22 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -8,37 +8,13 @@ /*eslint no-console: ["off"] */ -import SyncDish from "./SyncDish"; +import NodeDish from "./NodeDish"; import NodeRecipe from "./NodeRecipe"; import OperationConfig from "../core/config/OperationConfig.json"; import { sanitise, removeSubheadingsFromArray, sentenceToCamelCase } from "./apiUtils"; import ExludedOperationError from "../core/errors/ExcludedOperationError"; -/** - * Extract default arg value from operation argument - * @param {Object} arg - an arg from an operation - */ -function extractArg(arg) { - if (arg.type === "option") { - // pick default option if not already chosen - return typeof arg.value === "string" ? arg.value : arg.value[0]; - } - - if (arg.type === "editableOption") { - return typeof arg.value === "string" ? arg.value : arg.value[0].value; - } - - if (arg.type === "toggleString") { - // ensure string and option exist when user hasn't defined - arg.string = arg.string || ""; - arg.option = arg.option || arg.toggleValues[0]; - return arg; - } - - return arg.value; -} - /** * transformArgs * @@ -52,84 +28,87 @@ function extractArg(arg) { * @param {Object[]} originalArgs - the operation-s args list * @param {Object} newArgs - any inputted args */ -function transformArgs(originalArgs, newArgs) { +function reconcileOpArgs(operationArgs, objectStyleArgs) { + + if (Array.isArray(objectStyleArgs)) { + return objectStyleArgs; + } // Filter out arg values that are list subheadings - they are surrounded in []. // See Strings op for example. - const allArgs = Object.assign([], originalArgs).map((a) => { + const opArgs = Object.assign([], operationArgs).map((a) => { if (Array.isArray(a.value)) { a.value = removeSubheadingsFromArray(a.value); } return a; }); - if (newArgs) { - Object.keys(newArgs).map((key) => { - const index = allArgs.findIndex((arg) => { + // transform object style arg objects to the same shape as op's args + if (objectStyleArgs) { + Object.keys(objectStyleArgs).map((key) => { + const index = opArgs.findIndex((arg) => { return arg.name.toLowerCase().replace(/ /g, "") === key.toLowerCase().replace(/ /g, ""); }); if (index > -1) { - const argument = allArgs[index]; + const argument = opArgs[index]; if (argument.type === "toggleString") { - if (typeof newArgs[key] === "string") { - argument.string = newArgs[key]; + if (typeof objectStyleArgs[key] === "string") { + argument.string = objectStyleArgs[key]; } else { - argument.string = newArgs[key].string; - argument.option = newArgs[key].option; + argument.string = objectStyleArgs[key].string; + argument.option = objectStyleArgs[key].option; } } else if (argument.type === "editableOption") { // takes key: "option", key: {name, val: "string"}, key: {name, val: [...]} - argument.value = typeof newArgs[key] === "string" ? newArgs[key]: newArgs[key].value; + argument.value = typeof objectStyleArgs[key] === "string" ? objectStyleArgs[key]: objectStyleArgs[key].value; } else { - argument.value = newArgs[key]; + argument.value = objectStyleArgs[key]; } } }); } - return allArgs.map(extractArg); + + return opArgs.map((arg) => { + if (arg.type === "option") { + // pick default option if not already chosen + return typeof arg.value === "string" ? arg.value : arg.value[0]; + } + + if (arg.type === "editableOption") { + return typeof arg.value === "string" ? arg.value : arg.value[0].value; + } + + if (arg.type === "toggleString") { + // ensure string and option exist when user hasn't defined + arg.string = arg.string || ""; + arg.option = arg.option || arg.toggleValues[0]; + return arg; + } + + return arg.value; + }); } /** - * Ensure an input is a SyncDish object. + * Ensure an input is a NodeDish object. * @param input */ function ensureIsDish(input) { if (!input) { - return new SyncDish(); + return new NodeDish(); } - if (input instanceof SyncDish) { + if (input instanceof NodeDish) { return input; } else { - return new SyncDish(input); + return new NodeDish(input); } } -/** - * prepareOp: transform args, make input the right type. - * Also convert any Buffers to ArrayBuffers. - * @param opInstance - instance of the operation - * @param input - operation input - * @param args - operation args - */ -function prepareOp(opInstance, input, args) { - const dish = ensureIsDish(input); - let transformedArgs; - // Transform object-style args to original args array - if (!Array.isArray(args)) { - transformedArgs = transformArgs(opInstance.args, args); - } else { - transformedArgs = args; - } - const transformedInput = dish.get(opInstance.inputType); - return {transformedInput, transformedArgs}; -} - - /** * createArgOptions * @@ -154,7 +133,6 @@ function createArgOptions(op) { return result; } - /** * Wrap an operation to be consumed by node API. * Checks to see if run function is async or not. @@ -169,44 +147,29 @@ export function wrap(OpClass) { // Check to see if class's run function is async. const opInstance = new OpClass(); - const isAsync = opInstance.run.constructor.name === "AsyncFunction"; - let wrapped; + /** + * Async wrapped operation run function + * @param {*} input + * @param {Object | String[]} args - either in Object or normal args array + * @returns {Promise} operation's output, on a Dish. + * @throws {OperationError} if the operation throws one. + */ + const wrapped = async (input, args=null) => { + const dish = ensureIsDish(input); - // If async, wrap must be async. - if (isAsync) { - /** - * Async wrapped operation run function - * @param {*} input - * @param {Object | String[]} args - either in Object or normal args array - * @returns {Promise} operation's output, on a Dish. - * @throws {OperationError} if the operation throws one. - */ - wrapped = async (input, args=null) => { - const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); - const result = await opInstance.run(transformedInput, transformedArgs); - return new SyncDish({ - value: result, - type: opInstance.outputType - }); - }; - } else { - /** - * wrapped operation run function - * @param {*} input - * @param {Object | String[]} args - either in Object or normal args array - * @returns {SyncDish} operation's output, on a Dish. - * @throws {OperationError} if the operation throws one. - */ - wrapped = (input, args=null) => { - const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); - const result = opInstance.run(transformedInput, transformedArgs); - return new SyncDish({ - value: result, - type: opInstance.outputType - }); - }; - } + // Transform object-style args to original args array + const transformedArgs = reconcileOpArgs(opInstance.args, args); + + // ensure the input is the correct type + const transformedInput = await dish.get(opInstance.inputType); + + const result = await opInstance.run(transformedInput, transformedArgs); + return new NodeDish({ + value: result, + type: opInstance.outputType + }); + }; // used in chef.help wrapped.opName = OpClass.name; @@ -288,7 +251,7 @@ export function bake(operations){ * @param {*} input - some input for a recipe. * @param {String | Function | String[] | Function[] | [String | Function]} recipeConfig - * An operation, operation name, or an array of either. - * @returns {SyncDish} of the result + * @returns {NodeDish} of the result * @throws {TypeError} if invalid recipe given. */ return function(input, recipeConfig) { diff --git a/src/node/config/excludedOperations.mjs b/src/node/config/excludedOperations.mjs index ee097bbb..53cdfa95 100644 --- a/src/node/config/excludedOperations.mjs +++ b/src/node/config/excludedOperations.mjs @@ -14,8 +14,8 @@ export default [ "Comment", // Exclude file ops until HTML5 File Object can be mimicked - "Tar", - "Untar", + // "Tar", + // "Untar", "Unzip", "Zip", diff --git a/src/node/config/scripts/generateNodeIndex.mjs b/src/node/config/scripts/generateNodeIndex.mjs index ea2c044b..3056c270 100644 --- a/src/node/config/scripts/generateNodeIndex.mjs +++ b/src/node/config/scripts/generateNodeIndex.mjs @@ -39,7 +39,7 @@ let code = `/** import "babel-polyfill"; -import SyncDish from "./SyncDish"; +import NodeDish from "./NodeDish"; import { wrap, help, bake, explainExludedFunction } from "./api"; import { // import as core_ to avoid name clashes after wrap. @@ -87,7 +87,7 @@ code += ` }; const chef = generateChef(); // Add some additional features to chef object. chef.help = help; -chef.Dish = SyncDish; +chef.Dish = NodeDish; // Define consts here so we can add to top-level export - wont allow // export of chef property. @@ -121,7 +121,7 @@ Object.keys(operations).forEach((op) => { code += ` ${decapitalise(op)},\n`; }); -code += " SyncDish as Dish,\n"; +code += " NodeDish as Dish,\n"; code += " prebaked as bake,\n"; code += " help,\n"; code += "};\n"; diff --git a/src/node/repl-index.mjs b/src/node/repl-index.mjs index 3acc16fb..0a5c3f75 100644 --- a/src/node/repl-index.mjs +++ b/src/node/repl-index.mjs @@ -9,6 +9,7 @@ import chef from "./index"; import repl from "repl"; +import File from "./File"; import "babel-polyfill"; /*eslint no-console: ["off"] */ @@ -26,6 +27,8 @@ const replServer = repl.start({ prompt: "chef > ", }); +global.File = File; + Object.keys(chef).forEach((key) => { if (key !== "operations") { replServer.context[key] = chef[key]; diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index 7604ae13..fc5f01b9 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -14,7 +14,7 @@ import assert from "assert"; import it from "../assertionHandler"; import chef from "../../../src/node/index"; import OperationError from "../../../src/core/errors/OperationError"; -import SyncDish from "../../../src/node/SyncDish"; +import NodeDish from "../../../src/node/NodeDish"; import fs from "fs"; import { toBase32, Dish, SHA3 } from "../../../src/node/index"; @@ -93,9 +93,9 @@ TestRegister.addApiTests([ assert.equal(result.get("string"), "NFXHA5LU"); }), - it("should return a SyncDish", () => { + it("should return a NodeDish", async () => { const result = chef.toBase32("input"); - assert(result instanceof SyncDish); + assert(result instanceof NodeDish); }), it("should coerce to a string as you expect", () => { From 573a292e16817b1d36cd600abf3bda8cba5bce47 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 15 Feb 2019 15:40:29 +0000 Subject: [PATCH 146/687] WIP dynamically define async functions in Dish, only if needed --- src/core/Dish.mjs | 345 ++++++++++++++++++++++++++++-------------- src/node/NodeDish.mjs | 114 -------------- src/node/api.mjs | 105 ++++++++----- 3 files changed, 301 insertions(+), 263 deletions(-) diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 30b8621d..95d821d8 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -10,6 +10,229 @@ import DishError from "./errors/DishError"; import BigNumber from "bignumber.js"; import log from "loglevel"; + +/** + * Translates the data to the given type format. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ +async function _asyncTranslate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Convert data to intermediate byteArray type + try { + switch (this.type) { + case Dish.STRING: + this.value = this.value ? Utils.strToByteArray(this.value) : []; + break; + case Dish.NUMBER: + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + break; + case Dish.HTML: + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + break; + case Dish.ARRAY_BUFFER: + // Array.from() would be nicer here, but it's slightly slower + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + break; + case Dish.BIG_NUMBER: + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + break; + case Dish.JSON: + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + break; + case Dish.FILE: + this.value = await Utils.readFile(this.value); + this.value = Array.prototype.slice.call(this.value); + break; + case Dish.LIST_FILE: + this.value = await Promise.all(this.value.map(async f => Utils.readFile(f))); + this.value = this.value.map(b => Array.prototype.slice.call(b)); + this.value = [].concat.apply([], this.value); + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + + this.type = Dish.BYTE_ARRAY; + + // Convert from byteArray to toType + try { + switch (toType) { + case Dish.STRING: + case Dish.HTML: + this.value = this.value ? byteArrayToStr(this.value) : ""; + this.type = Dish.STRING; + break; + case Dish.NUMBER: + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + this.type = Dish.NUMBER; + break; + case Dish.ARRAY_BUFFER: + this.value = new Uint8Array(this.value).buffer; + this.type = Dish.ARRAY_BUFFER; + break; + case Dish.BIG_NUMBER: + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + this.type = Dish.BIG_NUMBER; + break; + case Dish.JSON: + this.value = JSON.parse(byteArrayToStr(this.value)); + this.type = Dish.JSON; + break; + case Dish.FILE: + this.value = new File(this.value, "unknown"); + break; + case Dish.LIST_FILE: + this.value = [new File(this.value, "unknown")]; + this.type = Dish.LIST_FILE; + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } +} + + +/** + * Translates the data to the given type format. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ +function _translate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Convert data to intermediate byteArray type + try { + switch (this.type) { + case Dish.STRING: + this.value = this.value ? Utils.strToByteArray(this.value) : []; + break; + case Dish.NUMBER: + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + break; + case Dish.HTML: + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + break; + case Dish.ARRAY_BUFFER: + // Array.from() would be nicer here, but it's slightly slower + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + break; + case Dish.BIG_NUMBER: + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + break; + case Dish.JSON: + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + break; + case Dish.FILE: + this.value = Utils.readFileSync(this.value); + this.value = Array.prototype.slice.call(this.value); + break; + case Dish.LIST_FILE: + this.value = this.value.map(f => Utils.readFileSync(f)); + this.value = this.value.map(b => Array.prototype.slice.call(b)); + this.value = [].concat.apply([], this.value); + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + + this.type = Dish.BYTE_ARRAY; + + // Convert from byteArray to toType + try { + switch (toType) { + case Dish.STRING: + case Dish.HTML: + this.value = this.value ? byteArrayToStr(this.value) : ""; + this.type = Dish.STRING; + break; + case Dish.NUMBER: + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + this.type = Dish.NUMBER; + break; + case Dish.ARRAY_BUFFER: + this.value = new Uint8Array(this.value).buffer; + this.type = Dish.ARRAY_BUFFER; + break; + case Dish.BIG_NUMBER: + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + this.type = Dish.BIG_NUMBER; + break; + case Dish.JSON: + this.value = JSON.parse(byteArrayToStr(this.value)); + this.type = Dish.JSON; + break; + case Dish.FILE: + this.value = new File(this.value, "unknown"); + break; + case Dish.LIST_FILE: + this.value = [new File(this.value, "unknown")]; + this.type = Dish.LIST_FILE; + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } +} + +/** + * Returns the value of the data in the type format specified. + * + * @param {number} type - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + * @returns {*} - The value of the output data. + */ +async function asyncGet(type, notUTF8=false) { + if (typeof type === "string") { + type = Dish.typeEnum(type); + } + if (this.type !== type) { + await this._translate(type, notUTF8); + } + return this.value; +} + +/** + * Returns the value of the data in the type format specified. + * + * @param {number} type - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + * @returns {*} - The value of the output data. + */ +function get(type, notUTF8=false) { + if (typeof type === "string") { + type = Dish.typeEnum(type); + } + if (this.type !== type) { + this._translate(type, notUTF8); + } + return this.value; +} + + /** * The data being operated on by each operation. */ @@ -24,6 +247,15 @@ class Dish { * literal input */ constructor(dishOrInput=null, type = null) { + + if (Utils.isBrowser()) { + this._translate = _asyncTranslate.bind(this); + this.get = asyncGet.bind(this); + } else { + this._translate = _translate.bind(this); + this.get = get.bind(this); + } + this.value = []; this.type = Dish.BYTE_ARRAY; @@ -74,7 +306,6 @@ class Dish { case "list": return Dish.LIST_FILE; default: - console.log(typeStr); throw new DishError("Invalid data type string. No matching enum."); } } @@ -136,118 +367,6 @@ class Dish { } - /** - * Returns the value of the data in the type format specified. - * - * @param {number} type - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - * @returns {*} - The value of the output data. - */ - async get(type, notUTF8=false) { - if (typeof type === "string") { - type = Dish.typeEnum(type); - } - if (this.type !== type) { - await this._translate(type, notUTF8); - } - return this.value; - } - - - /** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ - async _translate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = await Utils.readFile(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = await Promise.all(this.value.map(async f => Utils.readFile(f))); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } - } - - /** * Validates that the value is the type that has been specified. * May have to disable parts of BYTE_ARRAY validation if it effects performance. diff --git a/src/node/NodeDish.mjs b/src/node/NodeDish.mjs index 246defbf..bddc96e0 100644 --- a/src/node/NodeDish.mjs +++ b/src/node/NodeDish.mjs @@ -6,10 +6,6 @@ import util from "util"; import Dish from "../core/Dish"; -import Utils from "../core/Utils"; -import DishError from "../core/errors/DishError"; -import BigNumber from "bignumber.js"; - /** * Subclass of Dish where `get` and `_translate` are synchronous. @@ -34,23 +30,6 @@ class NodeDish extends Dish { super(inputOrDish, type); } - /** - * Returns the value of the data in the type format specified. - * - * @param {number} type - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - * @returns {*} - The value of the output data. - */ - get(type, notUTF8=false) { - if (typeof type === "string") { - type = Dish.typeEnum(type); - } - if (this.type !== type) { - this._translate(type, notUTF8); - } - return this.value; - } - /** * alias for get * @param args see get args @@ -88,99 +67,6 @@ class NodeDish extends Dish { return this.get(Dish.typeEnum("number")); } - /** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ - _translate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = Utils.readFileSync(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = this.value.map(f => Utils.readFileSync(f)); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } - } - } export default NodeDish; diff --git a/src/node/api.mjs b/src/node/api.mjs index 35d5bf22..e44a1752 100644 --- a/src/node/api.mjs +++ b/src/node/api.mjs @@ -28,24 +28,24 @@ import ExludedOperationError from "../core/errors/ExcludedOperationError"; * @param {Object[]} originalArgs - the operation-s args list * @param {Object} newArgs - any inputted args */ -function reconcileOpArgs(operationArgs, objectStyleArgs) { +function transformArgs(opArgsList, newArgs) { - if (Array.isArray(objectStyleArgs)) { - return objectStyleArgs; + if (newArgs && Array.isArray(newArgs)) { + return newArgs; } // Filter out arg values that are list subheadings - they are surrounded in []. // See Strings op for example. - const opArgs = Object.assign([], operationArgs).map((a) => { + const opArgs = Object.assign([], opArgsList).map((a) => { if (Array.isArray(a.value)) { a.value = removeSubheadingsFromArray(a.value); } return a; }); - // transform object style arg objects to the same shape as op's args - if (objectStyleArgs) { - Object.keys(objectStyleArgs).map((key) => { + // Reconcile object style arg info to fit operation args shape. + if (newArgs) { + Object.keys(newArgs).map((key) => { const index = opArgs.findIndex((arg) => { return arg.name.toLowerCase().replace(/ /g, "") === key.toLowerCase().replace(/ /g, ""); @@ -54,22 +54,23 @@ function reconcileOpArgs(operationArgs, objectStyleArgs) { if (index > -1) { const argument = opArgs[index]; if (argument.type === "toggleString") { - if (typeof objectStyleArgs[key] === "string") { - argument.string = objectStyleArgs[key]; + if (typeof newArgs[key] === "string") { + argument.string = newArgs[key]; } else { - argument.string = objectStyleArgs[key].string; - argument.option = objectStyleArgs[key].option; + argument.string = newArgs[key].string; + argument.option = newArgs[key].option; } } else if (argument.type === "editableOption") { // takes key: "option", key: {name, val: "string"}, key: {name, val: [...]} - argument.value = typeof objectStyleArgs[key] === "string" ? objectStyleArgs[key]: objectStyleArgs[key].value; + argument.value = typeof newArgs[key] === "string" ? newArgs[key]: newArgs[key].value; } else { - argument.value = objectStyleArgs[key]; + argument.value = newArgs[key]; } } }); } + // Sanitise args return opArgs.map((arg) => { if (arg.type === "option") { // pick default option if not already chosen @@ -93,7 +94,7 @@ function reconcileOpArgs(operationArgs, objectStyleArgs) { /** - * Ensure an input is a NodeDish object. + * Ensure an input is a SyncDish object. * @param input */ function ensureIsDish(input) { @@ -109,6 +110,22 @@ function ensureIsDish(input) { } +/** + * prepareOp: transform args, make input the right type. + * Also convert any Buffers to ArrayBuffers. + * @param opInstance - instance of the operation + * @param input - operation input + * @param args - operation args + */ +function prepareOp(opInstance, input, args) { + const dish = ensureIsDish(input); + // Transform object-style args to original args array + const transformedArgs = transformArgs(opInstance.args, args); + const transformedInput = dish.get(opInstance.inputType); + return {transformedInput, transformedArgs}; +} + + /** * createArgOptions * @@ -133,6 +150,7 @@ function createArgOptions(op) { return result; } + /** * Wrap an operation to be consumed by node API. * Checks to see if run function is async or not. @@ -147,29 +165,44 @@ export function wrap(OpClass) { // Check to see if class's run function is async. const opInstance = new OpClass(); + const isAsync = opInstance.run.constructor.name === "AsyncFunction"; - /** - * Async wrapped operation run function - * @param {*} input - * @param {Object | String[]} args - either in Object or normal args array - * @returns {Promise} operation's output, on a Dish. - * @throws {OperationError} if the operation throws one. - */ - const wrapped = async (input, args=null) => { - const dish = ensureIsDish(input); + let wrapped; - // Transform object-style args to original args array - const transformedArgs = reconcileOpArgs(opInstance.args, args); - - // ensure the input is the correct type - const transformedInput = await dish.get(opInstance.inputType); - - const result = await opInstance.run(transformedInput, transformedArgs); - return new NodeDish({ - value: result, - type: opInstance.outputType - }); - }; + // If async, wrap must be async. + if (isAsync) { + /** + * Async wrapped operation run function + * @param {*} input + * @param {Object | String[]} args - either in Object or normal args array + * @returns {Promise} operation's output, on a Dish. + * @throws {OperationError} if the operation throws one. + */ + wrapped = async (input, args=null) => { + const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + const result = await opInstance.run(transformedInput, transformedArgs); + return new NodeDish({ + value: result, + type: opInstance.outputType + }); + }; + } else { + /** + * wrapped operation run function + * @param {*} input + * @param {Object | String[]} args - either in Object or normal args array + * @returns {SyncDish} operation's output, on a Dish. + * @throws {OperationError} if the operation throws one. + */ + wrapped = (input, args=null) => { + const {transformedInput, transformedArgs} = prepareOp(opInstance, input, args); + const result = opInstance.run(transformedInput, transformedArgs); + return new NodeDish({ + value: result, + type: opInstance.outputType + }); + }; + } // used in chef.help wrapped.opName = OpClass.name; @@ -251,7 +284,7 @@ export function bake(operations){ * @param {*} input - some input for a recipe. * @param {String | Function | String[] | Function[] | [String | Function]} recipeConfig - * An operation, operation name, or an array of either. - * @returns {NodeDish} of the result + * @returns {SyncDish} of the result * @throws {TypeError} if invalid recipe given. */ return function(input, recipeConfig) { From 9094e8bde969d8d0250c7d8b2a7c095abad694ff Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 15 Feb 2019 16:11:13 +0000 Subject: [PATCH 147/687] WIP tidy up. WHy is dish being passed back with chef.bake now? --- Gruntfile.js | 4 ++-- src/core/Utils.mjs | 2 -- src/core/config/scripts/generateOpsIndex.mjs | 2 +- src/core/operations/Tar.mjs | 2 -- src/core/operations/Untar.mjs | 1 - tests/node/tests/nodeApi.mjs | 8 ++++---- 6 files changed, 7 insertions(+), 12 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 3193feca..a14c3001 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -200,7 +200,7 @@ module.exports = function (grunt) { }, moduleEntryPoints), output: { path: __dirname + "/build/prod", - globalObject: "this" + // globalObject: "this" }, resolve: { alias: { @@ -366,7 +366,7 @@ module.exports = function (grunt) { } }, output: { - globalObject: "this", + // globalObject: "this", }, plugins: [ new webpack.DefinePlugin(BUILD_CONSTANTS), diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index efd57acd..c4cd14ab 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -9,8 +9,6 @@ import {fromBase64, toBase64} from "./lib/Base64"; import {fromHex} from "./lib/Hex"; import {fromDecimal} from "./lib/Decimal"; import {fromBinary} from "./lib/Binary"; -import { fstat } from "fs"; - /** * Utility functions for use in operations, the core framework and the stage. diff --git a/src/core/config/scripts/generateOpsIndex.mjs b/src/core/config/scripts/generateOpsIndex.mjs index 6038656f..49cd635c 100644 --- a/src/core/config/scripts/generateOpsIndex.mjs +++ b/src/core/config/scripts/generateOpsIndex.mjs @@ -24,7 +24,7 @@ if (!fs.existsSync(dir)) { // Find all operation files const opObjs = []; fs.readdirSync(path.join(dir, "../operations")).forEach(file => { - if (!file.endsWith(".mjs") || file === "index.mjs" || file === "DetectFileType.mjs" || file === "Fork.mjs" || file === "GenerateQRCode.mjs" || file === "Magic.mjs" || file === "ParseQRCode.mjs" || file === "PlayMedia.mjs" || file === "RenderImage.mjs" || file === "ScanForEmbeddedFiles.mjs" || file === "SplitColourChannels.mjs") return; + if (!file.endsWith(".mjs") || file === "index.mjs") return; opObjs.push(file.split(".mjs")[0]); }); diff --git a/src/core/operations/Tar.mjs b/src/core/operations/Tar.mjs index 6e2334b8..84674bff 100644 --- a/src/core/operations/Tar.mjs +++ b/src/core/operations/Tar.mjs @@ -132,8 +132,6 @@ class Tar extends Operation { tarball.writeBytes(input); tarball.writeEndBlocks(); - console.log("here"); - return new File([new Uint8Array(tarball.bytes)], args[0]); } diff --git a/src/core/operations/Untar.mjs b/src/core/operations/Untar.mjs index c69aa771..af029184 100644 --- a/src/core/operations/Untar.mjs +++ b/src/core/operations/Untar.mjs @@ -131,7 +131,6 @@ class Untar extends Operation { * @returns {html} */ async present(files) { - console.log("err...."); return await Utils.displayFilesAsHTML(files); } diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index fc5f01b9..11361893 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -160,9 +160,9 @@ TestRegister.addApiTests([ assert(chef.bake); }), - it("chef.bake: should return SyncDish", () => { + it("chef.bake: should return NodeDish", () => { const result = chef.bake("input", "to base 64"); - assert(result instanceof SyncDish); + assert(result instanceof NodeDish); }), it("chef.bake: should take an input and an op name and perform it", () => { @@ -222,7 +222,7 @@ TestRegister.addApiTests([ it("chef.bake: if recipe is empty array, return input as dish", () => { const result = chef.bake("some input", []); assert.strictEqual(result.toString(), "some input"); - assert(result instanceof SyncDish, "Result is not instance of SyncDish"); + assert(result instanceof NodeDish, "Result is not instance of NodeDish"); }), it("chef.bake: accepts an array of operations as recipe", () => { @@ -332,7 +332,7 @@ TestRegister.addApiTests([ it("Composable Dish: composed function returns another dish", () => { const result = new Dish("some input").apply(toBase32); - assert.ok(result instanceof SyncDish); + assert.ok(result instanceof NodeDish); }), From ff2521aa9fc276225c7bd64ced3d199e87fcec24 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 15 Feb 2019 16:26:22 +0000 Subject: [PATCH 148/687] WIP Dish now working on dev with dynamically loaded _translate and get functions --- src/core/Dish.mjs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 95d821d8..17a6d2a0 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -248,14 +248,6 @@ class Dish { */ constructor(dishOrInput=null, type = null) { - if (Utils.isBrowser()) { - this._translate = _asyncTranslate.bind(this); - this.get = asyncGet.bind(this); - } else { - this._translate = _translate.bind(this); - this.get = get.bind(this); - } - this.value = []; this.type = Dish.BYTE_ARRAY; @@ -561,4 +553,12 @@ Dish.FILE = 7; */ Dish.LIST_FILE = 8; +if (Utils.isBrowser()) { + Dish.prototype._translate = _asyncTranslate + Dish.prototype.get = asyncGet +} else { + Dish.prototype._translate = _translate + Dish.prototype.get = get +} + export default Dish; From 91f4681a3cd2d8b9ac41ed18644e6a6625e18f2c Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Feb 2019 15:37:49 +0000 Subject: [PATCH 149/687] Add rotate image operation --- src/core/operations/RotateImage.mjs | 96 +++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/core/operations/RotateImage.mjs diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs new file mode 100644 index 00000000..1060aeb8 --- /dev/null +++ b/src/core/operations/RotateImage.mjs @@ -0,0 +1,96 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2018 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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} + */ + run(input, args) { + const [degrees] = args; + const type = Magic.magicFileType(input); + + if (type && type.mime.indexOf("image") === 0){ + return new Promise((resolve, reject) => { + jimp.read(Buffer.from(input)) + .then(image => { + image + .rotate(degrees / 100) + .getBuffer(jimp.AUTO, (error, result) => { + if (error){ + reject(new OperationError("Error getting the new image buffer")); + } else { + resolve([...result]); + } + }); + }) + .catch(err => { + reject(new OperationError("Error reading the input image.")); + }); + }); + } else { + throw new OperationError("Invalid file type."); + } + } + + /** + * Displays the rotated image using HTML for web apps + * + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default RotateImage; From 57e1061063c898ec8c73f8315582ebdb75da7bca Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Feb 2019 15:37:59 +0000 Subject: [PATCH 150/687] Add Scale Image operation --- src/core/operations/ScaleImage.mjs | 94 ++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/core/operations/ScaleImage.mjs diff --git a/src/core/operations/ScaleImage.mjs b/src/core/operations/ScaleImage.mjs new file mode 100644 index 00000000..8db50fea --- /dev/null +++ b/src/core/operations/ScaleImage.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 Magic from "../lib/Magic"; +import { toBase64 } from "../lib/Base64"; +import jimp from "jimp"; + +/** + * Scale Image operation + */ +class ScaleImage extends Operation { + + /** + * ScaleImage constructor + */ + constructor() { + super(); + + this.name = "Scale Image"; + this.module = "Image"; + this.description = "Uniformly scale an image by a specified factor."; + this.infoURL = ""; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.presentType = "html"; + this.args = [ + { + name: "Scale factor (percent)", + type: "number", + value: 100 + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + const [scaleFactor] = args; + const type = Magic.magicFileType(input); + + if (type && type.mime.indexOf("image") === 0){ + return new Promise((resolve, reject) => { + jimp.read(Buffer.from(input)) + .then(image => { + image + .scale((scaleFactor / 100)) + .getBuffer(jimp.AUTO, (error, result) => { + if (error){ + reject(new OperationError("Error getting the new image buffer.")); + } else { + resolve([...result]); + } + }); + }) + .catch(err => { + reject(new OperationError("Error reading the input image.")); + }); + }); + } else { + throw new OperationError("Invalid file type."); + } + } + + /** + * Displays the scaled image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default ScaleImage; From eb8725a0db0a04214ac6b77ea44a6b4d3e9c6b55 Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Feb 2019 16:10:53 +0000 Subject: [PATCH 151/687] Fix degrees error --- src/core/operations/RotateImage.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs index 1060aeb8..7f01b034 100644 --- a/src/core/operations/RotateImage.mjs +++ b/src/core/operations/RotateImage.mjs @@ -51,7 +51,7 @@ class RotateImage extends Operation { jimp.read(Buffer.from(input)) .then(image => { image - .rotate(degrees / 100) + .rotate(degrees) .getBuffer(jimp.AUTO, (error, result) => { if (error){ reject(new OperationError("Error getting the new image buffer")); From 1a2c5a95c737c0a70236b249ee70caa7f5b4c0aa Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Feb 2019 16:19:34 +0000 Subject: [PATCH 152/687] Add resize image operation --- src/core/operations/ResizeImage.mjs | 125 ++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/core/operations/ResizeImage.mjs diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs new file mode 100644 index 00000000..3e177c16 --- /dev/null +++ b/src/core/operations/ResizeImage.mjs @@ -0,0 +1,125 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 = ""; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.presentType = "html"; + this.args = [ + { + name: "Width", + type: "number", + value: 100 + }, + { + name: "Height", + type: "number", + value: 100 + }, + { + name: "Unit type", + type: "option", + value: ["Pixels", "Percent"] + }, + { + name: "Maintain aspect ratio", + type: "boolean", + value: false + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + let width = args[0], + height = args[1]; + const unit = args[2], + aspect = args[3], + type = Magic.magicFileType(input); + + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + return new Promise((resolve, reject) => { + jimp.read(Buffer.from(input)) + .then(image => { + if (unit === "Percent") { + width = image.getWidth() * (width / 100); + height = image.getHeight() * (height / 100); + } + if (aspect) { + image + .scaleToFit(width, height) + .getBuffer(jimp.AUTO, (error, result) => { + if (error){ + reject(new OperationError("Error scaling the image.")); + } else { + resolve([...result]); + } + }); + } else { + image + .resize(width, height) + .getBuffer(jimp.AUTO, (error, result) => { + if (error){ + reject(new OperationError("Error scaling the image.")); + } else { + resolve([...result]); + } + }); + } + }); + }); + } + + /** + * Displays the resized image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default ResizeImage; From 01acefe4cffec3e6401f9ec4aa1786d6e1adff5c Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Feb 2019 16:20:36 +0000 Subject: [PATCH 153/687] Remove scale image operation. (Same functionality is implemented in Resize Image) --- src/core/operations/ScaleImage.mjs | 94 ------------------------------ 1 file changed, 94 deletions(-) delete mode 100644 src/core/operations/ScaleImage.mjs diff --git a/src/core/operations/ScaleImage.mjs b/src/core/operations/ScaleImage.mjs deleted file mode 100644 index 8db50fea..00000000 --- a/src/core/operations/ScaleImage.mjs +++ /dev/null @@ -1,94 +0,0 @@ -/** - * @author j433866 [j433866@gmail.com] - * @copyright Crown Copyright 2019 - * @license Apache-2.0 - */ - -import Operation from "../Operation"; -import OperationError from "../errors/OperationError"; -import Magic from "../lib/Magic"; -import { toBase64 } from "../lib/Base64"; -import jimp from "jimp"; - -/** - * Scale Image operation - */ -class ScaleImage extends Operation { - - /** - * ScaleImage constructor - */ - constructor() { - super(); - - this.name = "Scale Image"; - this.module = "Image"; - this.description = "Uniformly scale an image by a specified factor."; - this.infoURL = ""; - this.inputType = "byteArray"; - this.outputType = "byteArray"; - this.presentType = "html"; - this.args = [ - { - name: "Scale factor (percent)", - type: "number", - value: 100 - } - ]; - } - - /** - * @param {byteArray} input - * @param {Object[]} args - * @returns {byteArray} - */ - run(input, args) { - const [scaleFactor] = args; - const type = Magic.magicFileType(input); - - if (type && type.mime.indexOf("image") === 0){ - return new Promise((resolve, reject) => { - jimp.read(Buffer.from(input)) - .then(image => { - image - .scale((scaleFactor / 100)) - .getBuffer(jimp.AUTO, (error, result) => { - if (error){ - reject(new OperationError("Error getting the new image buffer.")); - } else { - resolve([...result]); - } - }); - }) - .catch(err => { - reject(new OperationError("Error reading the input image.")); - }); - }); - } else { - throw new OperationError("Invalid file type."); - } - } - - /** - * Displays the scaled image using HTML for web apps - * @param {byteArray} data - * @returns {html} - */ - present(data) { - if (!data.length) return ""; - - let dataURI = "data:"; - const type = Magic.magicFileType(data); - if (type && type.mime.indexOf("image") === 0){ - dataURI += type.mime + ";"; - } else { - throw new OperationError("Invalid file type"); - } - dataURI += "base64," + toBase64(data); - - return ""; - } - -} - -export default ScaleImage; From b691c3067771e11a7775728be8d2cf62090c0055 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 09:20:38 +0000 Subject: [PATCH 154/687] Add dither image operation --- src/core/operations/DitherImage.mjs | 89 +++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/core/operations/DitherImage.mjs diff --git a/src/core/operations/DitherImage.mjs b/src/core/operations/DitherImage.mjs new file mode 100644 index 00000000..a3fd4974 --- /dev/null +++ b/src/core/operations/DitherImage.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 Magic from "../lib/Magic"; +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} + */ + run(input, args) { + const type = Magic.magicFileType(input); + + if (type && type.mime.indexOf("image") === 0){ + return new Promise((resolve, reject) => { + jimp.read(Buffer.from(input)) + .then(image => { + image + .dither565() + .getBuffer(jimp.AUTO, (error, result) => { + if (error){ + reject(new OperationError("Error getting the new image buffer")); + } else { + resolve([...result]); + } + }); + }) + .catch(err => { + reject(new OperationError("Error applying a dither effect to the image.")); + }); + }); + } else { + throw new OperationError("Invalid file type."); + } + } + + /** + * Displays the dithered image using HTML for web apps + * + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default DitherImage; From 74c2a2b5cbb022dd6e6de231861a9668c75c5d13 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 11:12:15 +0000 Subject: [PATCH 155/687] Add Invert Image operation --- src/core/operations/InvertImage.mjs | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/core/operations/InvertImage.mjs diff --git a/src/core/operations/InvertImage.mjs b/src/core/operations/InvertImage.mjs new file mode 100644 index 00000000..87da0156 --- /dev/null +++ b/src/core/operations/InvertImage.mjs @@ -0,0 +1,73 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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) { + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0) { + throw new OperationError("Invalid input file format."); + } + const image = await jimp.read(Buffer.from(input)); + image.invert(); + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } + + /** + * Displays the inverted image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default InvertImage; From a0b94bba4e32e9af2e2cb12a45e0fe02183bf219 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 11:26:39 +0000 Subject: [PATCH 156/687] Change run() functions to be async --- src/core/operations/DitherImage.mjs | 23 ++++----------- src/core/operations/ResizeImage.mjs | 45 +++++++++-------------------- src/core/operations/RotateImage.mjs | 24 ++++----------- 3 files changed, 24 insertions(+), 68 deletions(-) diff --git a/src/core/operations/DitherImage.mjs b/src/core/operations/DitherImage.mjs index a3fd4974..aff95a3c 100644 --- a/src/core/operations/DitherImage.mjs +++ b/src/core/operations/DitherImage.mjs @@ -36,27 +36,14 @@ class DitherImage extends Operation { * @param {Object[]} args * @returns {byteArray} */ - run(input, args) { + async run(input, args) { const type = Magic.magicFileType(input); if (type && type.mime.indexOf("image") === 0){ - return new Promise((resolve, reject) => { - jimp.read(Buffer.from(input)) - .then(image => { - image - .dither565() - .getBuffer(jimp.AUTO, (error, result) => { - if (error){ - reject(new OperationError("Error getting the new image buffer")); - } else { - resolve([...result]); - } - }); - }) - .catch(err => { - reject(new OperationError("Error applying a dither effect to the image.")); - }); - }); + const image = await jimp.read(Buffer.from(input)); + image.dither565(); + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; } else { throw new OperationError("Invalid file type."); } diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index 3e177c16..115d8c65 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -57,7 +57,7 @@ class ResizeImage extends Operation { * @param {Object[]} args * @returns {byteArray} */ - run(input, args) { + async run(input, args) { let width = args[0], height = args[1]; const unit = args[2], @@ -67,37 +67,20 @@ class ResizeImage extends Operation { if (!type || type.mime.indexOf("image") !== 0){ throw new OperationError("Invalid file type."); } + const image = await jimp.read(Buffer.from(input)); - return new Promise((resolve, reject) => { - jimp.read(Buffer.from(input)) - .then(image => { - if (unit === "Percent") { - width = image.getWidth() * (width / 100); - height = image.getHeight() * (height / 100); - } - if (aspect) { - image - .scaleToFit(width, height) - .getBuffer(jimp.AUTO, (error, result) => { - if (error){ - reject(new OperationError("Error scaling the image.")); - } else { - resolve([...result]); - } - }); - } else { - image - .resize(width, height) - .getBuffer(jimp.AUTO, (error, result) => { - if (error){ - reject(new OperationError("Error scaling the image.")); - } else { - resolve([...result]); - } - }); - } - }); - }); + if (unit === "Percent") { + width = image.getWidth() * (width / 100); + height = image.getHeight() * (height / 100); + } + if (aspect) { + image.scaleToFit(width, height); + } else { + image.resize(width, height); + } + + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; } /** diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs index 7f01b034..1bab6c98 100644 --- a/src/core/operations/RotateImage.mjs +++ b/src/core/operations/RotateImage.mjs @@ -42,28 +42,15 @@ class RotateImage extends Operation { * @param {Object[]} args * @returns {byteArray} */ - run(input, args) { + async run(input, args) { const [degrees] = args; const type = Magic.magicFileType(input); if (type && type.mime.indexOf("image") === 0){ - return new Promise((resolve, reject) => { - jimp.read(Buffer.from(input)) - .then(image => { - image - .rotate(degrees) - .getBuffer(jimp.AUTO, (error, result) => { - if (error){ - reject(new OperationError("Error getting the new image buffer")); - } else { - resolve([...result]); - } - }); - }) - .catch(err => { - reject(new OperationError("Error reading the input image.")); - }); - }); + const image = await jimp.read(Buffer.from(input)); + image.rotate(degrees); + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; } else { throw new OperationError("Invalid file type."); } @@ -71,7 +58,6 @@ class RotateImage extends Operation { /** * Displays the rotated image using HTML for web apps - * * @param {byteArray} data * @returns {html} */ From 0dd430490214c6ba334a3314b39e1da1e28107dc Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 11:48:24 +0000 Subject: [PATCH 157/687] Add new Blur Image operation. Performs both fast blur and gaussian blur --- src/core/operations/BlurImage.mjs | 96 +++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/core/operations/BlurImage.mjs diff --git a/src/core/operations/BlurImage.mjs b/src/core/operations/BlurImage.mjs new file mode 100644 index 00000000..68ae0b0f --- /dev/null +++ b/src/core/operations/BlurImage.mjs @@ -0,0 +1,96 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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.

Gaussian blur is much slower than fast blur, but produces better results."; + this.infoURL = ""; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.presentType = "html"; + this.args = [ + { + name: "Blur Amount", + type: "number", + value: 5 + }, + { + name: "Blur Type", + type: "option", + value: ["Fast", "Gaussian"] + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + async run(input, args) { + const [blurAmount, blurType] = args; + const type = Magic.magicFileType(input); + + if (type && type.mime.indexOf("image") === 0){ + const image = await jimp.read(Buffer.from(input)); + + switch (blurType){ + case "Fast": + image.blur(blurAmount); + break; + case "Gaussian": + image.gaussian(blurAmount); + break; + } + + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } else { + throw new OperationError("Invalid file type."); + } + } + + /** + * Displays the blurred image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default BlurImage; From fd160e87e88ac84c177f7d6732127fa1498dd229 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 11:54:59 +0000 Subject: [PATCH 158/687] Add image operations to Categories --- src/core/config/Categories.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 8235ab10..081a5152 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -359,7 +359,12 @@ "Play Media", "Remove EXIF", "Extract EXIF", - "Split Colour Channels" + "Split Colour Channels", + "Rotate Image", + "Resize Image", + "Blur Image", + "Dither Image", + "Invert Image" ] }, { From da838e266e08f676c9fcf72faf3b00c5cbd47350 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 13:04:15 +0000 Subject: [PATCH 159/687] Add flip image operation --- src/core/config/Categories.json | 3 +- src/core/operations/FlipImage.mjs | 90 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/FlipImage.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 081a5152..9b0f8249 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -364,7 +364,8 @@ "Resize Image", "Blur Image", "Dither Image", - "Invert Image" + "Invert Image", + "Flip Image" ] }, { diff --git a/src/core/operations/FlipImage.mjs b/src/core/operations/FlipImage.mjs new file mode 100644 index 00000000..fa3054e2 --- /dev/null +++ b/src/core/operations/FlipImage.mjs @@ -0,0 +1,90 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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: "Flip Axis", + type: "option", + value: ["Horizontal", "Vertical"] + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + async run(input, args) { + const [flipAxis] = args; + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid input file type."); + } + + const image = await jimp.read(Buffer.from(input)); + + 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]; + } + + /** + * Displays the flipped image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default FlipImage; From 9f4aa0a1233683c185cecdb2a7f9fb98d0233519 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 13:17:57 +0000 Subject: [PATCH 160/687] Remove trailing space --- src/core/operations/DitherImage.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/operations/DitherImage.mjs b/src/core/operations/DitherImage.mjs index aff95a3c..2cc9ac2d 100644 --- a/src/core/operations/DitherImage.mjs +++ b/src/core/operations/DitherImage.mjs @@ -51,7 +51,6 @@ class DitherImage extends Operation { /** * Displays the dithered image using HTML for web apps - * * @param {byteArray} data * @returns {html} */ From 0d86a7e42780336ac734aaf4db053715aca803ce Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 20 Feb 2019 15:35:53 +0000 Subject: [PATCH 161/687] Add resize algorithm option --- src/core/operations/ResizeImage.mjs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index 115d8c65..aa5cb24b 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -48,6 +48,18 @@ class ResizeImage extends Operation { name: "Maintain aspect ratio", type: "boolean", value: false + }, + { + name: "Resizing algorithm", + type: "option", + value: [ + "Nearest Neighbour", + "Bilinear", + "Bicubic", + "Hermite", + "Bezier" + ], + defaultIndex: 1 } ]; } @@ -62,8 +74,17 @@ class ResizeImage extends Operation { height = args[1]; const unit = args[2], aspect = args[3], + resizeAlg = args[4], type = Magic.magicFileType(input); + 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 (!type || type.mime.indexOf("image") !== 0){ throw new OperationError("Invalid file type."); } @@ -74,9 +95,9 @@ class ResizeImage extends Operation { height = image.getHeight() * (height / 100); } if (aspect) { - image.scaleToFit(width, height); + image.scaleToFit(width, height, resizeMap[resizeAlg]); } else { - image.resize(width, height); + image.resize(width, height, resizeMap[resizeAlg]); } const imageBuffer = await image.getBufferAsync(jimp.AUTO); From da2d5674a5a48637cd4f3e530f491aa91257b8e5 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 23 Feb 2019 00:41:19 +0000 Subject: [PATCH 162/687] Ported heatmap and hex density chart ops --- package-lock.json | 414 ++++++++++-- src/core/lib/Charts.mjs | 177 +++++ src/core/operations/Charts.js | 841 ------------------------ src/core/operations/HeatmapChart.mjs | 260 ++++++++ src/core/operations/HexDensityChart.mjs | 287 ++++++++ src/core/operations/legacy/Charts.js | 297 +++++++++ 6 files changed, 1362 insertions(+), 914 deletions(-) create mode 100644 src/core/lib/Charts.mjs delete mode 100755 src/core/operations/Charts.js create mode 100644 src/core/operations/HeatmapChart.mjs create mode 100644 src/core/operations/HexDensityChart.mjs create mode 100755 src/core/operations/legacy/Charts.js diff --git a/package-lock.json b/package-lock.json index 55ad6303..207a3058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1631,7 +1631,7 @@ }, "array-equal": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.0.tgz", "integrity": "sha1-jCpe8kcv2ep0KwTHenUJO6J1fJM=", "dev": true }, @@ -1716,7 +1716,7 @@ }, "util": { "version": "0.10.3", - "resolved": "http://registry.npmjs.org/util/-/util-0.10.3.tgz", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", "dev": true, "requires": { @@ -1864,7 +1864,7 @@ }, "axios": { "version": "0.18.0", - "resolved": "http://registry.npmjs.org/axios/-/axios-0.18.0.tgz", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.0.tgz", "integrity": "sha1-MtU+SFHv3AoRmTts0AB4nXDAUQI=", "dev": true, "requires": { @@ -2334,7 +2334,7 @@ }, "browserify-aes": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", "dev": true, "requires": { @@ -2371,7 +2371,7 @@ }, "browserify-rsa": { "version": "4.0.1", - "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", "dev": true, "requires": { @@ -2436,7 +2436,7 @@ }, "buffer": { "version": "4.9.1", - "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", "dev": true, "requires": { @@ -2590,7 +2590,7 @@ }, "camelcase-keys": { "version": "2.1.0", - "resolved": "http://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", "dev": true, "requires": { @@ -2639,7 +2639,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "requires": { "ansi-styles": "^2.2.1", @@ -3172,7 +3172,7 @@ }, "create-hash": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", "dev": true, "requires": { @@ -3185,7 +3185,7 @@ }, "create-hmac": { "version": "1.1.7", - "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", "dev": true, "requires": { @@ -3332,7 +3332,7 @@ }, "css-select": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", "integrity": "sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=", "dev": true, "requires": { @@ -3440,6 +3440,266 @@ "integrity": "sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA=", "dev": true }, + "d3": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-4.13.0.tgz", + "integrity": "sha512-l8c4+0SldjVKLaE2WG++EQlqD7mh/dmQjvi2L2lKPadAVC+TbJC4ci7Uk9bRi+To0+ansgsS0iWfPjD7DBy+FQ==", + "requires": { + "d3-array": "1.2.1", + "d3-axis": "1.0.8", + "d3-brush": "1.0.4", + "d3-chord": "1.0.4", + "d3-collection": "1.0.4", + "d3-color": "1.0.3", + "d3-dispatch": "1.0.3", + "d3-drag": "1.2.1", + "d3-dsv": "1.0.8", + "d3-ease": "1.0.3", + "d3-force": "1.1.0", + "d3-format": "1.2.2", + "d3-geo": "1.9.1", + "d3-hierarchy": "1.1.5", + "d3-interpolate": "1.1.6", + "d3-path": "1.0.5", + "d3-polygon": "1.0.3", + "d3-quadtree": "1.0.3", + "d3-queue": "3.0.7", + "d3-random": "1.1.0", + "d3-request": "1.0.6", + "d3-scale": "1.0.7", + "d3-selection": "1.3.0", + "d3-shape": "1.2.0", + "d3-time": "1.0.8", + "d3-time-format": "2.1.1", + "d3-timer": "1.0.7", + "d3-transition": "1.1.1", + "d3-voronoi": "1.1.2", + "d3-zoom": "1.7.1" + } + }, + "d3-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.1.tgz", + "integrity": "sha512-CyINJQ0SOUHojDdFDH4JEM0552vCR1utGyLHegJHyYH0JyCpSeTPxi4OBqHMA2jJZq4NH782LtaJWBImqI/HBw==" + }, + "d3-axis": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.8.tgz", + "integrity": "sha1-MacFoLU15ldZ3hQXOjGTMTfxjvo=" + }, + "d3-brush": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.0.4.tgz", + "integrity": "sha1-AMLyOAGfJPbAoZSibUGhUw/+e8Q=", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, + "d3-chord": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.4.tgz", + "integrity": "sha1-fexPC6iG9xP+ERxF92NBT290yiw=", + "requires": { + "d3-array": "1", + "d3-path": "1" + } + }, + "d3-collection": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.4.tgz", + "integrity": "sha1-NC39EoN8kJdPM/HMCnha6lcNzcI=" + }, + "d3-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.0.3.tgz", + "integrity": "sha1-vHZD/KjlOoNH4vva/6I2eWtYUJs=" + }, + "d3-dispatch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.3.tgz", + "integrity": "sha1-RuFJHqqbWMNY/OW+TovtYm54cfg=" + }, + "d3-drag": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.1.tgz", + "integrity": "sha512-Cg8/K2rTtzxzrb0fmnYOUeZHvwa4PHzwXOLZZPwtEs2SKLLKLXeYwZKBB+DlOxUvFmarOnmt//cU4+3US2lyyQ==", + "requires": { + "d3-dispatch": "1", + "d3-selection": "1" + } + }, + "d3-dsv": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.0.8.tgz", + "integrity": "sha512-IVCJpQ+YGe3qu6odkPQI0KPqfxkhbP/oM1XhhE/DFiYmcXKfCRub4KXyiuehV1d4drjWVXHUWx4gHqhdZb6n/A==", + "requires": { + "commander": "2", + "iconv-lite": "0.4", + "rw": "1" + } + }, + "d3-ease": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.3.tgz", + "integrity": "sha1-aL+8NJM4o4DETYrMT7wzBKotjA4=" + }, + "d3-force": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.1.0.tgz", + "integrity": "sha512-2HVQz3/VCQs0QeRNZTYb7GxoUCeb6bOzMp/cGcLa87awY9ZsPvXOGeZm0iaGBjXic6I1ysKwMn+g+5jSAdzwcg==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "d3-format": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.2.2.tgz", + "integrity": "sha512-zH9CfF/3C8zUI47nsiKfD0+AGDEuM8LwBIP7pBVpyR4l/sKkZqITmMtxRp04rwBrlshIZ17XeFAaovN3++wzkw==" + }, + "d3-geo": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.9.1.tgz", + "integrity": "sha512-l9wL/cEQkyZQYXw3xbmLsH3eQ5ij+icNfo4r0GrLa5rOCZR/e/3am45IQ0FvQ5uMsv+77zBRunLc9ufTWSQYFA==", + "requires": { + "d3-array": "1" + } + }, + "d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha1-nFg32s/UcasFM3qeke8Qv8T5iDE=" + }, + "d3-hierarchy": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.5.tgz", + "integrity": "sha1-ochFxC+Eoga88cAcAQmOpN2qeiY=" + }, + "d3-interpolate": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.1.6.tgz", + "integrity": "sha512-mOnv5a+pZzkNIHtw/V6I+w9Lqm9L5bG3OTXPM5A+QO0yyVMQ4W1uZhR+VOJmazaOZXri2ppbiZ5BUNWT0pFM9A==", + "requires": { + "d3-color": "1" + } + }, + "d3-path": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.5.tgz", + "integrity": "sha1-JB6xhJvZ6egCHA0KeZ+KDo5EF2Q=" + }, + "d3-polygon": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.3.tgz", + "integrity": "sha1-FoiOkCZGCTPysXllKtN4Ik04LGI=" + }, + "d3-quadtree": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.3.tgz", + "integrity": "sha1-rHmH4+I/6AWpkPKOG1DTj8uCJDg=" + }, + "d3-queue": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/d3-queue/-/d3-queue-3.0.7.tgz", + "integrity": "sha1-yTouVLQXwJWRKdfXP2z31Ckudhg=" + }, + "d3-random": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.0.tgz", + "integrity": "sha1-ZkLlBsb6OmSFldKyRpeIqNElKdM=" + }, + "d3-request": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-request/-/d3-request-1.0.6.tgz", + "integrity": "sha512-FJj8ySY6GYuAJHZMaCQ83xEYE4KbkPkmxZ3Hu6zA1xxG2GD+z6P+Lyp+zjdsHf0xEbp2xcluDI50rCS855EQ6w==", + "requires": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-dsv": "1", + "xmlhttprequest": "1" + } + }, + "d3-scale": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-1.0.7.tgz", + "integrity": "sha512-KvU92czp2/qse5tUfGms6Kjig0AhHOwkzXG0+PqIJB3ke0WUv088AHMZI0OssO9NCkXt4RP8yju9rpH8aGB7Lw==", + "requires": { + "d3-array": "^1.2.0", + "d3-collection": "1", + "d3-color": "1", + "d3-format": "1", + "d3-interpolate": "1", + "d3-time": "1", + "d3-time-format": "2" + } + }, + "d3-selection": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.3.0.tgz", + "integrity": "sha512-qgpUOg9tl5CirdqESUAu0t9MU/t3O9klYfGfyKsXEmhyxyzLpzpeh08gaxBUTQw1uXIOkr/30Ut2YRjSSxlmHA==" + }, + "d3-shape": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.2.0.tgz", + "integrity": "sha1-RdAVOPBkuv0F6j1tLLdI/YxB93c=", + "requires": { + "d3-path": "1" + } + }, + "d3-time": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.0.8.tgz", + "integrity": "sha512-YRZkNhphZh3KcnBfitvF3c6E0JOFGikHZ4YqD+Lzv83ZHn1/u6yGenRU1m+KAk9J1GnZMnKcrtfvSktlA1DXNQ==" + }, + "d3-time-format": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.1.1.tgz", + "integrity": "sha512-8kAkymq2WMfzW7e+s/IUNAtN/y3gZXGRrdGfo6R8NKPAA85UBTxZg5E61bR6nLwjPjj4d3zywSQe1CkYLPFyrw==", + "requires": { + "d3-time": "1" + } + }, + "d3-timer": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.7.tgz", + "integrity": "sha512-vMZXR88XujmG/L5oB96NNKH5lCWwiLM/S2HyyAQLcjWJCloK5shxta4CwOFYLZoY3AWX73v8Lgv4cCAdWtRmOA==" + }, + "d3-transition": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.1.1.tgz", + "integrity": "sha512-xeg8oggyQ+y5eb4J13iDgKIjUcEfIOZs2BqV/eEmXm2twx80wTzJ4tB4vaZ5BKfz7XsI/DFmQL5me6O27/5ykQ==", + "requires": { + "d3-color": "1", + "d3-dispatch": "1", + "d3-ease": "1", + "d3-interpolate": "1", + "d3-selection": "^1.1.0", + "d3-timer": "1" + } + }, + "d3-voronoi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.2.tgz", + "integrity": "sha1-Fodmfo8TotFYyAwUgMWinLDYlzw=" + }, + "d3-zoom": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.7.1.tgz", + "integrity": "sha512-sZHQ55DGq5BZBFGnRshUT8tm2sfhPHFnOlmPbbwTkAoPeVdRTkB4Xsf9GCY0TSHrTD8PeJPZGmP/TpGicwJDJQ==", + "requires": { + "d3-dispatch": "1", + "d3-drag": "1", + "d3-interpolate": "1", + "d3-selection": "1", + "d3-transition": "1" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3700,7 +3960,7 @@ }, "diffie-hellman": { "version": "5.0.3", - "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", "dev": true, "requires": { @@ -3764,7 +4024,7 @@ "dependencies": { "domelementtype": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.1.3.tgz", "integrity": "sha1-vSh3PiZCiBrsUVRJJCmcXNgiGFs=", "dev": true }, @@ -3969,7 +4229,7 @@ }, "entities": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/entities/-/entities-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.0.0.tgz", "integrity": "sha1-sph6o4ITR/zeZCsk/fyeT7cSvyY=", "dev": true }, @@ -4392,7 +4652,7 @@ }, "eventemitter2": { "version": "0.4.14", - "resolved": "http://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-0.4.14.tgz", "integrity": "sha1-j2G3XN4BKy6esoTUVFWDtWQ7Yas=", "dev": true }, @@ -4404,7 +4664,7 @@ }, "events": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/events/-/events-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", "dev": true }, @@ -4821,7 +5081,7 @@ }, "finalhandler": { "version": "1.1.1", - "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", "dev": true, "requires": { @@ -5057,7 +5317,7 @@ }, "fs-extra": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-1.0.0.tgz", "integrity": "sha1-zTzl9+fLYUWIP8rjGR6Yd/hYeVA=", "dev": true, "requires": { @@ -5726,7 +5986,7 @@ }, "get-stream": { "version": "3.0.0", - "resolved": "http://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", "dev": true }, @@ -5868,7 +6128,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -5945,7 +6205,7 @@ }, "grunt-cli": { "version": "1.2.0", - "resolved": "http://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz", + "resolved": "https://registry.npmjs.org/grunt-cli/-/grunt-cli-1.2.0.tgz", "integrity": "sha1-VisRnrsGndtGSs4oRVAb6Xs1tqg=", "dev": true, "requires": { @@ -5993,7 +6253,7 @@ "dependencies": { "shelljs": { "version": "0.5.3", - "resolved": "http://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.5.3.tgz", "integrity": "sha1-xUmCuZbHbvDB5rWfvcWCX1txMRM=", "dev": true } @@ -6013,7 +6273,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -6058,7 +6318,7 @@ }, "grunt-contrib-jshint": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/grunt-contrib-jshint/-/grunt-contrib-jshint-1.1.0.tgz", "integrity": "sha1-Np2QmyWTxA6L55lAshNAhQx5Oaw=", "dev": true, "requires": { @@ -6157,7 +6417,7 @@ "dependencies": { "colors": { "version": "1.1.2", - "resolved": "http://registry.npmjs.org/colors/-/colors-1.1.2.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", "integrity": "sha1-FopHAXVran9RoSzgyXv6KMCE7WM=", "dev": true } @@ -6221,7 +6481,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -6538,7 +6798,7 @@ }, "htmlparser2": { "version": "3.8.3", - "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.8.3.tgz", "integrity": "sha1-mWwosZFRaovoZQGn15dX5ccMEGg=", "dev": true, "requires": { @@ -6557,7 +6817,7 @@ }, "http-errors": { "version": "1.6.3", - "resolved": "http://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", "dev": true, "requires": { @@ -6607,7 +6867,7 @@ }, "http-proxy-middleware": { "version": "0.18.0", - "resolved": "http://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz", "integrity": "sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q==", "dev": true, "requires": { @@ -6689,7 +6949,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "requires": { "safer-buffer": ">= 2.1.2 < 3" } @@ -7053,7 +7312,7 @@ }, "is-builtin-module": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", "dev": true, "requires": { @@ -7614,7 +7873,7 @@ }, "jsonfile": { "version": "2.4.0", - "resolved": "http://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", "integrity": "sha1-NzaitCi4e72gzIO1P6PWM6NcKug=", "dev": true, "requires": { @@ -7725,7 +7984,7 @@ }, "kew": { "version": "0.7.0", - "resolved": "http://registry.npmjs.org/kew/-/kew-0.7.0.tgz", + "resolved": "https://registry.npmjs.org/kew/-/kew-0.7.0.tgz", "integrity": "sha1-edk9LTM2PW/dKXCzNdkUGtWR15s=", "dev": true }, @@ -7844,7 +8103,7 @@ }, "load-json-file": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", "dev": true, "requires": { @@ -7857,7 +8116,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -8221,7 +8480,7 @@ }, "media-typer": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "dev": true }, @@ -8280,7 +8539,7 @@ }, "meow": { "version": "3.7.0", - "resolved": "http://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", "dev": true, "requires": { @@ -8501,7 +8760,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -8542,7 +8801,7 @@ "dependencies": { "commander": { "version": "2.15.1", - "resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", "dev": true, "optional": true @@ -8711,7 +8970,7 @@ }, "ncp": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/ncp/-/ncp-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-1.0.1.tgz", "integrity": "sha1-0VNn5cuHQyuhF9K/gP30Wuz7QkY=", "dev": true }, @@ -8810,7 +9069,7 @@ "dependencies": { "semver": { "version": "5.3.0", - "resolved": "http://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", "dev": true } @@ -8993,7 +9252,7 @@ "dependencies": { "colors": { "version": "0.5.1", - "resolved": "http://registry.npmjs.org/colors/-/colors-0.5.1.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.5.1.tgz", "integrity": "sha1-fQAj6usVTo7p/Oddy5I9DtFmd3Q=" }, "underscore": { @@ -9287,13 +9546,13 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, "os-locale": { "version": "1.4.0", - "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "dev": true, "requires": { @@ -9302,7 +9561,7 @@ }, "os-tmpdir": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true }, @@ -9526,7 +9785,7 @@ }, "parse-asn1": { "version": "5.1.1", - "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", "dev": true, "requires": { @@ -9612,7 +9871,7 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true }, @@ -9653,7 +9912,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -9836,7 +10095,7 @@ "dependencies": { "async": { "version": "1.5.2", - "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true } @@ -10207,7 +10466,7 @@ }, "progress": { "version": "1.1.8", - "resolved": "http://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=" }, "promise-inflight": { @@ -10232,13 +10491,13 @@ "dependencies": { "async": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/async/-/async-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.0.0.tgz", "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=", "dev": true }, "winston": { "version": "2.1.1", - "resolved": "http://registry.npmjs.org/winston/-/winston-2.1.1.tgz", + "resolved": "https://registry.npmjs.org/winston/-/winston-2.1.1.tgz", "integrity": "sha1-PJNJ0ZYgf9G9/51LxD73JRDjoS4=", "dev": true, "requires": { @@ -10253,7 +10512,7 @@ "dependencies": { "colors": { "version": "1.0.3", - "resolved": "http://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", "dev": true }, @@ -10476,7 +10735,7 @@ "dependencies": { "pify": { "version": "2.3.0", - "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true } @@ -10665,7 +10924,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", "dev": true } @@ -10716,7 +10975,7 @@ }, "htmlparser2": { "version": "3.3.0", - "resolved": "http://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.3.0.tgz", "integrity": "sha1-zHDQWln2VC5D8OaFyYLhTJJKnv4=", "dev": true, "requires": { @@ -10728,7 +10987,7 @@ }, "readable-stream": { "version": "1.0.34", - "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", "dev": true, "requires": { @@ -10973,6 +11232,11 @@ "aproba": "^1.1.1" } }, + "rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q=" + }, "rxjs": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.4.0.tgz", @@ -10995,7 +11259,7 @@ }, "safe-regex": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", "dev": true, "requires": { @@ -11005,8 +11269,7 @@ "safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "sanitize-html": { "version": "1.19.1", @@ -11315,7 +11578,7 @@ }, "sha.js": { "version": "2.4.11", - "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", "dev": true, "requires": { @@ -11359,7 +11622,7 @@ }, "shelljs": { "version": "0.3.0", - "resolved": "http://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.3.0.tgz", "integrity": "sha1-NZbmMHp4FUT1kfN9phg2DzHbV7E=", "dev": true }, @@ -12080,7 +12343,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -12097,7 +12360,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", "dev": true }, @@ -12190,7 +12453,7 @@ }, "tar": { "version": "2.2.1", - "resolved": "http://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", "dev": true, "requires": { @@ -12348,7 +12611,7 @@ }, "through": { "version": "2.3.8", - "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", "dev": true }, @@ -13008,7 +13271,7 @@ "dependencies": { "async": { "version": "0.9.2", - "resolved": "http://registry.npmjs.org/async/-/async-0.9.2.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz", "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=", "dev": true }, @@ -13034,7 +13297,7 @@ }, "valid-data-url": { "version": "0.1.6", - "resolved": "http://registry.npmjs.org/valid-data-url/-/valid-data-url-0.1.6.tgz", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-0.1.6.tgz", "integrity": "sha512-FXg2qXMzfAhZc0y2HzELNfUeiOjPr+52hU1DNBWiJJ2luXD+dD1R9NA48Ug5aj0ibbxroeGDc/RJv6ThiGgkDw==", "dev": true }, @@ -13050,7 +13313,7 @@ }, "validator": { "version": "9.4.1", - "resolved": "http://registry.npmjs.org/validator/-/validator-9.4.1.tgz", + "resolved": "https://registry.npmjs.org/validator/-/validator-9.4.1.tgz", "integrity": "sha512-YV5KjzvRmSyJ1ee/Dm5UED0G+1L4GZnLN3w6/T+zZm8scVua4sOhYKWTUrKa0H/tMiJyO9QLHMPN+9mB/aMunA==", "dev": true }, @@ -13582,7 +13845,7 @@ }, "webpack-node-externals": { "version": "1.7.2", - "resolved": "http://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz", "integrity": "sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg==", "dev": true }, @@ -13736,14 +13999,14 @@ "dependencies": { "async": { "version": "1.0.0", - "resolved": "http://registry.npmjs.org/async/-/async-1.0.0.tgz", + "resolved": "https://registry.npmjs.org/async/-/async-1.0.0.tgz", "integrity": "sha1-+PwEyjoTeErenhZBr5hXjPvWR6k=", "dev": true, "optional": true }, "colors": { "version": "1.0.3", - "resolved": "http://registry.npmjs.org/colors/-/colors-1.0.3.tgz", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.0.3.tgz", "integrity": "sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=", "dev": true, "optional": true @@ -13776,7 +14039,7 @@ }, "wrap-ansi": { "version": "2.1.0", - "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", "dev": true, "requires": { @@ -13885,6 +14148,11 @@ "resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.27.tgz", "integrity": "sha1-1QH5ezvbQDr4757MIFcxh6rawOk=" }, + "xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha1-Z/4HXFwk/vOfnWX197f+dRcZaPw=" + }, "xpath": { "version": "0.0.27", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.27.tgz", diff --git a/src/core/lib/Charts.mjs b/src/core/lib/Charts.mjs new file mode 100644 index 00000000..8cb9d224 --- /dev/null +++ b/src/core/lib/Charts.mjs @@ -0,0 +1,177 @@ +/** + * @author tlwr [toby@toby.codes] - Original + * @author Matt C [matt@artemisbot.uk] - Conversion to new format + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError"; + + /** + * @constant + * @default + */ +export const RECORD_DELIMITER_OPTIONS = ["Line feed", "CRLF"]; + + +/** + * @constant + * @default + */ +export const FIELD_DELIMITER_OPTIONS = ["Space", "Comma", "Semi-colon", "Colon", "Tab"]; + + +/** + * Default from colour + * + * @constant + * @default + */ +export const COLOURS = { + min: "white", + max: "black" +}; + + +/** + * Gets values from input for a plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ +export function getValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded, length) { + let headings; + const values = []; + + input + .split(recordDelimiter) + .forEach((row, rowIndex) => { + const split = row.split(fieldDelimiter); + + if (split.length !== length) throw new OperationError(`Each row must have length ${length}.`); + + if (columnHeadingsAreIncluded && rowIndex === 0) { + headings = split; + } else { + values.push(split); + } + }); + + return { headings, values}; +} + + +/** + * Gets values from input for a scatter plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ +export function getScatterValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let { headings, values } = getValues( + input, + recordDelimiter, fieldDelimiter, + columnHeadingsAreIncluded, + 2 + ); + + if (headings) { + headings = {x: headings[0], y: headings[1]}; + } + + values = values.map(row => { + const x = parseFloat(row[0], 10), + y = parseFloat(row[1], 10); + + if (Number.isNaN(x)) throw new OperationError("Values must be numbers in base 10."); + if (Number.isNaN(y)) throw new OperationError("Values must be numbers in base 10."); + + return [x, y]; + }); + + return { headings, values }; +} + +/** + * Gets values from input for a scatter plot with colour from the third column. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ +export function getScatterValuesWithColour(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + let { headings, values } = getValues( + input, + recordDelimiter, fieldDelimiter, + columnHeadingsAreIncluded, + 3 + ); + + if (headings) { + headings = {x: headings[0], y: headings[1]}; + } + + values = values.map(row => { + const x = parseFloat(row[0], 10), + y = parseFloat(row[1], 10), + colour = row[2]; + + if (Number.isNaN(x)) throw new OperationError("Values must be numbers in base 10."); + if (Number.isNaN(y)) throw new OperationError("Values must be numbers in base 10."); + + return [x, y, colour]; + }); + + return { headings, values }; +} + +/** + * Gets values from input for a time series plot. + * + * @param {string} input + * @param {string} recordDelimiter + * @param {string} fieldDelimiter + * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record + * @returns {Object[]} + */ +export function getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { + const { values } = getValues( + input, + recordDelimiter, fieldDelimiter, + false, + 3 + ); + + let xValues = new Set(); + const series = {}; + + values.forEach(row => { + const serie = row[0], + xVal = row[1], + val = parseFloat(row[2], 10); + + if (Number.isNaN(val)) throw new OperationError("Values must be numbers in base 10."); + + xValues.add(xVal); + if (typeof series[serie] === "undefined") series[serie] = {}; + series[serie][xVal] = val; + }); + + xValues = new Array(...xValues); + + const seriesList = []; + for (const seriesName in series) { + const serie = series[seriesName]; + seriesList.push({name: seriesName, data: serie}); + } + + return { xValues, series: seriesList }; +} diff --git a/src/core/operations/Charts.js b/src/core/operations/Charts.js deleted file mode 100755 index 2ce084d0..00000000 --- a/src/core/operations/Charts.js +++ /dev/null @@ -1,841 +0,0 @@ -import * as d3 from "d3"; -import {hexbin as d3hexbin} from "d3-hexbin"; -import Utils from "../Utils.js"; - -/** - * Charting operations. - * - * @author tlwr [toby@toby.com] - * @copyright Crown Copyright 2016 - * @license Apache-2.0 - * - * @namespace - */ -const Charts = { - /** - * @constant - * @default - */ - RECORD_DELIMITER_OPTIONS: ["Line feed", "CRLF"], - - - /** - * @constant - * @default - */ - FIELD_DELIMITER_OPTIONS: ["Space", "Comma", "Semi-colon", "Colon", "Tab"], - - - /** - * Default from colour - * - * @constant - * @default - */ - COLOURS: { - min: "white", - max: "black", - }, - - - /** - * Gets values from input for a plot. - * - * @param {string} input - * @param {string} recordDelimiter - * @param {string} fieldDelimiter - * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record - * @returns {Object[]} - */ - _getValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded, length) { - let headings; - const values = []; - - input - .split(recordDelimiter) - .forEach((row, rowIndex) => { - let split = row.split(fieldDelimiter); - - if (split.length !== length) throw `Each row must have length ${length}.`; - - if (columnHeadingsAreIncluded && rowIndex === 0) { - headings = split; - } else { - values.push(split); - } - }); - - return { headings, values}; - }, - - - /** - * Gets values from input for a scatter plot. - * - * @param {string} input - * @param {string} recordDelimiter - * @param {string} fieldDelimiter - * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record - * @returns {Object[]} - */ - _getScatterValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { - let { headings, values } = Charts._getValues( - input, - recordDelimiter, fieldDelimiter, - columnHeadingsAreIncluded, - 2 - ); - - if (headings) { - headings = {x: headings[0], y: headings[1]}; - } - - values = values.map(row => { - let x = parseFloat(row[0], 10), - y = parseFloat(row[1], 10); - - if (Number.isNaN(x)) throw "Values must be numbers in base 10."; - if (Number.isNaN(y)) throw "Values must be numbers in base 10."; - - return [x, y]; - }); - - return { headings, values }; - }, - - - /** - * Gets values from input for a scatter plot with colour from the third column. - * - * @param {string} input - * @param {string} recordDelimiter - * @param {string} fieldDelimiter - * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record - * @returns {Object[]} - */ - _getScatterValuesWithColour(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { - let { headings, values } = Charts._getValues( - input, - recordDelimiter, fieldDelimiter, - columnHeadingsAreIncluded, - 3 - ); - - if (headings) { - headings = {x: headings[0], y: headings[1]}; - } - - values = values.map(row => { - let x = parseFloat(row[0], 10), - y = parseFloat(row[1], 10), - colour = row[2]; - - if (Number.isNaN(x)) throw "Values must be numbers in base 10."; - if (Number.isNaN(y)) throw "Values must be numbers in base 10."; - - return [x, y, colour]; - }); - - return { headings, values }; - }, - - - /** - * Gets values from input for a time series plot. - * - * @param {string} input - * @param {string} recordDelimiter - * @param {string} fieldDelimiter - * @param {boolean} columnHeadingsAreIncluded - whether we should skip the first record - * @returns {Object[]} - */ - _getSeriesValues(input, recordDelimiter, fieldDelimiter, columnHeadingsAreIncluded) { - let { headings, values } = Charts._getValues( - input, - recordDelimiter, fieldDelimiter, - false, - 3 - ); - - let xValues = new Set(), - series = {}; - - values = values.forEach(row => { - let serie = row[0], - xVal = row[1], - val = parseFloat(row[2], 10); - - if (Number.isNaN(val)) throw "Values must be numbers in base 10."; - - xValues.add(xVal); - if (typeof series[serie] === "undefined") series[serie] = {}; - series[serie][xVal] = val; - }); - - xValues = new Array(...xValues); - - const seriesList = []; - for (let seriesName in series) { - let serie = series[seriesName]; - seriesList.push({name: seriesName, data: serie}); - } - - return { xValues, series: seriesList }; - }, - - - /** - * Hex Bin chart operation. - * - * @param {Object[]} - centres - * @param {number} - radius - * @returns {Object[]} - */ - _getEmptyHexagons(centres, radius) { - const emptyCentres = []; - let boundingRect = [d3.extent(centres, d => d.x), d3.extent(centres, d => d.y)], - indent = false, - hexagonCenterToEdge = Math.cos(2 * Math.PI / 12) * radius, - hexagonEdgeLength = Math.sin(2 * Math.PI / 12) * radius; - - for (let y = boundingRect[1][0]; y <= boundingRect[1][1] + radius; y += hexagonEdgeLength + radius) { - for (let x = boundingRect[0][0]; x <= boundingRect[0][1] + radius; x += 2 * hexagonCenterToEdge) { - let cx = x, - cy = y; - - if (indent && x >= boundingRect[0][1]) break; - if (indent) cx += hexagonCenterToEdge; - - emptyCentres.push({x: cx, y: cy}); - } - indent = !indent; - } - - return emptyCentres; - }, - - - /** - * Hex Bin chart operation. - * - * @param {string} input - * @param {Object[]} args - * @returns {html} - */ - runHexDensityChart: function (input, args) { - const recordDelimiter = Utils.charRep[args[0]], - fieldDelimiter = Utils.charRep[args[1]], - packRadius = args[2], - drawRadius = args[3], - columnHeadingsAreIncluded = args[4], - drawEdges = args[7], - minColour = args[8], - maxColour = args[9], - drawEmptyHexagons = args[10], - dimension = 500; - - let xLabel = args[5], - yLabel = args[6], - { headings, values } = Charts._getScatterValues( - input, - recordDelimiter, - fieldDelimiter, - columnHeadingsAreIncluded - ); - - if (headings) { - xLabel = headings.x; - yLabel = headings.y; - } - - let svg = document.createElement("svg"); - svg = d3.select(svg) - .attr("width", "100%") - .attr("height", "100%") - .attr("viewBox", `0 0 ${dimension} ${dimension}`); - - let margin = { - top: 10, - right: 0, - bottom: 40, - left: 30, - }, - width = dimension - margin.left - margin.right, - height = dimension - margin.top - margin.bottom, - marginedSpace = svg.append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - let hexbin = d3hexbin() - .radius(packRadius) - .extent([0, 0], [width, height]); - - let hexPoints = hexbin(values), - maxCount = Math.max(...hexPoints.map(b => b.length)); - - let xExtent = d3.extent(hexPoints, d => d.x), - yExtent = d3.extent(hexPoints, d => d.y); - xExtent[0] -= 2 * packRadius; - xExtent[1] += 3 * packRadius; - yExtent[0] -= 2 * packRadius; - yExtent[1] += 2 * packRadius; - - let xAxis = d3.scaleLinear() - .domain(xExtent) - .range([0, width]); - let yAxis = d3.scaleLinear() - .domain(yExtent) - .range([height, 0]); - - let colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) - .domain([0, maxCount]); - - marginedSpace.append("clipPath") - .attr("id", "clip") - .append("rect") - .attr("width", width) - .attr("height", height); - - if (drawEmptyHexagons) { - marginedSpace.append("g") - .attr("class", "empty-hexagon") - .selectAll("path") - .data(Charts._getEmptyHexagons(hexPoints, packRadius)) - .enter() - .append("path") - .attr("d", d => { - return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; - }) - .attr("fill", (d) => colour(0)) - .attr("stroke", drawEdges ? "black" : "none") - .attr("stroke-width", drawEdges ? "0.5" : "none") - .append("title") - .text(d => { - let count = 0, - perc = 0, - tooltip = `Count: ${count}\n - Percentage: ${perc.toFixed(2)}%\n - Center: ${d.x.toFixed(2)}, ${d.y.toFixed(2)}\n - `.replace(/\s{2,}/g, "\n"); - return tooltip; - }); - } - - marginedSpace.append("g") - .attr("class", "hexagon") - .attr("clip-path", "url(#clip)") - .selectAll("path") - .data(hexPoints) - .enter() - .append("path") - .attr("d", d => { - return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; - }) - .attr("fill", (d) => colour(d.length)) - .attr("stroke", drawEdges ? "black" : "none") - .attr("stroke-width", drawEdges ? "0.5" : "none") - .append("title") - .text(d => { - let count = d.length, - perc = 100.0 * d.length / values.length, - CX = d.x, - CY = d.y, - xMin = Math.min(...d.map(d => d[0])), - xMax = Math.max(...d.map(d => d[0])), - yMin = Math.min(...d.map(d => d[1])), - yMax = Math.max(...d.map(d => d[1])), - tooltip = `Count: ${count}\n - Percentage: ${perc.toFixed(2)}%\n - Center: ${CX.toFixed(2)}, ${CY.toFixed(2)}\n - Min X: ${xMin.toFixed(2)}\n - Max X: ${xMax.toFixed(2)}\n - Min Y: ${yMin.toFixed(2)}\n - Max Y: ${yMax.toFixed(2)} - `.replace(/\s{2,}/g, "\n"); - return tooltip; - }); - - marginedSpace.append("g") - .attr("class", "axis axis--y") - .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); - - svg.append("text") - .attr("transform", "rotate(-90)") - .attr("y", -margin.left) - .attr("x", -(height / 2)) - .attr("dy", "1em") - .style("text-anchor", "middle") - .text(yLabel); - - marginedSpace.append("g") - .attr("class", "axis axis--x") - .attr("transform", "translate(0," + height + ")") - .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); - - svg.append("text") - .attr("x", width / 2) - .attr("y", dimension) - .style("text-anchor", "middle") - .text(xLabel); - - return svg._groups[0][0].outerHTML; - }, - - - /** - * Packs a list of x, y coordinates into a number of bins for use in a heatmap. - * - * @param {Object[]} points - * @param {number} number of vertical bins - * @param {number} number of horizontal bins - * @returns {Object[]} a list of bins (each bin is an Array) with x y coordinates, filled with the points - */ - _getHeatmapPacking(values, vBins, hBins) { - const xBounds = d3.extent(values, d => d[0]), - yBounds = d3.extent(values, d => d[1]), - bins = []; - - if (xBounds[0] === xBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum X coordinate."; - if (yBounds[0] === yBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum Y coordinate."; - - for (let y = 0; y < vBins; y++) { - bins.push([]); - for (let x = 0; x < hBins; x++) { - let item = []; - item.y = y; - item.x = x; - - bins[y].push(item); - } // x - } // y - - let epsilon = 0.000000001; // This is to clamp values that are exactly the maximum; - - values.forEach(v => { - let fractionOfY = (v[1] - yBounds[0]) / ((yBounds[1] + epsilon) - yBounds[0]), - fractionOfX = (v[0] - xBounds[0]) / ((xBounds[1] + epsilon) - xBounds[0]); - let y = Math.floor(vBins * fractionOfY), - x = Math.floor(hBins * fractionOfX); - - bins[y][x].push({x: v[0], y: v[1]}); - }); - - return bins; - }, - - - /** - * Heatmap chart operation. - * - * @param {string} input - * @param {Object[]} args - * @returns {html} - */ - runHeatmapChart: function (input, args) { - const recordDelimiter = Utils.charRep[args[0]], - fieldDelimiter = Utils.charRep[args[1]], - vBins = args[2], - hBins = args[3], - columnHeadingsAreIncluded = args[4], - drawEdges = args[7], - minColour = args[8], - maxColour = args[9], - dimension = 500; - - if (vBins <= 0) throw "Number of vertical bins must be greater than 0"; - if (hBins <= 0) throw "Number of horizontal bins must be greater than 0"; - - let xLabel = args[5], - yLabel = args[6], - { headings, values } = Charts._getScatterValues( - input, - recordDelimiter, - fieldDelimiter, - columnHeadingsAreIncluded - ); - - if (headings) { - xLabel = headings.x; - yLabel = headings.y; - } - - let svg = document.createElement("svg"); - svg = d3.select(svg) - .attr("width", "100%") - .attr("height", "100%") - .attr("viewBox", `0 0 ${dimension} ${dimension}`); - - let margin = { - top: 10, - right: 0, - bottom: 40, - left: 30, - }, - width = dimension - margin.left - margin.right, - height = dimension - margin.top - margin.bottom, - binWidth = width / hBins, - binHeight = height/ vBins, - marginedSpace = svg.append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - let bins = Charts._getHeatmapPacking(values, vBins, hBins), - maxCount = Math.max(...bins.map(row => { - let lengths = row.map(cell => cell.length); - return Math.max(...lengths); - })); - - let xExtent = d3.extent(values, d => d[0]), - yExtent = d3.extent(values, d => d[1]); - - let xAxis = d3.scaleLinear() - .domain(xExtent) - .range([0, width]); - let yAxis = d3.scaleLinear() - .domain(yExtent) - .range([height, 0]); - - let colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) - .domain([0, maxCount]); - - marginedSpace.append("clipPath") - .attr("id", "clip") - .append("rect") - .attr("width", width) - .attr("height", height); - - marginedSpace.append("g") - .attr("class", "bins") - .attr("clip-path", "url(#clip)") - .selectAll("g") - .data(bins) - .enter() - .append("g") - .selectAll("rect") - .data(d => d) - .enter() - .append("rect") - .attr("x", (d) => binWidth * d.x) - .attr("y", (d) => (height - binHeight * (d.y + 1))) - .attr("width", binWidth) - .attr("height", binHeight) - .attr("fill", (d) => colour(d.length)) - .attr("stroke", drawEdges ? "rgba(0, 0, 0, 0.5)" : "none") - .attr("stroke-width", drawEdges ? "0.5" : "none") - .append("title") - .text(d => { - let count = d.length, - perc = 100.0 * d.length / values.length, - tooltip = `Count: ${count}\n - Percentage: ${perc.toFixed(2)}%\n - `.replace(/\s{2,}/g, "\n"); - return tooltip; - }); - - marginedSpace.append("g") - .attr("class", "axis axis--y") - .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); - - svg.append("text") - .attr("transform", "rotate(-90)") - .attr("y", -margin.left) - .attr("x", -(height / 2)) - .attr("dy", "1em") - .style("text-anchor", "middle") - .text(yLabel); - - marginedSpace.append("g") - .attr("class", "axis axis--x") - .attr("transform", "translate(0," + height + ")") - .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); - - svg.append("text") - .attr("x", width / 2) - .attr("y", dimension) - .style("text-anchor", "middle") - .text(xLabel); - - return svg._groups[0][0].outerHTML; - }, - - - /** - * Scatter chart operation. - * - * @param {string} input - * @param {Object[]} args - * @returns {html} - */ - runScatterChart: function (input, args) { - const recordDelimiter = Utils.charRep[args[0]], - fieldDelimiter = Utils.charRep[args[1]], - columnHeadingsAreIncluded = args[2], - fillColour = args[5], - radius = args[6], - colourInInput = args[7], - dimension = 500; - - let xLabel = args[3], - yLabel = args[4]; - - let dataFunction = colourInInput ? Charts._getScatterValuesWithColour : Charts._getScatterValues; - - let { headings, values } = dataFunction( - input, - recordDelimiter, - fieldDelimiter, - columnHeadingsAreIncluded - ); - - if (headings) { - xLabel = headings.x; - yLabel = headings.y; - } - - let svg = document.createElement("svg"); - svg = d3.select(svg) - .attr("width", "100%") - .attr("height", "100%") - .attr("viewBox", `0 0 ${dimension} ${dimension}`); - - let margin = { - top: 10, - right: 0, - bottom: 40, - left: 30, - }, - width = dimension - margin.left - margin.right, - height = dimension - margin.top - margin.bottom, - marginedSpace = svg.append("g") - .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); - - let xExtent = d3.extent(values, d => d[0]), - xDelta = xExtent[1] - xExtent[0], - yExtent = d3.extent(values, d => d[1]), - yDelta = yExtent[1] - yExtent[0], - xAxis = d3.scaleLinear() - .domain([xExtent[0] - (0.1 * xDelta), xExtent[1] + (0.1 * xDelta)]) - .range([0, width]), - yAxis = d3.scaleLinear() - .domain([yExtent[0] - (0.1 * yDelta), yExtent[1] + (0.1 * yDelta)]) - .range([height, 0]); - - marginedSpace.append("clipPath") - .attr("id", "clip") - .append("rect") - .attr("width", width) - .attr("height", height); - - marginedSpace.append("g") - .attr("class", "points") - .attr("clip-path", "url(#clip)") - .selectAll("circle") - .data(values) - .enter() - .append("circle") - .attr("cx", (d) => xAxis(d[0])) - .attr("cy", (d) => yAxis(d[1])) - .attr("r", d => radius) - .attr("fill", d => { - return colourInInput ? d[2] : fillColour; - }) - .attr("stroke", "rgba(0, 0, 0, 0.5)") - .attr("stroke-width", "0.5") - .append("title") - .text(d => { - let x = d[0], - y = d[1], - tooltip = `X: ${x}\n - Y: ${y}\n - `.replace(/\s{2,}/g, "\n"); - return tooltip; - }); - - marginedSpace.append("g") - .attr("class", "axis axis--y") - .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); - - svg.append("text") - .attr("transform", "rotate(-90)") - .attr("y", -margin.left) - .attr("x", -(height / 2)) - .attr("dy", "1em") - .style("text-anchor", "middle") - .text(yLabel); - - marginedSpace.append("g") - .attr("class", "axis axis--x") - .attr("transform", "translate(0," + height + ")") - .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); - - svg.append("text") - .attr("x", width / 2) - .attr("y", dimension) - .style("text-anchor", "middle") - .text(xLabel); - - return svg._groups[0][0].outerHTML; - }, - - - /** - * Series chart operation. - * - * @param {string} input - * @param {Object[]} args - * @returns {html} - */ - runSeriesChart(input, args) { - const recordDelimiter = Utils.charRep[args[0]], - fieldDelimiter = Utils.charRep[args[1]], - xLabel = args[2], - pipRadius = args[3], - seriesColours = args[4].split(","), - svgWidth = 500, - interSeriesPadding = 20, - xAxisHeight = 50, - seriesLabelWidth = 50, - seriesHeight = 100, - seriesWidth = svgWidth - seriesLabelWidth - interSeriesPadding; - - let { xValues, series } = Charts._getSeriesValues(input, recordDelimiter, fieldDelimiter), - allSeriesHeight = Object.keys(series).length * (interSeriesPadding + seriesHeight), - svgHeight = allSeriesHeight + xAxisHeight + interSeriesPadding; - - let svg = document.createElement("svg"); - svg = d3.select(svg) - .attr("width", "100%") - .attr("height", "100%") - .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`); - - let xAxis = d3.scalePoint() - .domain(xValues) - .range([0, seriesWidth]); - - svg.append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`) - .call( - d3.axisTop(xAxis).tickValues(xValues.filter((x, i) => { - return [0, Math.round(xValues.length / 2), xValues.length -1].indexOf(i) >= 0; - })) - ); - - svg.append("text") - .attr("x", svgWidth / 2) - .attr("y", xAxisHeight / 2) - .style("text-anchor", "middle") - .text(xLabel); - - let tooltipText = {}, - tooltipAreaWidth = seriesWidth / xValues.length; - - xValues.forEach(x => { - let tooltip = []; - - series.forEach(serie => { - let y = serie.data[x]; - if (typeof y === "undefined") return; - - tooltip.push(`${serie.name}: ${y}`); - }); - - tooltipText[x] = tooltip.join("\n"); - }); - - let chartArea = svg.append("g") - .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`); - - chartArea - .append("g") - .selectAll("rect") - .data(xValues) - .enter() - .append("rect") - .attr("x", x => { - return xAxis(x) - (tooltipAreaWidth / 2); - }) - .attr("y", 0) - .attr("width", tooltipAreaWidth) - .attr("height", allSeriesHeight) - .attr("stroke", "none") - .attr("fill", "transparent") - .append("title") - .text(x => { - return `${x}\n - --\n - ${tooltipText[x]}\n - `.replace(/\s{2,}/g, "\n"); - }); - - let yAxesArea = svg.append("g") - .attr("transform", `translate(0, ${xAxisHeight})`); - - series.forEach((serie, seriesIndex) => { - let yExtent = d3.extent(Object.values(serie.data)), - yAxis = d3.scaleLinear() - .domain(yExtent) - .range([seriesHeight, 0]); - - let seriesGroup = chartArea - .append("g") - .attr("transform", `translate(0, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`); - - let path = ""; - xValues.forEach((x, xIndex) => { - let nextX = xValues[xIndex + 1], - y = serie.data[x], - nextY= serie.data[nextX]; - - if (typeof y === "undefined" || typeof nextY === "undefined") return; - - x = xAxis(x); nextX = xAxis(nextX); - y = yAxis(y); nextY = yAxis(nextY); - - path += `M ${x} ${y} L ${nextX} ${nextY} z `; - }); - - seriesGroup - .append("path") - .attr("d", path) - .attr("fill", "none") - .attr("stroke", seriesColours[seriesIndex % seriesColours.length]) - .attr("stroke-width", "1"); - - xValues.forEach(x => { - let y = serie.data[x]; - if (typeof y === "undefined") return; - - seriesGroup - .append("circle") - .attr("cx", xAxis(x)) - .attr("cy", yAxis(y)) - .attr("r", pipRadius) - .attr("fill", seriesColours[seriesIndex % seriesColours.length]) - .append("title") - .text(d => { - return `${x}\n - --\n - ${tooltipText[x]}\n - `.replace(/\s{2,}/g, "\n"); - }); - }); - - yAxesArea - .append("g") - .attr("transform", `translate(${seriesLabelWidth - interSeriesPadding}, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) - .attr("class", "axis axis--y") - .call(d3.axisLeft(yAxis).ticks(5)); - - yAxesArea - .append("g") - .attr("transform", `translate(0, ${seriesHeight / 2 + seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) - .append("text") - .style("text-anchor", "middle") - .attr("transform", "rotate(-90)") - .text(serie.name); - }); - - return svg._groups[0][0].outerHTML; - }, -}; - -export default Charts; diff --git a/src/core/operations/HeatmapChart.mjs b/src/core/operations/HeatmapChart.mjs new file mode 100644 index 00000000..047ce054 --- /dev/null +++ b/src/core/operations/HeatmapChart.mjs @@ -0,0 +1,260 @@ +/** + * @author tlwr [toby@toby.codes] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import * as d3 from "d3"; +import { getScatterValues, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; + + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Utils from "../Utils"; + +/** + * Heatmap chart operation + */ +class HeatmapChart extends Operation { + + /** + * HeatmapChart constructor + */ + constructor() { + super(); + + this.name = "Heatmap chart"; + this.module = "Charts"; + this.description = ""; + this.infoURL = ""; + this.inputType = "string"; + this.outputType = "html"; + this.args = [ + { + name: "Record delimiter", + type: "option", + value: RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: FIELD_DELIMITER_OPTIONS, + }, + { + name: "Number of vertical bins", + type: "number", + value: 25, + }, + { + name: "Number of horizontal bins", + type: "number", + value: 25, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + { + name: "Draw bin edges", + type: "boolean", + value: false, + }, + { + name: "Min colour value", + type: "string", + value: COLOURS.min, + }, + { + name: "Max colour value", + type: "string", + value: COLOURS.max, + }, + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + run(input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + vBins = args[2], + hBins = args[3], + columnHeadingsAreIncluded = args[4], + drawEdges = args[7], + minColour = args[8], + maxColour = args[9], + dimension = 500; + + if (vBins <= 0) throw new OperationError("Number of vertical bins must be greater than 0"); + if (hBins <= 0) throw new OperationError("Number of horizontal bins must be greater than 0"); + + let xLabel = args[5], + yLabel = args[6]; + const { headings, values } = getScatterValues( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + const margin = { + top: 10, + right: 0, + bottom: 40, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + binWidth = width / hBins, + binHeight = height/ vBins, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + const bins = this.getHeatmapPacking(values, vBins, hBins), + maxCount = Math.max(...bins.map(row => { + const lengths = row.map(cell => cell.length); + return Math.max(...lengths); + })); + + const xExtent = d3.extent(values, d => d[0]), + yExtent = d3.extent(values, d => d[1]); + + const xAxis = d3.scaleLinear() + .domain(xExtent) + .range([0, width]); + const yAxis = d3.scaleLinear() + .domain(yExtent) + .range([height, 0]); + + const colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) + .domain([0, maxCount]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "bins") + .attr("clip-path", "url(#clip)") + .selectAll("g") + .data(bins) + .enter() + .append("g") + .selectAll("rect") + .data(d => d) + .enter() + .append("rect") + .attr("x", (d) => binWidth * d.x) + .attr("y", (d) => (height - binHeight * (d.y + 1))) + .attr("width", binWidth) + .attr("height", binHeight) + .attr("fill", (d) => colour(d.length)) + .attr("stroke", drawEdges ? "rgba(0, 0, 0, 0.5)" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") + .append("title") + .text(d => { + let count = d.length, + perc = 100.0 * d.length / values.length, + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + } + + /** + * Packs a list of x, y coordinates into a number of bins for use in a heatmap. + * + * @param {Object[]} points + * @param {number} number of vertical bins + * @param {number} number of horizontal bins + * @returns {Object[]} a list of bins (each bin is an Array) with x y coordinates, filled with the points + */ + getHeatmapPacking(values, vBins, hBins) { + const xBounds = d3.extent(values, d => d[0]), + yBounds = d3.extent(values, d => d[1]), + bins = []; + + if (xBounds[0] === xBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum X coordinate."; + if (yBounds[0] === yBounds[1]) throw "Cannot pack points. There is no difference between the minimum and maximum Y coordinate."; + + for (let y = 0; y < vBins; y++) { + bins.push([]); + for (let x = 0; x < hBins; x++) { + const item = []; + item.y = y; + item.x = x; + + bins[y].push(item); + } // x + } // y + + const epsilon = 0.000000001; // This is to clamp values that are exactly the maximum; + + values.forEach(v => { + const fractionOfY = (v[1] - yBounds[0]) / ((yBounds[1] + epsilon) - yBounds[0]), + fractionOfX = (v[0] - xBounds[0]) / ((xBounds[1] + epsilon) - xBounds[0]), + y = Math.floor(vBins * fractionOfY), + x = Math.floor(hBins * fractionOfX); + + bins[y][x].push({x: v[0], y: v[1]}); + }); + + return bins; + } + +} + +export default HeatmapChart; diff --git a/src/core/operations/HexDensityChart.mjs b/src/core/operations/HexDensityChart.mjs new file mode 100644 index 00000000..3d010f13 --- /dev/null +++ b/src/core/operations/HexDensityChart.mjs @@ -0,0 +1,287 @@ +/** + * @author tlwr [toby@toby.codes] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import * as d3 from "d3"; +import * as d3hexbin from "d3-hexbin"; +import { getScatterValues, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; + +import Operation from "../Operation"; +import Utils from "../Utils"; + +/** + * Hex Density chart operation + */ +class HexDensityChart extends Operation { + + /** + * HexDensityChart constructor + */ + constructor() { + super(); + + this.name = "Hex Density chart"; + this.module = "Charts"; + this.description = ""; + this.infoURL = ""; + this.inputType = "string"; + this.outputType = "html"; + this.args = [ + { + name: "Record delimiter", + type: "option", + value: RECORD_DELIMITER_OPTIONS, + }, + { + name: "Field delimiter", + type: "option", + value: FIELD_DELIMITER_OPTIONS, + }, + { + name: "Pack radius", + type: "number", + value: 25, + }, + { + name: "Draw radius", + type: "number", + value: 15, + }, + { + name: "Use column headers as labels", + type: "boolean", + value: true, + }, + { + name: "X label", + type: "string", + value: "", + }, + { + name: "Y label", + type: "string", + value: "", + }, + { + name: "Draw hexagon edges", + type: "boolean", + value: false, + }, + { + name: "Min colour value", + type: "string", + value: COLOURS.min, + }, + { + name: "Max colour value", + type: "string", + value: COLOURS.max, + }, + { + name: "Draw empty hexagons within data boundaries", + type: "boolean", + value: false, + } + ]; + } + + + /** + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + run(input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + packRadius = args[2], + drawRadius = args[3], + columnHeadingsAreIncluded = args[4], + drawEdges = args[7], + minColour = args[8], + maxColour = args[9], + drawEmptyHexagons = args[10], + dimension = 500; + + let xLabel = args[5], + yLabel = args[6]; + const { headings, values } = getScatterValues( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + const margin = { + top: 10, + right: 0, + bottom: 40, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + const hexbin = d3hexbin.hexbin() + .radius(packRadius) + .extent([0, 0], [width, height]); + + const hexPoints = hexbin(values), + maxCount = Math.max(...hexPoints.map(b => b.length)); + + const xExtent = d3.extent(hexPoints, d => d.x), + yExtent = d3.extent(hexPoints, d => d.y); + xExtent[0] -= 2 * packRadius; + xExtent[1] += 3 * packRadius; + yExtent[0] -= 2 * packRadius; + yExtent[1] += 2 * packRadius; + + const xAxis = d3.scaleLinear() + .domain(xExtent) + .range([0, width]); + const yAxis = d3.scaleLinear() + .domain(yExtent) + .range([height, 0]); + + const colour = d3.scaleSequential(d3.interpolateLab(minColour, maxColour)) + .domain([0, maxCount]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + if (drawEmptyHexagons) { + marginedSpace.append("g") + .attr("class", "empty-hexagon") + .selectAll("path") + .data(this.getEmptyHexagons(hexPoints, packRadius)) + .enter() + .append("path") + .attr("d", d => { + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; + }) + .attr("fill", (d) => colour(0)) + .attr("stroke", drawEdges ? "black" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") + .append("title") + .text(d => { + const count = 0, + perc = 0, + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + Center: ${d.x.toFixed(2)}, ${d.y.toFixed(2)}\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + } + + marginedSpace.append("g") + .attr("class", "hexagon") + .attr("clip-path", "url(#clip)") + .selectAll("path") + .data(hexPoints) + .enter() + .append("path") + .attr("d", d => { + return `M${xAxis(d.x)},${yAxis(d.y)} ${hexbin.hexagon(drawRadius)}`; + }) + .attr("fill", (d) => colour(d.length)) + .attr("stroke", drawEdges ? "black" : "none") + .attr("stroke-width", drawEdges ? "0.5" : "none") + .append("title") + .text(d => { + const count = d.length, + perc = 100.0 * d.length / values.length, + CX = d.x, + CY = d.y, + xMin = Math.min(...d.map(d => d[0])), + xMax = Math.max(...d.map(d => d[0])), + yMin = Math.min(...d.map(d => d[1])), + yMax = Math.max(...d.map(d => d[1])), + tooltip = `Count: ${count}\n + Percentage: ${perc.toFixed(2)}%\n + Center: ${CX.toFixed(2)}, ${CY.toFixed(2)}\n + Min X: ${xMin.toFixed(2)}\n + Max X: ${xMax.toFixed(2)}\n + Min Y: ${yMin.toFixed(2)}\n + Max Y: ${yMax.toFixed(2)} + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + } + + + /** + * Hex Bin chart operation. + * + * @param {Object[]} - centres + * @param {number} - radius + * @returns {Object[]} + */ + getEmptyHexagons(centres, radius) { + const emptyCentres = [], + boundingRect = [d3.extent(centres, d => d.x), d3.extent(centres, d => d.y)], + hexagonCenterToEdge = Math.cos(2 * Math.PI / 12) * radius, + hexagonEdgeLength = Math.sin(2 * Math.PI / 12) * radius; + let indent = false; + + for (let y = boundingRect[1][0]; y <= boundingRect[1][1] + radius; y += hexagonEdgeLength + radius) { + for (let x = boundingRect[0][0]; x <= boundingRect[0][1] + radius; x += 2 * hexagonCenterToEdge) { + let cx = x; + const cy = y; + + if (indent && x >= boundingRect[0][1]) break; + if (indent) cx += hexagonCenterToEdge; + + emptyCentres.push({x: cx, y: cy}); + } + indent = !indent; + } + + return emptyCentres; + } + +} + +export default HexDensityChart; diff --git a/src/core/operations/legacy/Charts.js b/src/core/operations/legacy/Charts.js new file mode 100755 index 00000000..1d4a5a3b --- /dev/null +++ b/src/core/operations/legacy/Charts.js @@ -0,0 +1,297 @@ +import * as d3 from "d3"; +import Utils from "../Utils.js"; + +/** + * Charting operations. + * + * @author tlwr [toby@toby.com] + * @copyright Crown Copyright 2016 + * @license Apache-2.0 + * + * @namespace + */ +const Charts = { + + + /** + * Scatter chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runScatterChart: function (input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + columnHeadingsAreIncluded = args[2], + fillColour = args[5], + radius = args[6], + colourInInput = args[7], + dimension = 500; + + let xLabel = args[3], + yLabel = args[4]; + + let dataFunction = colourInInput ? Charts._getScatterValuesWithColour : Charts._getScatterValues; + + let { headings, values } = dataFunction( + input, + recordDelimiter, + fieldDelimiter, + columnHeadingsAreIncluded + ); + + if (headings) { + xLabel = headings.x; + yLabel = headings.y; + } + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${dimension} ${dimension}`); + + let margin = { + top: 10, + right: 0, + bottom: 40, + left: 30, + }, + width = dimension - margin.left - margin.right, + height = dimension - margin.top - margin.bottom, + marginedSpace = svg.append("g") + .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); + + let xExtent = d3.extent(values, d => d[0]), + xDelta = xExtent[1] - xExtent[0], + yExtent = d3.extent(values, d => d[1]), + yDelta = yExtent[1] - yExtent[0], + xAxis = d3.scaleLinear() + .domain([xExtent[0] - (0.1 * xDelta), xExtent[1] + (0.1 * xDelta)]) + .range([0, width]), + yAxis = d3.scaleLinear() + .domain([yExtent[0] - (0.1 * yDelta), yExtent[1] + (0.1 * yDelta)]) + .range([height, 0]); + + marginedSpace.append("clipPath") + .attr("id", "clip") + .append("rect") + .attr("width", width) + .attr("height", height); + + marginedSpace.append("g") + .attr("class", "points") + .attr("clip-path", "url(#clip)") + .selectAll("circle") + .data(values) + .enter() + .append("circle") + .attr("cx", (d) => xAxis(d[0])) + .attr("cy", (d) => yAxis(d[1])) + .attr("r", d => radius) + .attr("fill", d => { + return colourInInput ? d[2] : fillColour; + }) + .attr("stroke", "rgba(0, 0, 0, 0.5)") + .attr("stroke-width", "0.5") + .append("title") + .text(d => { + let x = d[0], + y = d[1], + tooltip = `X: ${x}\n + Y: ${y}\n + `.replace(/\s{2,}/g, "\n"); + return tooltip; + }); + + marginedSpace.append("g") + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).tickSizeOuter(-width)); + + svg.append("text") + .attr("transform", "rotate(-90)") + .attr("y", -margin.left) + .attr("x", -(height / 2)) + .attr("dy", "1em") + .style("text-anchor", "middle") + .text(yLabel); + + marginedSpace.append("g") + .attr("class", "axis axis--x") + .attr("transform", "translate(0," + height + ")") + .call(d3.axisBottom(xAxis).tickSizeOuter(-height)); + + svg.append("text") + .attr("x", width / 2) + .attr("y", dimension) + .style("text-anchor", "middle") + .text(xLabel); + + return svg._groups[0][0].outerHTML; + }, + + + /** + * Series chart operation. + * + * @param {string} input + * @param {Object[]} args + * @returns {html} + */ + runSeriesChart(input, args) { + const recordDelimiter = Utils.charRep[args[0]], + fieldDelimiter = Utils.charRep[args[1]], + xLabel = args[2], + pipRadius = args[3], + seriesColours = args[4].split(","), + svgWidth = 500, + interSeriesPadding = 20, + xAxisHeight = 50, + seriesLabelWidth = 50, + seriesHeight = 100, + seriesWidth = svgWidth - seriesLabelWidth - interSeriesPadding; + + let { xValues, series } = Charts._getSeriesValues(input, recordDelimiter, fieldDelimiter), + allSeriesHeight = Object.keys(series).length * (interSeriesPadding + seriesHeight), + svgHeight = allSeriesHeight + xAxisHeight + interSeriesPadding; + + let svg = document.createElement("svg"); + svg = d3.select(svg) + .attr("width", "100%") + .attr("height", "100%") + .attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`); + + let xAxis = d3.scalePoint() + .domain(xValues) + .range([0, seriesWidth]); + + svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`) + .call( + d3.axisTop(xAxis).tickValues(xValues.filter((x, i) => { + return [0, Math.round(xValues.length / 2), xValues.length -1].indexOf(i) >= 0; + })) + ); + + svg.append("text") + .attr("x", svgWidth / 2) + .attr("y", xAxisHeight / 2) + .style("text-anchor", "middle") + .text(xLabel); + + let tooltipText = {}, + tooltipAreaWidth = seriesWidth / xValues.length; + + xValues.forEach(x => { + let tooltip = []; + + series.forEach(serie => { + let y = serie.data[x]; + if (typeof y === "undefined") return; + + tooltip.push(`${serie.name}: ${y}`); + }); + + tooltipText[x] = tooltip.join("\n"); + }); + + let chartArea = svg.append("g") + .attr("transform", `translate(${seriesLabelWidth}, ${xAxisHeight})`); + + chartArea + .append("g") + .selectAll("rect") + .data(xValues) + .enter() + .append("rect") + .attr("x", x => { + return xAxis(x) - (tooltipAreaWidth / 2); + }) + .attr("y", 0) + .attr("width", tooltipAreaWidth) + .attr("height", allSeriesHeight) + .attr("stroke", "none") + .attr("fill", "transparent") + .append("title") + .text(x => { + return `${x}\n + --\n + ${tooltipText[x]}\n + `.replace(/\s{2,}/g, "\n"); + }); + + let yAxesArea = svg.append("g") + .attr("transform", `translate(0, ${xAxisHeight})`); + + series.forEach((serie, seriesIndex) => { + let yExtent = d3.extent(Object.values(serie.data)), + yAxis = d3.scaleLinear() + .domain(yExtent) + .range([seriesHeight, 0]); + + let seriesGroup = chartArea + .append("g") + .attr("transform", `translate(0, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`); + + let path = ""; + xValues.forEach((x, xIndex) => { + let nextX = xValues[xIndex + 1], + y = serie.data[x], + nextY= serie.data[nextX]; + + if (typeof y === "undefined" || typeof nextY === "undefined") return; + + x = xAxis(x); nextX = xAxis(nextX); + y = yAxis(y); nextY = yAxis(nextY); + + path += `M ${x} ${y} L ${nextX} ${nextY} z `; + }); + + seriesGroup + .append("path") + .attr("d", path) + .attr("fill", "none") + .attr("stroke", seriesColours[seriesIndex % seriesColours.length]) + .attr("stroke-width", "1"); + + xValues.forEach(x => { + let y = serie.data[x]; + if (typeof y === "undefined") return; + + seriesGroup + .append("circle") + .attr("cx", xAxis(x)) + .attr("cy", yAxis(y)) + .attr("r", pipRadius) + .attr("fill", seriesColours[seriesIndex % seriesColours.length]) + .append("title") + .text(d => { + return `${x}\n + --\n + ${tooltipText[x]}\n + `.replace(/\s{2,}/g, "\n"); + }); + }); + + yAxesArea + .append("g") + .attr("transform", `translate(${seriesLabelWidth - interSeriesPadding}, ${seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) + .attr("class", "axis axis--y") + .call(d3.axisLeft(yAxis).ticks(5)); + + yAxesArea + .append("g") + .attr("transform", `translate(0, ${seriesHeight / 2 + seriesHeight * seriesIndex + interSeriesPadding * (seriesIndex + 1)})`) + .append("text") + .style("text-anchor", "middle") + .attr("transform", "rotate(-90)") + .text(serie.name); + }); + + return svg._groups[0][0].outerHTML; + }, +}; + +export default Charts; From c005c86c276eb8a9f16ebb82b21ce1cb94779c66 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 28 Feb 2019 15:27:35 +0000 Subject: [PATCH 163/687] Added argSelector ingredient type and reversed rotors in Enigma and Bombe operations. --- src/core/operations/Bombe.mjs | 57 ++++++--- src/core/operations/Enigma.mjs | 135 +++++++++++--------- src/web/App.mjs | 3 + src/web/HTMLIngredient.mjs | 48 ++++++++ src/web/RecipeWaiter.mjs | 28 +++-- tests/operations/tests/Bombe.mjs | 84 +++++++------ tests/operations/tests/Enigma.mjs | 198 +++++++++++++++++------------- 7 files changed, 344 insertions(+), 209 deletions(-) diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs index 6b277a03..ea3210fa 100644 --- a/src/core/operations/Bombe.mjs +++ b/src/core/operations/Bombe.mjs @@ -30,28 +30,42 @@ class Bombe extends Operation { this.presentType = "html"; this.args = [ { - name: "1st (right-hand) rotor", - type: "editableOption", - value: ROTORS, - defaultIndex: 2 + name: "Model", + type: "argSelector", + value: [ + { + name: "3-rotor", + off: [1] + }, + { + name: "4-rotor", + on: [1] + } + ] }, { - name: "2nd (middle) rotor", + name: "Left-most rotor", + type: "editableOption", + value: ROTORS_FOURTH, + defaultIndex: 0 + }, + { + name: "Left-hand rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 0 + }, + { + name: "Middle rotor", type: "editableOption", value: ROTORS, defaultIndex: 1 }, { - name: "3rd (left-hand) rotor", + name: "Right-hand rotor", type: "editableOption", value: ROTORS, - defaultIndex: 0 - }, - { - name: "4th (left-most, only some models) rotor", - type: "editableOption", - value: ROTORS_FOURTH, - defaultIndex: 0 + defaultIndex: 2 }, { name: "Reflector", @@ -93,23 +107,26 @@ class Bombe extends Operation { * @returns {string} */ run(input, args) { - const reflectorstr = args[4]; - let crib = args[5]; - const offset = args[6]; - const check = args[7]; + const model = args[0]; + const reflectorstr = args[5]; + let crib = args[6]; + const offset = args[7]; + const check = args[8]; const rotors = []; for (let i=0; i<4; i++) { - if (i === 3 && args[i] === "") { + if (i === 0 && model === "3-rotor") { // No fourth rotor - break; + continue; } - let rstr = args[i]; + let rstr = args[i + 1]; // The Bombe doesn't take stepping into account so we'll just ignore it here if (rstr.includes("<")) { rstr = rstr.split("<", 2)[0]; } rotors.push(rstr); } + // Rotors are handled in reverse + rotors.reverse(); if (crib.length === 0) { throw new OperationError("Crib cannot be empty"); } diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs index 4af79993..ace50604 100644 --- a/src/core/operations/Enigma.mjs +++ b/src/core/operations/Enigma.mjs @@ -28,67 +28,81 @@ class Enigma extends Operation { this.outputType = "string"; this.args = [ { - name: "1st (right-hand) rotor", + name: "Model", + type: "argSelector", + value: [ + { + name: "3-rotor", + off: [1, 2, 3] + }, + { + name: "4-rotor", + on: [1, 2, 3] + } + ] + }, + { + name: "Left-most rotor", + type: "editableOption", + value: ROTORS_FOURTH, + defaultIndex: 0 + }, + { + name: "Left-most rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Left-most rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Left-hand rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 0 + }, + { + name: "Left-hand rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Left-hand rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Middle rotor", + type: "editableOption", + value: ROTORS, + defaultIndex: 1 + }, + { + name: "Middle rotor ring setting", + type: "option", + value: LETTERS + }, + { + name: "Middle rotor initial value", + type: "option", + value: LETTERS + }, + { + name: "Right-hand rotor", type: "editableOption", value: ROTORS, // Default config is the rotors I-III *left to right* defaultIndex: 2 }, { - name: "1st rotor ring setting", + name: "Right-hand rotor ring setting", type: "option", value: LETTERS }, { - name: "1st rotor initial value", - type: "option", - value: LETTERS - }, - { - name: "2nd (middle) rotor", - type: "editableOption", - value: ROTORS, - defaultIndex: 1 - }, - { - name: "2nd rotor ring setting", - type: "option", - value: LETTERS - }, - { - name: "2nd rotor initial value", - type: "option", - value: LETTERS - }, - { - name: "3rd (left-hand) rotor", - type: "editableOption", - value: ROTORS, - defaultIndex: 0 - }, - { - name: "3rd rotor ring setting", - type: "option", - value: LETTERS - }, - { - name: "3rd rotor initial value", - type: "option", - value: LETTERS - }, - { - name: "4th (left-most, only some models) rotor", - type: "editableOption", - value: ROTORS_FOURTH, - defaultIndex: 0 - }, - { - name: "4th rotor ring setting", - type: "option", - value: LETTERS - }, - { - name: "4th rotor initial value", + name: "Right-hand rotor initial value", type: "option", value: LETTERS }, @@ -135,18 +149,21 @@ class Enigma extends Operation { * @returns {string} */ run(input, args) { - const reflectorstr = args[12]; - const plugboardstr = args[13]; - const removeOther = args[14]; + const model = args[0]; + const reflectorstr = args[13]; + const plugboardstr = args[14]; + const removeOther = args[15]; const rotors = []; for (let i=0; i<4; i++) { - if (i === 3 && args[i*3] === "") { - // No fourth rotor - break; + if (i === 0 && model === "3-rotor") { + // Skip the 4th rotor settings + continue; } - const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*3], 1); - rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*3 + 1], args[i*3 + 2])); + const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*3 + 1], 1); + rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*3 + 2], args[i*3 + 3])); } + // Rotors are handled in reverse + rotors.reverse(); const reflector = new Reflector(reflectorstr); const plugboard = new Plugboard(plugboardstr); if (removeOther) { diff --git a/src/web/App.mjs b/src/web/App.mjs index e203b85c..04846fb6 100755 --- a/src/web/App.mjs +++ b/src/web/App.mjs @@ -472,6 +472,7 @@ class App { const item = this.manager.recipe.addOperation(recipeConfig[i].op); // Populate arguments + log.debug(`Populating arguments for ${recipeConfig[i].op}`); const args = item.querySelectorAll(".arg"); for (let j = 0; j < args.length; j++) { if (recipeConfig[i].args[j] === undefined) continue; @@ -497,6 +498,8 @@ class App { item.querySelector(".breakpoint").click(); } + this.manager.recipe.triggerArgEvents(item); + this.progress = 0; } diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index c7c024fb..19c816ea 100755 --- a/src/web/HTMLIngredient.mjs +++ b/src/web/HTMLIngredient.mjs @@ -240,6 +240,27 @@ class HTMLIngredient { ${this.hint ? "" + this.hint + "" : ""}
`; break; + case "argSelector": + html += `
+ + + ${this.hint ? "" + this.hint + "" : ""} +
`; + + this.manager.addDynamicListener(".arg-selector", "change", this.argSelectorChange, this); + break; default: break; } @@ -321,6 +342,33 @@ class HTMLIngredient { this.manager.recipe.ingChange(); } + + /** + * Handler for argument selector changes. + * Shows or hides the relevant arguments for this operation. + * + * @param {event} e + */ + argSelectorChange(e) { + e.preventDefault(); + e.stopPropagation(); + + const option = e.target.options[e.target.selectedIndex]; + const op = e.target.closest(".operation"); + const args = op.querySelectorAll(".ingredients .form-group"); + const turnon = JSON.parse(option.getAttribute("turnon")); + const turnoff = JSON.parse(option.getAttribute("turnoff")); + + args.forEach((arg, i) => { + if (turnon.includes(i)) { + arg.classList.remove("d-none"); + } + if (turnoff.includes(i)) { + arg.classList.add("d-none"); + } + }); + } + } export default HTMLIngredient; diff --git a/src/web/RecipeWaiter.mjs b/src/web/RecipeWaiter.mjs index 4c568c8b..4eca4af7 100755 --- a/src/web/RecipeWaiter.mjs +++ b/src/web/RecipeWaiter.mjs @@ -393,15 +393,6 @@ class RecipeWaiter { this.buildRecipeOperation(item); document.getElementById("rec-list").appendChild(item); - // Trigger populateOption events - const populateOptions = item.querySelectorAll(".populate-option"); - const evt = new Event("change", {bubbles: true}); - if (populateOptions.length) { - for (const el of populateOptions) { - el.dispatchEvent(evt); - } - } - item.dispatchEvent(this.manager.operationadd); return item; } @@ -439,6 +430,23 @@ class RecipeWaiter { } + /** + * Triggers various change events for operation arguments that have just been initialised. + * + * @param {HTMLElement} op + */ + triggerArgEvents(op) { + // Trigger populateOption and argSelector events + const triggerableOptions = op.querySelectorAll(".populate-option, .arg-selector"); + const evt = new Event("change", {bubbles: true}); + if (triggerableOptions.length) { + for (const el of triggerableOptions) { + el.dispatchEvent(evt); + } + } + } + + /** * Handler for operationadd events. * @@ -448,6 +456,8 @@ class RecipeWaiter { */ opAdd(e) { log.debug(`'${e.target.querySelector(".op-title").textContent}' added to recipe`); + + this.triggerArgEvents(e.target); window.dispatchEvent(this.manager.statechange); } diff --git a/tests/operations/tests/Bombe.mjs b/tests/operations/tests/Bombe.mjs index 0f00f1be..9e5a79c6 100644 --- a/tests/operations/tests/Bombe.mjs +++ b/tests/operations/tests/Bombe.mjs @@ -16,10 +16,11 @@ TestRegister.addTests([ { "op": "Bombe", "args": [ - "BDFHJLCPRTXVZNYEIWGAKMUSQO Date: Thu, 28 Feb 2019 16:56:28 +0000 Subject: [PATCH 164/687] Tweaks for new rotor order --- src/core/lib/Enigma.mjs | 1 - src/core/operations/Bombe.mjs | 2 +- src/core/operations/Enigma.mjs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/lib/Enigma.mjs b/src/core/lib/Enigma.mjs index 1ed0ea2b..39193f69 100644 --- a/src/core/lib/Enigma.mjs +++ b/src/core/lib/Enigma.mjs @@ -25,7 +25,6 @@ export const ROTORS = [ ]; export const ROTORS_FOURTH = [ - {name: "None", value: ""}, {name: "Beta", value: "LEYJVCNIXWPBQMDRTAKZGFUHOS"}, {name: "Gamma", value: "FSOKANUERHMBTIYCWLQPZXVGJD"}, ]; diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs index ea3210fa..5e128498 100644 --- a/src/core/operations/Bombe.mjs +++ b/src/core/operations/Bombe.mjs @@ -44,7 +44,7 @@ class Bombe extends Operation { ] }, { - name: "Left-most rotor", + name: "Left-most (4th) rotor", type: "editableOption", value: ROTORS_FOURTH, defaultIndex: 0 diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs index ace50604..77333b18 100644 --- a/src/core/operations/Enigma.mjs +++ b/src/core/operations/Enigma.mjs @@ -42,7 +42,7 @@ class Enigma extends Operation { ] }, { - name: "Left-most rotor", + name: "Left-most (4th) rotor", type: "editableOption", value: ROTORS_FOURTH, defaultIndex: 0 From 1f9fd92b01db91518855039a41aee470daf3608f Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 28 Feb 2019 17:21:47 +0000 Subject: [PATCH 165/687] Typex: rotors in same order as Enigma --- src/core/operations/Typex.mjs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/core/operations/Typex.mjs b/src/core/operations/Typex.mjs index 504cb891..9c963357 100644 --- a/src/core/operations/Typex.mjs +++ b/src/core/operations/Typex.mjs @@ -29,10 +29,10 @@ class Typex extends Operation { this.outputType = "string"; this.args = [ { - name: "1st (right-hand, static) rotor", + name: "1st (left-hand) rotor", type: "editableOption", value: ROTORS, - defaultIndex: 4 + defaultIndex: 0 }, { name: "1st rotor reversed", @@ -50,10 +50,10 @@ class Typex extends Operation { value: LETTERS }, { - name: "2nd (static) rotor", + name: "2nd rotor", type: "editableOption", value: ROTORS, - defaultIndex: 3 + defaultIndex: 1 }, { name: "2nd rotor reversed", @@ -71,7 +71,7 @@ class Typex extends Operation { value: LETTERS }, { - name: "3rd rotor", + name: "3rd (middle) rotor", type: "editableOption", value: ROTORS, defaultIndex: 2 @@ -92,10 +92,10 @@ class Typex extends Operation { value: LETTERS }, { - name: "4th rotor", + name: "4th (static) rotor", type: "editableOption", value: ROTORS, - defaultIndex: 1 + defaultIndex: 3 }, { name: "4th rotor reversed", @@ -113,10 +113,10 @@ class Typex extends Operation { value: LETTERS }, { - name: "5th rotor", + name: "5th (right-hand, static) rotor", type: "editableOption", value: ROTORS, - defaultIndex: 0 + defaultIndex: 4 }, { name: "5th rotor reversed", @@ -190,6 +190,8 @@ class Typex extends Operation { const [rotorwiring, rotorsteps] = this.parseRotorStr(args[i*4]); rotors.push(new Rotor(rotorwiring, rotorsteps, args[i*4 + 1], args[i*4+2], args[i*4+3])); } + // Rotors are handled in reverse + rotors.reverse(); const reflector = new Reflector(reflectorstr); let plugboardstrMod = plugboardstr; if (plugboardstrMod === "") { From 765aded208b7f87ffef92d30294ac3edca763a2a Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 28 Feb 2019 17:22:09 +0000 Subject: [PATCH 166/687] Typex: add simple tests --- tests/operations/index.mjs | 1 + tests/operations/tests/Typex.mjs | 105 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/operations/tests/Typex.mjs diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index ff967163..cff77217 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -86,6 +86,7 @@ import "./tests/ConvertCoordinateFormat"; import "./tests/Enigma"; import "./tests/Bombe"; import "./tests/MultipleBombe"; +import "./tests/Typex"; // Cannot test operations that use the File type yet //import "./tests/SplitColourChannels"; diff --git a/tests/operations/tests/Typex.mjs b/tests/operations/tests/Typex.mjs new file mode 100644 index 00000000..e3751e8a --- /dev/null +++ b/tests/operations/tests/Typex.mjs @@ -0,0 +1,105 @@ +/** + * Typex machine tests. + * @author s2224834 + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ +import TestRegister from "../TestRegister"; + +TestRegister.addTests([ + { + // Unlike Enigma we're not verifying against a real machine here, so this is just a test + // to catch inadvertent breakage. + name: "Typex: basic", + input: "hello world, this is a test message.", + expectedOutput: "VIXQQ VHLPN UCVLA QDZNZ EAYAT HWC", + recipeConfig: [ + { + "op": "Typex", + "args": [ + "MCYLPQUVRXGSAOWNBJEZDTFKHI Date: Thu, 28 Feb 2019 17:50:10 +0000 Subject: [PATCH 167/687] Add some files that escaped commit before --- package.json | 1 + webpack.config.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cb59db38..64ef09cc 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "sass-loader": "^7.1.0", "sitemap": "^2.1.0", "style-loader": "^0.23.1", + "svg-url-loader": "^2.3.2", "url-loader": "^1.1.2", "web-resource-inliner": "^4.2.1", "webpack": "^4.28.3", diff --git a/webpack.config.js b/webpack.config.js index 054152b2..e2a7c728 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,8 +100,15 @@ module.exports = { limit: 10000 } }, + { + test: /\.svg$/, + loader: "svg-url-loader", + options: { + encoding: "base64" + } + }, { // First party images are saved as files to be cached - test: /\.(png|jpg|gif|svg)$/, + test: /\.(png|jpg|gif)$/, exclude: /node_modules/, loader: "file-loader", options: { @@ -109,7 +116,7 @@ module.exports = { } }, { // Third party images are inlined - test: /\.(png|jpg|gif|svg)$/, + test: /\.(png|jpg|gif)$/, exclude: /web\/static/, loader: "url-loader", options: { From 9323737d1da3dc7b9a2d4f485e456829e7aa0e98 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 28 Feb 2019 18:37:48 +0000 Subject: [PATCH 168/687] Bombe: fix rotor listing order for multibombe --- src/core/operations/MultipleBombe.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs index 7a0ae2fd..6887bc46 100644 --- a/src/core/operations/MultipleBombe.mjs +++ b/src/core/operations/MultipleBombe.mjs @@ -291,7 +291,7 @@ class MultipleBombe extends Operation { let html = `Bombe run on menu with ${output.nLoops} loop${output.nLoops === 1 ? "" : "s"} (2+ desirable). Note: Rotors and rotor positions are listed left to right, ignore stepping and the ring setting, and positions start at the beginning of the crib. Some plugboard settings are determined. A decryption preview starting at the beginning of the crib and ignoring stepping is also provided.\n`; for (const run of output.bombeRuns) { - html += `\nRotors: ${run.rotors.join(", ")}\nReflector: ${run.reflector}\n`; + html += `\nRotors: ${run.rotors.slice().reverse().join(", ")}\nReflector: ${run.reflector}\n`; html += ""; for (const [setting, stecker, decrypt] of run.result) { html += `\n`; From a446ec31c712d4a820e2cd484bed97b2a71c9e83 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 28 Feb 2019 18:48:36 +0000 Subject: [PATCH 169/687] Improve Enigma/Bombe descriptions a little. --- src/core/operations/Bombe.mjs | 2 +- src/core/operations/Enigma.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs index 5e128498..f0d7048c 100644 --- a/src/core/operations/Bombe.mjs +++ b/src/core/operations/Bombe.mjs @@ -23,7 +23,7 @@ class Bombe extends Operation { this.name = "Bombe"; this.module = "Default"; - this.description = "Emulation of the Bombe machine used to attack Enigma.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops, a large number of incorrect outputs will be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine."; + this.description = "Emulation of the Bombe machine used at Bletchley Park to attack Enigma, based on work by Polish and British cryptanalysts.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops or is too short, a large number of incorrect outputs will usually be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine."; this.infoURL = "https://wikipedia.org/wiki/Bombe"; this.inputType = "string"; this.outputType = "JSON"; diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs index 77333b18..71593070 100644 --- a/src/core/operations/Enigma.mjs +++ b/src/core/operations/Enigma.mjs @@ -22,7 +22,7 @@ class Enigma extends Operation { this.name = "Enigma"; this.module = "Default"; - this.description = "Encipher/decipher with the WW2 Enigma machine.

The standard set of German military rotors and reflectors are provided. To configure the plugboard, enter a string of connected pairs of letters, e.g. AB CD EF connects A to B, C to D, and E to F. This is also used to create your own reflectors. To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points.
This is deliberately fairly permissive with rotor placements etc compared to a real Enigma (on which, for example, a four-rotor Enigma uses the thin reflectors and the beta or gamma rotor in the 4th slot)."; + this.description = "Encipher/decipher with the WW2 Enigma machine.

Enigma was used by the German military, among others, around the WW2 era as a portable cipher machine to protect sensitive military, diplomatic and commercial communications.

The standard set of German military rotors and reflectors are provided. To configure the plugboard, enter a string of connected pairs of letters, e.g. AB CD EF connects A to B, C to D, and E to F. This is also used to create your own reflectors. To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points.
This is deliberately fairly permissive with rotor placements etc compared to a real Enigma (on which, for example, a four-rotor Enigma uses only the thin reflectors and the beta or gamma rotor in the 4th slot)."; this.infoURL = "https://wikipedia.org/wiki/Enigma_machine"; this.inputType = "string"; this.outputType = "string"; From 9a0b78415360c5d7c6ccf9ea025bedbea74f0d41 Mon Sep 17 00:00:00 2001 From: s2224834 <46319860+s2224834@users.noreply.github.com> Date: Thu, 28 Feb 2019 18:56:59 +0000 Subject: [PATCH 170/687] Typex: improve operation description --- src/core/operations/Typex.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/Typex.mjs b/src/core/operations/Typex.mjs index 9c963357..760914f5 100644 --- a/src/core/operations/Typex.mjs +++ b/src/core/operations/Typex.mjs @@ -23,7 +23,7 @@ class Typex extends Operation { this.name = "Typex"; this.module = "Default"; - this.description = "Encipher/decipher with the WW2 Typex machine.

Typex rotors were changed regularly and none are public: a random example set are provided. Later Typexes had a reflector which could be configured with a plugboard: to configure this, enter a string of connected pairs of letters in the reflector box, e.g. AB CD EF connects A to B, C to D, and E to F (you'll need to connect every letter). These Typexes also have an input plugboard: unlike Enigma's plugboard, it's not restricted to pairs, so it's entered like a rotor (without stepping). To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points."; + this.description = "Encipher/decipher with the WW2 Typex machine.

Typex was originally built by the British Royal Air Force prior to WW2, and is based on the Enigma machine with some improvements made, including using five rotors with more stepping points and interchangeable wiring cores. It was used across the British and Commonewealth militaries. A number of later variants were produced; here we simulate a WW2 era Mark 22 Typex with plugboards for the reflector and input. Typex rotors were changed regularly and none are public: a random example set are provided.

To configure the reflector plugboard, enter a string of connected pairs of letters in the reflector box, e.g. AB CD EF connects A to B, C to D, and E to F (you'll need to connect every letter). There is also an input plugboard: unlike Enigma's plugboard, it's not restricted to pairs, so it's entered like a rotor (without stepping). To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points."; this.infoURL = "https://wikipedia.org/wiki/Typex"; this.inputType = "string"; this.outputType = "string"; From 0a1ca18de53a0527175ee57e6e7f76f96e92dc3d Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 1 Mar 2019 08:59:18 +0000 Subject: [PATCH 171/687] refactor Dish get to handle sync and async --- src/core/Dish.mjs | 455 +++++++++++++++++++++++----------------------- 1 file changed, 229 insertions(+), 226 deletions(-) diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 17a6d2a0..64602181 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -11,228 +11,6 @@ import BigNumber from "bignumber.js"; import log from "loglevel"; -/** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ -async function _asyncTranslate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = await Utils.readFile(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = await Promise.all(this.value.map(async f => Utils.readFile(f))); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } -} - - -/** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ -function _translate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = Utils.readFileSync(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = this.value.map(f => Utils.readFileSync(f)); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } -} - -/** - * Returns the value of the data in the type format specified. - * - * @param {number} type - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - * @returns {*} - The value of the output data. - */ -async function asyncGet(type, notUTF8=false) { - if (typeof type === "string") { - type = Dish.typeEnum(type); - } - if (this.type !== type) { - await this._translate(type, notUTF8); - } - return this.value; -} - -/** - * Returns the value of the data in the type format specified. - * - * @param {number} type - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - * @returns {*} - The value of the output data. - */ -function get(type, notUTF8=false) { - if (typeof type === "string") { - type = Dish.typeEnum(type); - } - if (this.type !== type) { - this._translate(type, notUTF8); - } - return this.value; -} - - /** * The data being operated on by each operation. */ @@ -335,6 +113,41 @@ class Dish { } + /** + * Returns the value of the data in the type format specified. + * + * @param {number} type - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + * @returns {* | Promise} - (Broswer) A promise | (Node) value of dish in given type + */ + get(type, notUTF8=false) { + if (typeof type === "string") { + type = Dish.typeEnum(type); + } + + if (this.type !== type) { + + // Browser environment => _translate is async + if (Utils.isBrowser()) { + return new Promise((resolve, reject) => { + this._translate(type, notUTF8) + .then(() => { + resolve(this.value); + }) + .catch(reject); + }); + + // Node environment => _translate is sync + } else { + this._translate(type, notUTF8); + return this.value; + } + } + + return this.value; + } + + /** * Sets the data value and type and then validates them. * @@ -553,12 +366,202 @@ Dish.FILE = 7; */ Dish.LIST_FILE = 8; + + + +/** + * Translates the data to the given type format. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ +async function _asyncTranslate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Convert data to intermediate byteArray type + try { + switch (this.type) { + case Dish.STRING: + this.value = this.value ? Utils.strToByteArray(this.value) : []; + break; + case Dish.NUMBER: + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + break; + case Dish.HTML: + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + break; + case Dish.ARRAY_BUFFER: + // Array.from() would be nicer here, but it's slightly slower + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + break; + case Dish.BIG_NUMBER: + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + break; + case Dish.JSON: + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + break; + case Dish.FILE: + this.value = await Utils.readFile(this.value); + this.value = Array.prototype.slice.call(this.value); + break; + case Dish.LIST_FILE: + this.value = await Promise.all(this.value.map(async f => Utils.readFile(f))); + this.value = this.value.map(b => Array.prototype.slice.call(b)); + this.value = [].concat.apply([], this.value); + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + + this.type = Dish.BYTE_ARRAY; + + // Convert from byteArray to toType + try { + switch (toType) { + case Dish.STRING: + case Dish.HTML: + this.value = this.value ? byteArrayToStr(this.value) : ""; + this.type = Dish.STRING; + break; + case Dish.NUMBER: + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + this.type = Dish.NUMBER; + break; + case Dish.ARRAY_BUFFER: + this.value = new Uint8Array(this.value).buffer; + this.type = Dish.ARRAY_BUFFER; + break; + case Dish.BIG_NUMBER: + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + this.type = Dish.BIG_NUMBER; + break; + case Dish.JSON: + this.value = JSON.parse(byteArrayToStr(this.value)); + this.type = Dish.JSON; + break; + case Dish.FILE: + this.value = new File(this.value, "unknown"); + break; + case Dish.LIST_FILE: + this.value = [new File(this.value, "unknown")]; + this.type = Dish.LIST_FILE; + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } +} + + +/** + * Translates the data to the given type format. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ +function _translate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Convert data to intermediate byteArray type + try { + switch (this.type) { + case Dish.STRING: + this.value = this.value ? Utils.strToByteArray(this.value) : []; + break; + case Dish.NUMBER: + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + break; + case Dish.HTML: + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + break; + case Dish.ARRAY_BUFFER: + // Array.from() would be nicer here, but it's slightly slower + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + break; + case Dish.BIG_NUMBER: + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + break; + case Dish.JSON: + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + break; + case Dish.FILE: + this.value = Utils.readFileSync(this.value); + this.value = Array.prototype.slice.call(this.value); + break; + case Dish.LIST_FILE: + this.value = this.value.map(f => Utils.readFileSync(f)); + this.value = this.value.map(b => Array.prototype.slice.call(b)); + this.value = [].concat.apply([], this.value); + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + + this.type = Dish.BYTE_ARRAY; + + // Convert from byteArray to toType + try { + switch (toType) { + case Dish.STRING: + case Dish.HTML: + this.value = this.value ? byteArrayToStr(this.value) : ""; + this.type = Dish.STRING; + break; + case Dish.NUMBER: + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + this.type = Dish.NUMBER; + break; + case Dish.ARRAY_BUFFER: + this.value = new Uint8Array(this.value).buffer; + this.type = Dish.ARRAY_BUFFER; + break; + case Dish.BIG_NUMBER: + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + this.type = Dish.BIG_NUMBER; + break; + case Dish.JSON: + this.value = JSON.parse(byteArrayToStr(this.value)); + this.type = Dish.JSON; + break; + case Dish.FILE: + this.value = new File(this.value, "unknown"); + break; + case Dish.LIST_FILE: + this.value = [new File(this.value, "unknown")]; + this.type = Dish.LIST_FILE; + break; + default: + break; + } + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } +} + + if (Utils.isBrowser()) { - Dish.prototype._translate = _asyncTranslate - Dish.prototype.get = asyncGet + Dish.prototype._translate = _asyncTranslate; + } else { - Dish.prototype._translate = _translate - Dish.prototype.get = get + Dish.prototype._translate = _translate; } export default Dish; From b48c16b4db221c3a4ea03c740b35a40b552424ee Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 1 Mar 2019 16:02:21 +0000 Subject: [PATCH 172/687] Refactor Dish _translate to handle sync and async depending on environment. --- Gruntfile.js | 3 - src/core/Dish.mjs | 326 +++++++----------- src/core/Utils.mjs | 20 +- .../dishTranslationTypes/DishArrayBuffer.mjs | 32 ++ .../dishTranslationTypes/DishBigNumber.mjs | 40 +++ src/core/dishTranslationTypes/DishFile.mjs | 44 +++ src/core/dishTranslationTypes/DishHTML.mjs | 35 ++ src/core/dishTranslationTypes/DishJSON.mjs | 34 ++ .../dishTranslationTypes/DishListFile.mjs | 42 +++ src/core/dishTranslationTypes/DishNumber.mjs | 34 ++ src/core/dishTranslationTypes/DishString.mjs | 34 ++ .../DishTranslationType.mjs | 39 +++ src/core/dishTranslationTypes/index.mjs | 26 ++ .../tests/ConvertCoordinateFormat.mjs | 2 +- .../tests/ToFromInsensitiveRegex.mjs | 2 +- tests/operations/tests/YARA.mjs | 2 +- 16 files changed, 492 insertions(+), 223 deletions(-) create mode 100644 src/core/dishTranslationTypes/DishArrayBuffer.mjs create mode 100644 src/core/dishTranslationTypes/DishBigNumber.mjs create mode 100644 src/core/dishTranslationTypes/DishFile.mjs create mode 100644 src/core/dishTranslationTypes/DishHTML.mjs create mode 100644 src/core/dishTranslationTypes/DishJSON.mjs create mode 100644 src/core/dishTranslationTypes/DishListFile.mjs create mode 100644 src/core/dishTranslationTypes/DishNumber.mjs create mode 100644 src/core/dishTranslationTypes/DishString.mjs create mode 100644 src/core/dishTranslationTypes/DishTranslationType.mjs create mode 100644 src/core/dishTranslationTypes/index.mjs diff --git a/Gruntfile.js b/Gruntfile.js index a14c3001..30b9fa8c 100755 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -365,9 +365,6 @@ module.exports = function (grunt) { "./config/modules/OpModules": "./config/modules/Default" } }, - output: { - // globalObject: "this", - }, plugins: [ new webpack.DefinePlugin(BUILD_CONSTANTS), new HtmlWebpackPlugin({ diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 64602181..452be80d 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -10,6 +10,17 @@ import DishError from "./errors/DishError"; import BigNumber from "bignumber.js"; import log from "loglevel"; +import { + DishArrayBuffer, + DishBigNumber, + DishFile, + DishHTML, + DishJSON, + DishListFile, + DishNumber, + DishString, +} from "./dishTranslationTypes"; + /** * The data being operated on by each operation. @@ -116,6 +127,8 @@ class Dish { /** * Returns the value of the data in the type format specified. * + * If running in a browser, get is asynchronous. + * * @param {number} type - The data type of value, see Dish enums. * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. * @returns {* | Promise} - (Broswer) A promise | (Node) value of dish in given type @@ -127,8 +140,13 @@ class Dish { if (this.type !== type) { + // Node environment => _translate is sync + if (Utils.isNode()) { + this._translate(type, notUTF8); + return this.value; + // Browser environment => _translate is async - if (Utils.isBrowser()) { + } else { return new Promise((resolve, reject) => { this._translate(type, notUTF8) .then(() => { @@ -136,11 +154,6 @@ class Dish { }) .catch(reject); }); - - // Node environment => _translate is sync - } else { - this._translate(type, notUTF8); - return this.value; } } @@ -308,6 +321,110 @@ class Dish { return newDish; } + + /** + * Translates the data to the given type format. + * + * If running in the browser, _translate is asynchronous. + * + * @param {number} toType - The data type of value, see Dish enums. + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + * @returns {Promise || undefined} + */ + _translate(toType, notUTF8=false) { + log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); + + // Node environment => translate is sync + if (Utils.isNode()) { + this._toByteArray(); + this._fromByteArray(toType, notUTF8); + + // Browser environment => translate is async + } else { + return new Promise((resolve, reject) => { + this._toByteArray() + .then(() => this.type = Dish.BYTE_ARRAY) + .then(() => { + this._fromByteArray(toType); + resolve(); + }) + .catch(reject); + }); + } + + } + + /** + * Convert this.value to a ByteArray + * + * If running in a browser, _toByteArray is asynchronous. + * + * @returns {Promise || undefined} + */ + _toByteArray() { + // Using 'bind' here to allow this.value to be mutated within translation functions + const toByteArrayFuncs = { + browser: { + [Dish.STRING]: () => Promise.resolve(DishString.toByteArray.bind(this)()), + [Dish.NUMBER]: () => Promise.resolve(DishNumber.toByteArray.bind(this)()), + [Dish.HTML]: () => Promise.resolve(DishHTML.toByteArray.bind(this)()), + [Dish.ARRAY_BUFFER]: () => Promise.resolve(DishArrayBuffer.toByteArray.bind(this)()), + [Dish.BIG_NUMBER]: () => Promise.resolve(DishBigNumber.toByteArray.bind(this)()), + [Dish.JSON]: () => Promise.resolve(DishJSON.toByteArray.bind(this)()), + [Dish.FILE]: () => DishFile.toByteArray.bind(this)(), + [Dish.LIST_FILE]: () => DishListFile.toByteArray.bind(this)(), + [Dish.BYTE_ARRAY]: () => Promise.resolve(), + }, + node: { + [Dish.STRING]: () => DishString.toByteArray.bind(this)(), + [Dish.NUMBER]: () => DishNumber.toByteArray.bind(this)(), + [Dish.HTML]: () => DishHTML.toByteArray.bind(this)(), + [Dish.ARRAY_BUFFER]: () => DishArrayBuffer.toByteArray.bind(this)(), + [Dish.BIG_NUMBER]: () => DishBigNumber.toByteArray.bind(this)(), + [Dish.JSON]: () => DishJSON.toByteArray.bind(this)(), + [Dish.FILE]: () => DishFile.toByteArray.bind(this)(), + [Dish.LIST_FILE]: () => DishListFile.toByteArray.bind(this)(), + [Dish.BYTE_ARRAY]: () => {}, + } + }; + + try { + return toByteArrayFuncs[Utils.isNode() && "node" || "browser"][this.type](); + } catch (err) { + throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); + } + } + + /** + * Convert this.value to the given type. + * + * @param {number} toType - the Dish enum to convert to + * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. + */ + _fromByteArray(toType, notUTF8) { + const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; + + // Using 'bind' here to allow this.value to be mutated within translation functions + const toTypeFunctions = { + [Dish.STRING]: () => DishString.fromByteArray.bind(this)(byteArrayToStr), + [Dish.NUMBER]: () => DishNumber.fromByteArray.bind(this)(byteArrayToStr), + [Dish.HTML]: () => DishHTML.fromByteArray.bind(this)(byteArrayToStr), + [Dish.ARRAY_BUFFER]: () => DishArrayBuffer.fromByteArray.bind(this)(), + [Dish.BIG_NUMBER]: () => DishBigNumber.fromByteArray.bind(this)(byteArrayToStr), + [Dish.JSON]: () => DishJSON.fromByteArray.bind(this)(byteArrayToStr), + [Dish.FILE]: () => DishFile.fromByteArray.bind(this)(), + [Dish.LIST_FILE]: () => DishListFile.fromByteArray.bind(this)(), + [Dish.BYTE_ARRAY]: () => {}, + }; + + try { + toTypeFunctions[toType](); + this.type = toType; + } catch (err) { + throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); + } + } + } @@ -367,201 +484,4 @@ Dish.FILE = 7; Dish.LIST_FILE = 8; - - -/** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ -async function _asyncTranslate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = await Utils.readFile(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = await Promise.all(this.value.map(async f => Utils.readFile(f))); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } -} - - -/** - * Translates the data to the given type format. - * - * @param {number} toType - The data type of value, see Dish enums. - * @param {boolean} [notUTF8=false] - Do not treat strings as UTF8. - */ -function _translate(toType, notUTF8=false) { - log.debug(`Translating Dish from ${Dish.enumLookup(this.type)} to ${Dish.enumLookup(toType)}`); - const byteArrayToStr = notUTF8 ? Utils.byteArrayToChars : Utils.byteArrayToUtf8; - - // Convert data to intermediate byteArray type - try { - switch (this.type) { - case Dish.STRING: - this.value = this.value ? Utils.strToByteArray(this.value) : []; - break; - case Dish.NUMBER: - this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; - break; - case Dish.HTML: - this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; - break; - case Dish.ARRAY_BUFFER: - // Array.from() would be nicer here, but it's slightly slower - this.value = Array.prototype.slice.call(new Uint8Array(this.value)); - break; - case Dish.BIG_NUMBER: - this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; - break; - case Dish.JSON: - this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; - break; - case Dish.FILE: - this.value = Utils.readFileSync(this.value); - this.value = Array.prototype.slice.call(this.value); - break; - case Dish.LIST_FILE: - this.value = this.value.map(f => Utils.readFileSync(f)); - this.value = this.value.map(b => Array.prototype.slice.call(b)); - this.value = [].concat.apply([], this.value); - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from ${Dish.enumLookup(this.type)} to byteArray: ${err}`); - } - - this.type = Dish.BYTE_ARRAY; - - // Convert from byteArray to toType - try { - switch (toType) { - case Dish.STRING: - case Dish.HTML: - this.value = this.value ? byteArrayToStr(this.value) : ""; - this.type = Dish.STRING; - break; - case Dish.NUMBER: - this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; - this.type = Dish.NUMBER; - break; - case Dish.ARRAY_BUFFER: - this.value = new Uint8Array(this.value).buffer; - this.type = Dish.ARRAY_BUFFER; - break; - case Dish.BIG_NUMBER: - try { - this.value = new BigNumber(byteArrayToStr(this.value)); - } catch (err) { - this.value = new BigNumber(NaN); - } - this.type = Dish.BIG_NUMBER; - break; - case Dish.JSON: - this.value = JSON.parse(byteArrayToStr(this.value)); - this.type = Dish.JSON; - break; - case Dish.FILE: - this.value = new File(this.value, "unknown"); - break; - case Dish.LIST_FILE: - this.value = [new File(this.value, "unknown")]; - this.type = Dish.LIST_FILE; - break; - default: - break; - } - } catch (err) { - throw new DishError(`Error translating from byteArray to ${Dish.enumLookup(toType)}: ${err}`); - } -} - - -if (Utils.isBrowser()) { - Dish.prototype._translate = _asyncTranslate; - -} else { - Dish.prototype._translate = _translate; -} - export default Dish; diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index c4cd14ab..934186ab 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -926,7 +926,11 @@ class Utils { * await Utils.readFile(new File(["hello"], "test")) */ static readFile(file) { - if (Utils.isBrowser()) { + + if (Utils.isNode()) { + return Buffer.from(file).buffer; + + } else { return new Promise((resolve, reject) => { const reader = new FileReader(); const data = new Uint8Array(file.size); @@ -954,17 +958,12 @@ class Utils { seek(); }); - - } else if (Utils.isNode()) { - return Buffer.from(file).buffer; } - - throw new Error("Unkown environment!"); } /** */ static readFileSync(file) { - if (Utils.isBrowser()) { + if (!Utils.isNode()) { throw new TypeError("Browser environment cannot support readFileSync"); } @@ -1065,13 +1064,6 @@ class Utils { }[token]; } - /** - * Check if code is running in a browser environment - */ - static isBrowser() { - return typeof window !== "undefined" && typeof window.document !== "undefined"; - } - /** * Check if code is running in a Node environment */ diff --git a/src/core/dishTranslationTypes/DishArrayBuffer.mjs b/src/core/dishTranslationTypes/DishArrayBuffer.mjs new file mode 100644 index 00000000..96a8b8e3 --- /dev/null +++ b/src/core/dishTranslationTypes/DishArrayBuffer.mjs @@ -0,0 +1,32 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; + +/** + * Translation methods for ArrayBuffer Dishes + */ +class DishArrayBuffer extends DishTranslationType { + + /** + * convert the given value to a ByteArray + */ + static toByteArray() { + DishArrayBuffer.checkForValue(this.value); + this.value = Array.prototype.slice.call(new Uint8Array(this.value)); + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray() { + DishArrayBuffer.checkForValue(this.value); + this.value = new Uint8Array(this.value).buffer; + } +} + +export default DishArrayBuffer; diff --git a/src/core/dishTranslationTypes/DishBigNumber.mjs b/src/core/dishTranslationTypes/DishBigNumber.mjs new file mode 100644 index 00000000..e2aae7b9 --- /dev/null +++ b/src/core/dishTranslationTypes/DishBigNumber.mjs @@ -0,0 +1,40 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; +import BigNumber from "bignumber.js"; + +/** + * translation methods for BigNumber Dishes + */ +class DishBigNumber extends DishTranslationType { + + /** + * convert the given value to a ByteArray + * @param {BigNumber} value + */ + static toByteArray() { + DishBigNumber.checkForValue(this.value); + this.value = BigNumber.isBigNumber(this.value) ? Utils.strToByteArray(this.value.toFixed()) : []; + } + + /** + * convert the given value from a ByteArray + * @param {ByteArray} value + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr) { + DishBigNumber.checkForValue(this.value); + try { + this.value = new BigNumber(byteArrayToStr(this.value)); + } catch (err) { + this.value = new BigNumber(NaN); + } + } +} + +export default DishBigNumber; diff --git a/src/core/dishTranslationTypes/DishFile.mjs b/src/core/dishTranslationTypes/DishFile.mjs new file mode 100644 index 00000000..11a9c871 --- /dev/null +++ b/src/core/dishTranslationTypes/DishFile.mjs @@ -0,0 +1,44 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; + +/** + * Translation methods for file Dishes + */ +class DishFile extends DishTranslationType { + + /** + * convert the given value to a ByteArray + * @param {File} value + */ + static toByteArray() { + DishFile.checkForValue(this.value); + if (Utils.isNode()) { + this.value = Array.prototype.slice.call(Utils.readFileSync(this.value)); + } else { + return new Promise((resolve, reject) => { + Utils.readFile(this.value) + .then(v => this.value = Array.prototype.slice.call(v)) + .then(resolve) + .catch(reject); + }); + } + } + + /** + * convert the given value from a ByteArray + * @param {ByteArray} value + * @param {function} byteArrayToStr + */ + static fromByteArray() { + DishFile.checkForValue(this.value); + this.value = new File(this.value, "unknown"); + } +} + +export default DishFile; diff --git a/src/core/dishTranslationTypes/DishHTML.mjs b/src/core/dishTranslationTypes/DishHTML.mjs new file mode 100644 index 00000000..f7a74d9e --- /dev/null +++ b/src/core/dishTranslationTypes/DishHTML.mjs @@ -0,0 +1,35 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; +import DishString from "./DishString"; + +/** + * Translation methods for HTML Dishes + */ +class DishHTML extends DishTranslationType { + + /** + * convert the given value to a ByteArray + * @param {String} value + */ + static toByteArray() { + DishHTML.checkForValue(this.value); + this.value = this.value ? Utils.strToByteArray(Utils.unescapeHtml(Utils.stripHtmlTags(this.value, true))) : []; + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr) { + DishHTML.checkForValue(this.value); + DishString.fromByteArray(this.value, byteArrayToStr); + } +} + +export default DishHTML; diff --git a/src/core/dishTranslationTypes/DishJSON.mjs b/src/core/dishTranslationTypes/DishJSON.mjs new file mode 100644 index 00000000..a2c20390 --- /dev/null +++ b/src/core/dishTranslationTypes/DishJSON.mjs @@ -0,0 +1,34 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; + +/** + * Translation methods for JSON dishes + */ +class DishJSON extends DishTranslationType { + + /** + * convert the given value to a ByteArray + */ + static toByteArray() { + DishJSON.checkForValue(this.value); + this.value = this.value ? Utils.strToByteArray(JSON.stringify(this.value, null, 4)) : []; + } + + /** + * convert the given value from a ByteArray + * @param {ByteArray} value + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr) { + DishJSON.checkForValue(this.value); + this.value = JSON.parse(byteArrayToStr(this.value)); + } +} + +export default DishJSON; diff --git a/src/core/dishTranslationTypes/DishListFile.mjs b/src/core/dishTranslationTypes/DishListFile.mjs new file mode 100644 index 00000000..678e2d59 --- /dev/null +++ b/src/core/dishTranslationTypes/DishListFile.mjs @@ -0,0 +1,42 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; + +/** + * Translation methods for ListFile Dishes + */ +class DishListFile extends DishTranslationType { + + /** + * convert the given value to a ByteArray + */ + static toByteArray() { + DishListFile.checkForValue(this.value); + if (Utils.isNode()) { + this.value = [].concat.apply([], this.value.map(f => Utils.readFileSync(f)).map(b => Array.prototype.slice.call(b))); + } else { + return new Promise((resolve, reject) => { + Promise.all(this.value.map(async f => Utils.readFile(f))) + .then(values => this.value = values.map(b => [].concat.apply([], Array.prototype.slice.call(b)))) + .then(resolve) + .catch(reject); + }); + } + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray() { + DishListFile.checkForValue(this.value); + this.value = [new File(this.value, "unknown")]; + } +} + +export default DishListFile; diff --git a/src/core/dishTranslationTypes/DishNumber.mjs b/src/core/dishTranslationTypes/DishNumber.mjs new file mode 100644 index 00000000..0cc97af0 --- /dev/null +++ b/src/core/dishTranslationTypes/DishNumber.mjs @@ -0,0 +1,34 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; + +/** + * Translation methods for number dishes + */ +class DishNumber extends DishTranslationType { + + /** + * convert the given value to a ByteArray + */ + static toByteArray() { + DishNumber.checkForValue(this.value); + this.value = typeof this.value === "number" ? Utils.strToByteArray(this.value.toString()) : []; + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr) { + DishNumber.checkForValue(this.value); + this.value = this.value ? parseFloat(byteArrayToStr(this.value)) : 0; + } +} + +export default DishNumber; diff --git a/src/core/dishTranslationTypes/DishString.mjs b/src/core/dishTranslationTypes/DishString.mjs new file mode 100644 index 00000000..78c273c6 --- /dev/null +++ b/src/core/dishTranslationTypes/DishString.mjs @@ -0,0 +1,34 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + + +import DishTranslationType from "./DishTranslationType"; +import Utils from "../Utils"; + +/** + * Translation methods for string dishes + */ +class DishString extends DishTranslationType { + + /** + * convert the given value to a ByteArray + */ + static toByteArray() { + DishString.checkForValue(this.value); + this.value = this.value ? Utils.strToByteArray(this.value) : []; + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr) { + DishString.checkForValue(this.value); + this.value = this.value ? byteArrayToStr(this.value) : ""; + } +} + +export default DishString; diff --git a/src/core/dishTranslationTypes/DishTranslationType.mjs b/src/core/dishTranslationTypes/DishTranslationType.mjs new file mode 100644 index 00000000..261f9bcd --- /dev/null +++ b/src/core/dishTranslationTypes/DishTranslationType.mjs @@ -0,0 +1,39 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + + +/** + * Abstract class for dish translation methods + */ +class DishTranslationType { + + /** + * Warn translations dont work without value from bind + */ + static checkForValue(value) { + if (value === undefined) { + throw new Error("only use translation methods with .bind"); + } + } + + /** + * convert the given value to a ByteArray + * @param {*} value + */ + static toByteArray() { + throw new Error("toByteArray has not been implemented"); + } + + /** + * convert the given value from a ByteArray + * @param {function} byteArrayToStr + */ + static fromByteArray(byteArrayToStr=undefined) { + throw new Error("toType has not been implemented"); + } +} + +export default DishTranslationType; diff --git a/src/core/dishTranslationTypes/index.mjs b/src/core/dishTranslationTypes/index.mjs new file mode 100644 index 00000000..8f6f920c --- /dev/null +++ b/src/core/dishTranslationTypes/index.mjs @@ -0,0 +1,26 @@ +/** + * @author d98762625 [d98762625@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + + +import DishArrayBuffer from "./DishArrayBuffer"; +import DishBigNumber from "./DishBigNumber"; +import DishFile from "./DishFile"; +import DishHTML from "./DishHTML"; +import DishJSON from "./DishJSON"; +import DishListFile from "./DishListFile"; +import DishNumber from "./DishNumber"; +import DishString from "./DishString"; + +export { + DishArrayBuffer, + DishBigNumber, + DishFile, + DishHTML, + DishJSON, + DishListFile, + DishNumber, + DishString, +}; diff --git a/tests/operations/tests/ConvertCoordinateFormat.mjs b/tests/operations/tests/ConvertCoordinateFormat.mjs index 1291aa4d..80a336b7 100644 --- a/tests/operations/tests/ConvertCoordinateFormat.mjs +++ b/tests/operations/tests/ConvertCoordinateFormat.mjs @@ -18,7 +18,7 @@ * UTM: 30N 699456 5709791, */ -import TestRegister from "../TestRegister"; +import TestRegister from "../../lib/TestRegister"; TestRegister.addTests([ { diff --git a/tests/operations/tests/ToFromInsensitiveRegex.mjs b/tests/operations/tests/ToFromInsensitiveRegex.mjs index fa191951..0aaf89e2 100644 --- a/tests/operations/tests/ToFromInsensitiveRegex.mjs +++ b/tests/operations/tests/ToFromInsensitiveRegex.mjs @@ -6,7 +6,7 @@ * @copyright Crown Copyright 2018 * @license Apache-2.0 */ -import TestRegister from "../TestRegister"; +import TestRegister from "../../lib/TestRegister"; TestRegister.addTests([ { diff --git a/tests/operations/tests/YARA.mjs b/tests/operations/tests/YARA.mjs index e3c28ef1..5495ca69 100644 --- a/tests/operations/tests/YARA.mjs +++ b/tests/operations/tests/YARA.mjs @@ -6,7 +6,7 @@ * @copyright Crown Copyright 2019 * @license Apache-2.0 */ -import TestRegister from "../TestRegister"; +import TestRegister from "../../lib/TestRegister"; TestRegister.addTests([ { From 6d219ade2d47500c0176a293500e576750942fd9 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 1 Mar 2019 16:56:14 +0000 Subject: [PATCH 173/687] remove legacy async api from NodeRecipe --- src/node/NodeDish.mjs | 11 +++++++++++ src/node/NodeRecipe.mjs | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/node/NodeDish.mjs b/src/node/NodeDish.mjs index bddc96e0..f619fdd8 100644 --- a/src/node/NodeDish.mjs +++ b/src/node/NodeDish.mjs @@ -30,6 +30,17 @@ class NodeDish extends Dish { super(inputOrDish, type); } + /** + * Apply the inputted operation to the dish. + * + * @param {WrappedOperation} operation the operation to perform + * @param {*} args - any arguments for the operation + * @returns {Dish} a new dish with the result of the operation. + */ + apply(operation, args=null) { + return operation(this.value, args); + } + /** * alias for get * @param args see get args diff --git a/src/node/NodeRecipe.mjs b/src/node/NodeRecipe.mjs index aa72fa6b..070c5433 100644 --- a/src/node/NodeRecipe.mjs +++ b/src/node/NodeRecipe.mjs @@ -76,14 +76,14 @@ class NodeRecipe { * @param {NodeDish} dish * @returns {NodeDish} */ - async execute(dish) { - return await this.opList.reduce(async (prev, curr) => { + execute(dish) { + return this.opList.reduce((prev, curr) => { // CASE where opLis item is op and args if (curr.hasOwnProperty("op") && curr.hasOwnProperty("args")) { - return await curr.op(prev, curr.args); + return curr.op(prev, curr.args); } // CASE opList item is just op. - return await curr(prev); + return curr(prev); }, dish); } } From e4b688a2c3ea192fa93a3c35ed35db32ec930eaf Mon Sep 17 00:00:00 2001 From: d98762625 Date: Fri, 1 Mar 2019 16:58:43 +0000 Subject: [PATCH 174/687] Make Frequency dist test more sensible --- tests/node/tests/ops.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/node/tests/ops.mjs b/tests/node/tests/ops.mjs index 864adf5a..9f621cec 100644 --- a/tests/node/tests/ops.mjs +++ b/tests/node/tests/ops.mjs @@ -510,7 +510,8 @@ Top Drawer`, { it("Frequency distribution", () => { const result = chef.frequencyDistribution("Don't Count Your Chickens Before They Hatch"); const expected = "{\"dataLength\":43,\"percentages\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13.953488372093023,0,0,0,0,0,0,2.3255813953488373,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2.3255813953488373,4.651162790697675,2.3255813953488373,0,0,0,2.3255813953488373,0,0,0,0,0,0,0,0,0,0,0,2.3255813953488373,0,0,0,0,2.3255813953488373,0,0,0,0,0,0,0,2.3255813953488373,0,4.651162790697675,0,9.30232558139535,2.3255813953488373,0,6.976744186046512,2.3255813953488373,0,2.3255813953488373,0,0,6.976744186046512,9.30232558139535,0,0,4.651162790697675,2.3255813953488373,6.976744186046512,4.651162790697675,0,0,0,2.3255813953488373,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"distribution\":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,1,0,2,0,4,1,0,3,1,0,1,0,0,3,4,0,0,2,1,3,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],\"bytesRepresented\":22}"; - assert.strictEqual(result.toString(), expected); + // Whacky formatting, but the data is all there + assert.strictEqual(result.toString().replace(/\r?\n|\r|\s/g, ""), expected); }), it("From base", () => { From 77b098c5feb405fafdcdf274ccd188e43e1373db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sat, 2 Mar 2019 15:00:42 +0100 Subject: [PATCH 175/687] Add Bacon cipher decoding --- src/core/config/Categories.json | 1 + src/core/lib/Bacon.mjs | 37 ++++ src/core/operations/BaconCipherDecode.mjs | 108 ++++++++++ tests/operations/index.mjs | 1 + tests/operations/tests/BaconCipher.mjs | 246 ++++++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 src/core/lib/Bacon.mjs create mode 100644 src/core/operations/BaconCipherDecode.mjs create mode 100644 tests/operations/tests/BaconCipher.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 8235ab10..846e3e8e 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -85,6 +85,7 @@ "Vigenère Decode", "To Morse Code", "From Morse Code", + "Bacon Cipher Decode", "Bifid Cipher Encode", "Bifid Cipher Decode", "Affine Cipher Encode", diff --git a/src/core/lib/Bacon.mjs b/src/core/lib/Bacon.mjs new file mode 100644 index 00000000..a2be4b56 --- /dev/null +++ b/src/core/lib/Bacon.mjs @@ -0,0 +1,37 @@ +/** + * Bacon resources. + * + * @author Karsten Silkenbäumer [kassi@users.noreply.github.com] + * @copyright Karsten Silkenbäumer 2019 + * @license Apache-2.0 + */ + +/** + * Bacon definitions. + */ + +export const BACON_ALPHABET_REDUCED = "ABCDEFGHIKLMNOPQRSTUWXYZ"; +export const BACON_ALPHABET_COMPLETE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +export const BACON_TRANSLATION_01 = "0/1"; +export const BACON_TRANSLATION_AB = "A/B"; +export const BACON_TRANSLATION_CASE = "Case"; +export const BACON_TRANSLATION_AMNZ = "A-M/N-Z first letter"; +export const BACON_TRANSLATIONS = [ + BACON_TRANSLATION_01, + BACON_TRANSLATION_AB, + BACON_TRANSLATION_CASE, + BACON_TRANSLATION_AMNZ, +]; +export const BACON_CLEARER_MAP = { + [BACON_TRANSLATIONS[0]]: /[^01]/g, + [BACON_TRANSLATIONS[1]]: /[^ABab]/g, + [BACON_TRANSLATIONS[2]]: /[^A-Za-z]/g, +}; +export const BACON_NORMALIZE_MAP = { + [BACON_TRANSLATIONS[1]]: { + "A": "0", + "B": "1", + "a": "0", + "b": "1" + }, +}; diff --git a/src/core/operations/BaconCipherDecode.mjs b/src/core/operations/BaconCipherDecode.mjs new file mode 100644 index 00000000..2d767538 --- /dev/null +++ b/src/core/operations/BaconCipherDecode.mjs @@ -0,0 +1,108 @@ +/** + * BaconCipher operation. + * +* @author kassi [kassi@users.noreply.github.com] +* @copyright Karsten Silkenbäumer 2019 +* @license Apache-2.0 +*/ + +import Operation from "../Operation"; +import { + BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, + BACON_TRANSLATION_CASE, BACON_TRANSLATION_AMNZ, BACON_TRANSLATIONS, BACON_CLEARER_MAP, BACON_NORMALIZE_MAP +} from "../lib/Bacon"; + +/** +* BaconCipherDecode operation +*/ +class BaconCipherDecode extends Operation { + /** + * BaconCipherDecode constructor + */ + constructor() { + super(); + + this.name = "Bacon Cipher Decode"; + this.module = "Default"; + this.description = "Bacon's cipher or the Baconian cipher is a method of steganography(a method of hiding a secret message as opposed to just a cipher) devised by Francis Bacon in 1605.[1][2][3] A message is concealed in the presentation of text, rather than its content."; + this.infoURL = "https://en.wikipedia.org/wiki/Bacon%27s_cipher"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Alphabet", + "type": "option", + "value": [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE] + }, + { + "name": "Translation", + "type": "option", + "value": BACON_TRANSLATIONS + }, + { + "name": "Invert Translation", + "type": "boolean", + "value": false + } + ]; + } + + /** + * @param {String} input + * @param {Object[]} args + * @returns {String} + */ + run(input, args) { + const [alphabet, translation, invert] = args; + // split text into groups of 5 characters + + // remove invalid characters + input = input.replace(BACON_CLEARER_MAP[translation], ""); + // normalize to unique alphabet + if (BACON_NORMALIZE_MAP[translation] !== undefined) { + input = input.replace(/./g, function (c) { + return BACON_NORMALIZE_MAP[translation][c]; + }); + } else if (translation === BACON_TRANSLATION_CASE) { + const codeA = "A".charCodeAt(0); + const codeZ = "Z".charCodeAt(0); + input = input.replace(/./g, function (c) { + const code = c.charCodeAt(0); + if (code >= codeA && code <= codeZ) { + return "1"; + } else { + return "0"; + } + }); + } else if (translation === BACON_TRANSLATION_AMNZ) { + const words = input.split(" "); + const letters = words.map(function (e) { + const code = e[0].toUpperCase().charCodeAt(0); + return code >= "N".charCodeAt(0) ? "1" : "0"; + }); + input = letters.join(""); + } + + if (invert) { + input = input.replace(/./g, function (c) { + return { + "0": "1", + "1": "0" + }[c]; + }); + } + + // group into 5 + const inputArray = input.match(/(.{5})/g) || []; + + let output = ""; + for (let index = 0; index < inputArray.length; index++) { + const code = inputArray[index]; + const number = parseInt(code, 2); + output += number < alphabet.length ? alphabet[number] : "?"; + } + return output; + } +} + +export default BaconCipherDecode; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index fb68ed9c..e61f886a 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -26,6 +26,7 @@ global.ENVIRONMENT_IS_WEB = function() { import TestRegister from "./TestRegister"; import "./tests/BCD"; import "./tests/BSON"; +import "./tests/BaconCipher"; import "./tests/Base58"; import "./tests/Base64"; import "./tests/Base62"; diff --git a/tests/operations/tests/BaconCipher.mjs b/tests/operations/tests/BaconCipher.mjs new file mode 100644 index 00000000..b4b63b8f --- /dev/null +++ b/tests/operations/tests/BaconCipher.mjs @@ -0,0 +1,246 @@ +/** + * BaconCipher tests. + * + * @author Karsten Silkenbäumer [kassi@users.noreply.github.com] + * @copyright Karsten Silkenbäumer 2019 + * @license Apache-2.0 + */ +import TestRegister from "../TestRegister"; +import { BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, BACON_TRANSLATIONS } from "../../../src/core/lib/Bacon"; + +const alphabets = [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE]; +const translations = BACON_TRANSLATIONS; + +TestRegister.addTests([ + { + name: "Bacon Decode: no input", + input: "", + expectedOutput: "", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[0], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet 0/1", + input: "00011 00100 00010 01101 00011 01000 01100 00110 00001 00000 00010 01101 01100 10100 01101 10000 01001 10001", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[0], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet 0/1 inverse", + input: "11100 11011 11101 10010 11100 10111 10011 11001 11110 11111 11101 10010 10011 01011 10010 01111 10110 01110", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[0], true] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet A/B lower case", + input: "aaabb aabaa aaaba abbab aaabb abaaa abbaa aabba aaaab aaaaa aaaba abbab abbaa babaa abbab baaaa abaab baaab", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[1], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet A/B lower case inverse", + input: "bbbaa bbabb bbbab baaba bbbaa babbb baabb bbaab bbbba bbbbb bbbab baaba baabb ababb baaba abbbb babba abbba", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[1], true] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet A/B upper case", + input: "AAABB AABAA AAABA ABBAB AAABB ABAAA ABBAA AABBA AAAAB AAAAA AAABA ABBAB ABBAA BABAA ABBAB BAAAA ABAAB BAAAB", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[1], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet A/B upper case inverse", + input: "BBBAA BBABB BBBAB BAABA BBBAA BABBB BAABB BBAAB BBBBA BBBBB BBBAB BAABA BAABB ABABB BAABA ABBBB BABBA ABBBA", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[1], true] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet case code", + input: "thiS IsaN exampLe oF ThE bacON cIpher WIth upPPercasE letters tRanSLaTiNG to OnEs anD LoWErcase To zERoes. KS", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[2], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet case code inverse", + input: "THIs iS An EXAMPlE Of tHe BACon CiPHER wiTH UPppERCASe LETTERS TrANslAtIng TO oNeS ANd lOweRCASE tO ZerOES. ks", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[2], true] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet case code", + input: "A little example of the Bacon Cipher to be decoded. It is a working example and shorter than my others, but it anyways works tremendously. And just that's important, correct?", + expectedOutput: "DECODE", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[3], false] + } + ], + }, + { + name: "Bacon Decode: reduced alphabet case code inverse", + input: "Well, there's now another example which will be not only strange to read but sound weird for everyone not knowing what the thing is about. Nevertheless, works great out of the box.", + expectedOutput: "DECODE", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[0], translations[3], true] + } + ], + }, + { + name: "Bacon Decode: complete alphabet 0/1", + input: "00011 00100 00010 01110 00011 01000 01101 00110 00001 00000 00010 01110 01101 10110 01110 10001 01010 10010", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[0], false] + } + ], + }, + { + name: "Bacon Decode: complete alphabet 0/1 inverse", + input: "11100 11011 11101 10001 11100 10111 10010 11001 11110 11111 11101 10001 10010 01001 10001 01110 10101 01101", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[0], true] + } + ], + }, + { + name: "Bacon Decode: complete alphabet A/B lower case", + input: "aaabb aabaa aaaba abbba aaabb abaaa abbab aabba aaaab aaaaa aaaba abbba abbab babba abbba baaab ababa baaba", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[1], false] + } + ], + }, + { + name: "Bacon Decode: complete alphabet A/B lower case inverse", + input: "bbbaa bbabb bbbab baaab bbbaa babbb baaba bbaab bbbba bbbbb bbbab baaab baaba abaab baaab abbba babab abbab", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[1], true] + } + ], + }, + { + name: "Bacon Decode: complete alphabet A/B upper case", + input: "AAABB AABAA AAABA ABBBA AAABB ABAAA ABBAB AABBA AAAAB AAAAA AAABA ABBBA ABBAB BABBA ABBBA BAAAB ABABA BAABA", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[1], false] + } + ], + }, + { + name: "Bacon Decode: complete alphabet A/B upper case inverse", + input: "BBBAA BBABB BBBAB BAAAB BBBAA BABBB BAABA BBAAB BBBBA BBBBB BBBAB BAAAB BAABA ABAAB BAAAB ABBBA BABAB ABBAB", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[1], true] + } + ], + }, + { + name: "Bacon Decode: complete alphabet case code", + input: "thiS IsaN exampLe oF THe bacON cIpher WItH upPPercasE letters tRanSLAtiNG tO OnES anD LOwErcaSe To ZeRoeS. kS", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[2], false] + } + ], + }, + { + name: "Bacon Decode: complete alphabet case code inverse", + input: "THIs iSAn EXAMPlE Of thE BACon CiPHER wiTh UPppERCASe LETTERS TrANslaTIng To zEroES and LoWERcAsE tO oNEs. Ks", + expectedOutput: "DECODINGBACONWORKS", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[2], true] + } + ], + }, + { + name: "Bacon Decode: complete alphabet case code", + input: "A little example of the Bacon Cipher to be decoded. It is a working example and shorter than the first, but it anyways works tremendously. And just that's important, correct?", + expectedOutput: "DECODE", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[3], false] + } + ], + }, + { + name: "Bacon Decode: complete alphabet case code inverse", + input: "Well, there's now another example which will be not only strange to read but sound weird for everyone knowing nothing what the thing is about. Nevertheless, works great out of the box.", + expectedOutput: "DECODE", + recipeConfig: [ + { + op: "Bacon Cipher Decode", + args: [alphabets[1], translations[3], true] + } + ], + }, +]); From 9fa7edffbf1d559e54c31501b7f8be4e7b86448b Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sat, 2 Mar 2019 16:12:21 +0000 Subject: [PATCH 176/687] Improved file extraction error handling --- src/core/lib/FileSignatures.mjs | 6 +++--- src/core/operations/ExtractFiles.mjs | 22 +++++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/core/lib/FileSignatures.mjs b/src/core/lib/FileSignatures.mjs index 93247413..36e6818e 100644 --- a/src/core/lib/FileSignatures.mjs +++ b/src/core/lib/FileSignatures.mjs @@ -1057,7 +1057,7 @@ export function extractJPEG(bytes, offset) { while (stream.hasMore()) { const marker = stream.getBytes(2); - if (marker[0] !== 0xff) throw new Error("Invalid JPEG marker: " + marker); + if (marker[0] !== 0xff) throw new Error(`Invalid marker while parsing JPEG at pos ${stream.position}: ${marker}`); let segmentSize = 0; switch (marker[1]) { @@ -1609,7 +1609,7 @@ function parseDEFLATE(stream) { parseHuffmanBlock(stream, dynamicLiteralTable, dynamicDistanceTable); } else { - throw new Error("Invalid block type"); + throw new Error(`Invalid block type while parsing DEFLATE stream at pos ${stream.position}`); } } @@ -1712,7 +1712,7 @@ function readHuffmanCode(stream, table) { const codeLength = codeWithLength >>> 16; if (codeLength > maxCodeLength) { - throw new Error("Invalid code length: " + codeLength); + throw new Error(`Invalid Huffman Code length while parsing DEFLATE block at pos ${stream.position}: ${codeLength}`); } stream.moveBackwardsByBits(maxCodeLength - codeLength); diff --git a/src/core/operations/ExtractFiles.mjs b/src/core/operations/ExtractFiles.mjs index f172d926..d2b87990 100644 --- a/src/core/operations/ExtractFiles.mjs +++ b/src/core/operations/ExtractFiles.mjs @@ -5,7 +5,7 @@ */ import Operation from "../Operation"; -// import OperationError from "../errors/OperationError"; +import OperationError from "../errors/OperationError"; import Utils from "../Utils"; import {scanForFileTypes, extractFile} from "../lib/FileType"; import {FILE_SIGNATURES} from "../lib/FileSignatures"; @@ -34,7 +34,13 @@ class ExtractFiles extends Operation { type: "boolean", value: cat === "Miscellaneous" ? false : true }; - }); + }).concat([ + { + name: "Ignore failed extractions", + type: "boolean", + value: "true" + } + ]); } /** @@ -44,7 +50,8 @@ class ExtractFiles extends Operation { */ run(input, args) { const bytes = new Uint8Array(input), - categories = []; + categories = [], + ignoreFailedExtractions = args.pop(1); args.forEach((cat, i) => { if (cat) categories.push(Object.keys(FILE_SIGNATURES)[i]); @@ -59,8 +66,13 @@ class ExtractFiles extends Operation { try { files.push(extractFile(bytes, detectedFile.fileDetails, detectedFile.offset)); } catch (err) { - if (err.message.indexOf("No extraction algorithm available") < 0) - throw err; + if (!ignoreFailedExtractions && err.message.indexOf("No extraction algorithm available") < 0) { + throw new OperationError( + `Error while attempting to extract ${detectedFile.fileDetails.name} ` + + `at offset ${detectedFile.offset}:\n` + + `${err.message}` + ); + } } }); From a262d70b88584d33dee99dee9062ee7c9687a620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sat, 2 Mar 2019 17:33:17 +0100 Subject: [PATCH 177/687] Add Bacon cipher encoding --- src/core/config/Categories.json | 1 + src/core/lib/Bacon.mjs | 24 +++ src/core/operations/BaconCipherDecode.mjs | 10 +- src/core/operations/BaconCipherEncode.mjs | 103 ++++++++++++ tests/operations/tests/BaconCipher.mjs | 189 ++++++++++++++++++++++ 5 files changed, 320 insertions(+), 7 deletions(-) create mode 100644 src/core/operations/BaconCipherEncode.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 846e3e8e..05ab4524 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -85,6 +85,7 @@ "Vigenère Decode", "To Morse Code", "From Morse Code", + "Bacon Cipher Encode", "Bacon Cipher Decode", "Bifid Cipher Encode", "Bifid Cipher Decode", diff --git a/src/core/lib/Bacon.mjs b/src/core/lib/Bacon.mjs index a2be4b56..e45c3b74 100644 --- a/src/core/lib/Bacon.mjs +++ b/src/core/lib/Bacon.mjs @@ -12,6 +12,7 @@ export const BACON_ALPHABET_REDUCED = "ABCDEFGHIKLMNOPQRSTUWXYZ"; export const BACON_ALPHABET_COMPLETE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +export const BACON_CODES_REDUCED = [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23]; export const BACON_TRANSLATION_01 = "0/1"; export const BACON_TRANSLATION_AB = "A/B"; export const BACON_TRANSLATION_CASE = "Case"; @@ -22,6 +23,10 @@ export const BACON_TRANSLATIONS = [ BACON_TRANSLATION_CASE, BACON_TRANSLATION_AMNZ, ]; +export const BACON_TRANSLATIONS_FOR_ENCODING = [ + BACON_TRANSLATION_01, + BACON_TRANSLATION_AB +]; export const BACON_CLEARER_MAP = { [BACON_TRANSLATIONS[0]]: /[^01]/g, [BACON_TRANSLATIONS[1]]: /[^ABab]/g, @@ -35,3 +40,22 @@ export const BACON_NORMALIZE_MAP = { "b": "1" }, }; + +/** + * Swaps zeros to ones and ones to zeros. + * + * @param {string} data + * @returns {string} + * + * @example + * // returns "11001 01010" + * swapZeroAndOne("00110 10101"); + */ +export function swapZeroAndOne(string) { + return string.replace(/[01]/g, function (c) { + return { + "0": "1", + "1": "0" + }[c]; + }); +} diff --git a/src/core/operations/BaconCipherDecode.mjs b/src/core/operations/BaconCipherDecode.mjs index 2d767538..6aa5aac8 100644 --- a/src/core/operations/BaconCipherDecode.mjs +++ b/src/core/operations/BaconCipherDecode.mjs @@ -9,7 +9,8 @@ import Operation from "../Operation"; import { BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, - BACON_TRANSLATION_CASE, BACON_TRANSLATION_AMNZ, BACON_TRANSLATIONS, BACON_CLEARER_MAP, BACON_NORMALIZE_MAP + BACON_TRANSLATION_CASE, BACON_TRANSLATION_AMNZ, BACON_TRANSLATIONS, BACON_CLEARER_MAP, BACON_NORMALIZE_MAP, + swapZeroAndOne } from "../lib/Bacon"; /** @@ -84,12 +85,7 @@ class BaconCipherDecode extends Operation { } if (invert) { - input = input.replace(/./g, function (c) { - return { - "0": "1", - "1": "0" - }[c]; - }); + input = swapZeroAndOne(input); } // group into 5 diff --git a/src/core/operations/BaconCipherEncode.mjs b/src/core/operations/BaconCipherEncode.mjs new file mode 100644 index 00000000..4761df46 --- /dev/null +++ b/src/core/operations/BaconCipherEncode.mjs @@ -0,0 +1,103 @@ +/** + * BaconCipher operation. + * +* @author kassi [kassi@users.noreply.github.com] +* @copyright Karsten Silkenbäumer 2019 +* @license Apache-2.0 +*/ + +import Operation from "../Operation"; +import { + BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, + BACON_TRANSLATIONS_FOR_ENCODING, BACON_TRANSLATION_AB, + swapZeroAndOne +} from "../lib/Bacon"; +import { BACON_CODES_REDUCED } from "../lib/Bacon.mjs"; + +/** +* BaconCipherEncode operation +*/ +class BaconCipherEncode extends Operation { + /** + * BaconCipherEncode constructor + */ + constructor() { + super(); + + this.name = "Bacon Cipher Encode"; + this.module = "Default"; + this.description = "Bacon's cipher or the Baconian cipher is a method of steganography(a method of hiding a secret message as opposed to just a cipher) devised by Francis Bacon in 1605.[1][2][3] A message is concealed in the presentation of text, rather than its content."; + this.infoURL = "https://en.wikipedia.org/wiki/Bacon%27s_cipher"; + this.inputType = "string"; + this.outputType = "string"; + this.args = [ + { + "name": "Alphabet", + "type": "option", + "value": [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE] + }, + { + "name": "Translation", + "type": "option", + "value": BACON_TRANSLATIONS_FOR_ENCODING + }, + { + "name": "Keep extra characters", + "type": "boolean", + "value": false + }, + { + "name": "Invert Translation", + "type": "boolean", + "value": false + } + ]; + } + + /** + * @param {String} input + * @param {Object[]} args + * @returns {String} + */ + run(input, args) { + const [alphabet, translation, keep, invert] = args; + + const charCodeA = "A".charCodeAt(0); + const charCodeZ = "Z".charCodeAt(0); + + let output = input.replace(/./g, function (c) { + const charCode = c.toUpperCase().charCodeAt(0); + if (charCode >= charCodeA && charCode <= charCodeZ) { + let code = charCode - charCodeA; + if (alphabet === BACON_ALPHABET_REDUCED) { + code = BACON_CODES_REDUCED[code]; + } + const bacon = ("00000" + code.toString(2)).substr(-5, 5); + return bacon; + } else { + return c; + } + }); + + if (invert) { + output = swapZeroAndOne(output); + } + if (!keep) { + output = output.replace(/[^01]/g, ""); + const outputArray = output.match(/(.{5})/g) || []; + output = outputArray.join(" "); + } + if (translation === BACON_TRANSLATION_AB) { + output = output.replace(/[01]/g, function (c) { + return { + "0": "A", + "1": "B" + }[c]; + }); + } + + return output; + } +} + +export default BaconCipherEncode; diff --git a/tests/operations/tests/BaconCipher.mjs b/tests/operations/tests/BaconCipher.mjs index b4b63b8f..dce51659 100644 --- a/tests/operations/tests/BaconCipher.mjs +++ b/tests/operations/tests/BaconCipher.mjs @@ -242,5 +242,194 @@ TestRegister.addTests([ args: [alphabets[1], translations[3], true] } ], + } +]); +TestRegister.addTests([ + { + name: "Bacon Encode: no input", + input: "", + expectedOutput: "", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[0], false, false] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet 0/1", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "10010 00111 00100 10000 00100 10001 00000 00101 01101 10101 00000 01100 00011 01000 10010 01000 10011 01011 01110 10001 01101 10011 00100 10000 10010 00111 00100 00101 00100 01100 00010 00100", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[0], false, false] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet 0/1 inverse", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "01101 11000 11011 01111 11011 01110 11111 11010 10010 01010 11111 10011 11100 10111 01101 10111 01100 10100 10001 01110 10010 01100 11011 01111 01101 11000 11011 11010 11011 10011 11101 11011", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[0], false, true] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet 0/1, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "1001000111001001000000100'10001 00000 001010110110101, 000000110000011 0100010010 0100010011010110111010001 01101100110010010000 100100011100100 0010100100011000001000100.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[0], true, false] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet 0/1 inverse, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "0110111000110110111111011'01110 11111 110101001001010, 111111001111100 1011101101 1011101100101001000101110 10010011001101101111 011011100011011 1101011011100111110111011.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[0], true, true] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet A/B", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "BAABA AABBB AABAA BAAAA AABAA BAAAB AAAAA AABAB ABBAB BABAB AAAAA ABBAA AAABB ABAAA BAABA ABAAA BAABB ABABB ABBBA BAAAB ABBAB BAABB AABAA BAAAA BAABA AABBB AABAA AABAB AABAA ABBAA AAABA AABAA", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[1], false, false] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet A/B inverse", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "ABBAB BBAAA BBABB ABBBB BBABB ABBBA BBBBB BBABA BAABA ABABA BBBBB BAABB BBBAA BABBB ABBAB BABBB ABBAA BABAA BAAAB ABBBA BAABA ABBAA BBABB ABBBB ABBAB BBAAA BBABB BBABA BBABB BAABB BBBAB BBABB", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[1], false, true] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet A/B, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "BAABAAABBBAABAABAAAAAABAA'BAAAB AAAAA AABABABBABBABAB, AAAAAABBAAAAABB ABAAABAABA ABAAABAABBABABBABBBABAAAB ABBABBAABBAABAABAAAA BAABAAABBBAABAA AABABAABAAABBAAAAABAAABAA.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[1], true, false] + } + ], + }, + { + name: "Bacon Encode: reduced alphabet A/B inverse, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "ABBABBBAAABBABBABBBBBBABB'ABBBA BBBBB BBABABAABAABABA, BBBBBBAABBBBBAA BABBBABBAB BABBBABBAABABAABAAABABBBA BAABAABBAABBABBABBBB ABBABBBAAABBABB BBABABBABBBAABBBBBABBBABB.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[0], translations[1], true, true] + } + ], + }, + { + name: "Bacon Encode: complete alphabet 0/1", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "10011 00111 00100 10001 00100 10010 00000 00101 01110 10111 00000 01101 00011 01000 10011 01001 10100 01100 01111 10010 01110 10101 00100 10001 10011 00111 00100 00101 00100 01101 00010 00100", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[0], false, false] + } + ], + }, + { + name: "Bacon Encode: complete alphabet 0/1 inverse", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "01100 11000 11011 01110 11011 01101 11111 11010 10001 01000 11111 10010 11100 10111 01100 10110 01011 10011 10000 01101 10001 01010 11011 01110 01100 11000 11011 11010 11011 10010 11101 11011", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[0], false, true] + } + ], + }, + { + name: "Bacon Encode: complete alphabet 0/1, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "1001100111001001000100100'10010 00000 001010111010111, 000000110100011 0100010011 0100110100011000111110010 01110101010010010001 100110011100100 0010100100011010001000100.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[0], true, false] + } + ], + }, + { + name: "Bacon Encode: complete alphabet 0/1 inverse, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "0110011000110110111011011'01101 11111 110101000101000, 111111001011100 1011101100 1011001011100111000001101 10001010101101101110 011001100011011 1101011011100101110111011.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[0], true, true] + } + ], + }, + { + name: "Bacon Encode: complete alphabet A/B", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "BAABB AABBB AABAA BAAAB AABAA BAABA AAAAA AABAB ABBBA BABBB AAAAA ABBAB AAABB ABAAA BAABB ABAAB BABAA ABBAA ABBBB BAABA ABBBA BABAB AABAA BAAAB BAABB AABBB AABAA AABAB AABAA ABBAB AAABA AABAA", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[1], false, false] + } + ], + }, + { + name: "Bacon Encode: complete alphabet A/B inverse", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "ABBAA BBAAA BBABB ABBBA BBABB ABBAB BBBBB BBABA BAAAB ABAAA BBBBB BAABA BBBAA BABBB ABBAA BABBA ABABB BAABB BAAAA ABBAB BAAAB ABABA BBABB ABBBA ABBAA BBAAA BBABB BBABA BBABB BAABA BBBAB BBABB", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[1], false, true] + } + ], + }, + { + name: "Bacon Encode: complete alphabet A/B, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "BAABBAABBBAABAABAAABAABAA'BAABA AAAAA AABABABBBABABBB, AAAAAABBABAAABB ABAAABAABB ABAABBABAAABBAAABBBBBAABA ABBBABABABAABAABAAAB BAABBAABBBAABAA AABABAABAAABBABAAABAAABAA.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[1], true, false] + } + ], + }, + { + name: "Bacon Encode: complete alphabet A/B inverse, keeping extra characters", + input: "There's a fox, and it jumps over the fence.", + expectedOutput: "ABBAABBAAABBABBABBBABBABB'ABBAB BBBBB BBABABAAABABAAA, BBBBBBAABABBBAA BABBBABBAA BABBAABABBBAABBBAAAAABBAB BAAABABABABBABBABBBA ABBAABBAAABBABB BBABABBABBBAABABBBABBBABB.", + recipeConfig: [ + { + op: "Bacon Cipher Encode", + args: [alphabets[1], translations[1], true, true] + } + ], }, ]); From d36cede0c7706c90fa579991022029946779d589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sat, 2 Mar 2019 17:55:03 +0100 Subject: [PATCH 178/687] Use better names for the alphabet selection --- src/core/lib/Bacon.mjs | 13 +++++++++---- src/core/operations/BaconCipherDecode.mjs | 9 +++++---- src/core/operations/BaconCipherEncode.mjs | 10 +++++----- tests/operations/tests/BaconCipher.mjs | 4 ++-- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/core/lib/Bacon.mjs b/src/core/lib/Bacon.mjs index e45c3b74..3ed39336 100644 --- a/src/core/lib/Bacon.mjs +++ b/src/core/lib/Bacon.mjs @@ -9,10 +9,15 @@ /** * Bacon definitions. */ - -export const BACON_ALPHABET_REDUCED = "ABCDEFGHIKLMNOPQRSTUWXYZ"; -export const BACON_ALPHABET_COMPLETE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; -export const BACON_CODES_REDUCED = [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23]; +export const BACON_ALPHABETS = { + "Standard (I=J and U=V)": { + alphabet: "ABCDEFGHIKLMNOPQRSTUWXYZ", + codes: [0, 1, 2, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23] + }, + "Complete": { + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + } +}; export const BACON_TRANSLATION_01 = "0/1"; export const BACON_TRANSLATION_AB = "A/B"; export const BACON_TRANSLATION_CASE = "Case"; diff --git a/src/core/operations/BaconCipherDecode.mjs b/src/core/operations/BaconCipherDecode.mjs index 6aa5aac8..ecd1bc92 100644 --- a/src/core/operations/BaconCipherDecode.mjs +++ b/src/core/operations/BaconCipherDecode.mjs @@ -8,7 +8,7 @@ import Operation from "../Operation"; import { - BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, + BACON_ALPHABETS, BACON_TRANSLATION_CASE, BACON_TRANSLATION_AMNZ, BACON_TRANSLATIONS, BACON_CLEARER_MAP, BACON_NORMALIZE_MAP, swapZeroAndOne } from "../lib/Bacon"; @@ -33,7 +33,7 @@ class BaconCipherDecode extends Operation { { "name": "Alphabet", "type": "option", - "value": [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE] + "value": Object.keys(BACON_ALPHABETS) }, { "name": "Translation", @@ -55,10 +55,11 @@ class BaconCipherDecode extends Operation { */ run(input, args) { const [alphabet, translation, invert] = args; - // split text into groups of 5 characters + const alphabetObject = BACON_ALPHABETS[alphabet]; // remove invalid characters input = input.replace(BACON_CLEARER_MAP[translation], ""); + // normalize to unique alphabet if (BACON_NORMALIZE_MAP[translation] !== undefined) { input = input.replace(/./g, function (c) { @@ -95,7 +96,7 @@ class BaconCipherDecode extends Operation { for (let index = 0; index < inputArray.length; index++) { const code = inputArray[index]; const number = parseInt(code, 2); - output += number < alphabet.length ? alphabet[number] : "?"; + output += number < alphabetObject.alphabet.length ? alphabetObject.alphabet[number] : "?"; } return output; } diff --git a/src/core/operations/BaconCipherEncode.mjs b/src/core/operations/BaconCipherEncode.mjs index 4761df46..e163792e 100644 --- a/src/core/operations/BaconCipherEncode.mjs +++ b/src/core/operations/BaconCipherEncode.mjs @@ -8,11 +8,10 @@ import Operation from "../Operation"; import { - BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, + BACON_ALPHABETS, BACON_TRANSLATIONS_FOR_ENCODING, BACON_TRANSLATION_AB, swapZeroAndOne } from "../lib/Bacon"; -import { BACON_CODES_REDUCED } from "../lib/Bacon.mjs"; /** * BaconCipherEncode operation @@ -34,7 +33,7 @@ class BaconCipherEncode extends Operation { { "name": "Alphabet", "type": "option", - "value": [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE] + "value": Object.keys(BACON_ALPHABETS) }, { "name": "Translation", @@ -62,6 +61,7 @@ class BaconCipherEncode extends Operation { run(input, args) { const [alphabet, translation, keep, invert] = args; + const alphabetObject = BACON_ALPHABETS[alphabet]; const charCodeA = "A".charCodeAt(0); const charCodeZ = "Z".charCodeAt(0); @@ -69,8 +69,8 @@ class BaconCipherEncode extends Operation { const charCode = c.toUpperCase().charCodeAt(0); if (charCode >= charCodeA && charCode <= charCodeZ) { let code = charCode - charCodeA; - if (alphabet === BACON_ALPHABET_REDUCED) { - code = BACON_CODES_REDUCED[code]; + if (alphabetObject.codes !== undefined) { + code = alphabetObject.codes[code]; } const bacon = ("00000" + code.toString(2)).substr(-5, 5); return bacon; diff --git a/tests/operations/tests/BaconCipher.mjs b/tests/operations/tests/BaconCipher.mjs index dce51659..16f4bac1 100644 --- a/tests/operations/tests/BaconCipher.mjs +++ b/tests/operations/tests/BaconCipher.mjs @@ -6,9 +6,9 @@ * @license Apache-2.0 */ import TestRegister from "../TestRegister"; -import { BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE, BACON_TRANSLATIONS } from "../../../src/core/lib/Bacon"; +import { BACON_ALPHABETS, BACON_TRANSLATIONS } from "../../../src/core/lib/Bacon"; -const alphabets = [BACON_ALPHABET_REDUCED, BACON_ALPHABET_COMPLETE]; +const alphabets = Object.keys(BACON_ALPHABETS); const translations = BACON_TRANSLATIONS; TestRegister.addTests([ From 282f02f4d579f408842853bccac93dc4039f2357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sat, 2 Mar 2019 22:17:44 +0100 Subject: [PATCH 179/687] Fix error when decoding a text with 2+ whitespaces in AMNZ mode --- src/core/operations/BaconCipherDecode.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/operations/BaconCipherDecode.mjs b/src/core/operations/BaconCipherDecode.mjs index ecd1bc92..5d830603 100644 --- a/src/core/operations/BaconCipherDecode.mjs +++ b/src/core/operations/BaconCipherDecode.mjs @@ -77,10 +77,14 @@ class BaconCipherDecode extends Operation { } }); } else if (translation === BACON_TRANSLATION_AMNZ) { - const words = input.split(" "); + const words = input.split(/\s+/); const letters = words.map(function (e) { - const code = e[0].toUpperCase().charCodeAt(0); - return code >= "N".charCodeAt(0) ? "1" : "0"; + if (e) { + const code = e[0].toUpperCase().charCodeAt(0); + return code >= "N".charCodeAt(0) ? "1" : "0"; + } else { + return ""; + } }); input = letters.join(""); } From 14d924f6c74f5d8cdd5450e318dfdc38a7a36ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sat, 2 Mar 2019 22:27:53 +0100 Subject: [PATCH 180/687] Add test for the error fixed before --- tests/operations/tests/BaconCipher.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/operations/tests/BaconCipher.mjs b/tests/operations/tests/BaconCipher.mjs index 16f4bac1..ba3922d7 100644 --- a/tests/operations/tests/BaconCipher.mjs +++ b/tests/operations/tests/BaconCipher.mjs @@ -234,7 +234,7 @@ TestRegister.addTests([ }, { name: "Bacon Decode: complete alphabet case code inverse", - input: "Well, there's now another example which will be not only strange to read but sound weird for everyone knowing nothing what the thing is about. Nevertheless, works great out of the box.", + input: "Well, there's now another example which will be not only strange to read but sound weird for everyone knowing nothing what the thing is about. Nevertheless, works great out of the box. ", expectedOutput: "DECODE", recipeConfig: [ { From ad571e60190fdd7c01e7241f38d6a6feb554ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karsten=20Silkenb=C3=A4umer?= Date: Sun, 3 Mar 2019 17:20:54 +0100 Subject: [PATCH 181/687] Change author URL --- src/core/lib/Bacon.mjs | 2 +- src/core/operations/BaconCipherDecode.mjs | 2 +- src/core/operations/BaconCipherEncode.mjs | 2 +- tests/operations/tests/BaconCipher.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/lib/Bacon.mjs b/src/core/lib/Bacon.mjs index 3ed39336..9e664cce 100644 --- a/src/core/lib/Bacon.mjs +++ b/src/core/lib/Bacon.mjs @@ -1,7 +1,7 @@ /** * Bacon resources. * - * @author Karsten Silkenbäumer [kassi@users.noreply.github.com] + * @author Karsten Silkenbäumer [github.com/kassi] * @copyright Karsten Silkenbäumer 2019 * @license Apache-2.0 */ diff --git a/src/core/operations/BaconCipherDecode.mjs b/src/core/operations/BaconCipherDecode.mjs index 5d830603..624765d6 100644 --- a/src/core/operations/BaconCipherDecode.mjs +++ b/src/core/operations/BaconCipherDecode.mjs @@ -1,7 +1,7 @@ /** * BaconCipher operation. * -* @author kassi [kassi@users.noreply.github.com] +* @author Karsten Silkenbäumer [github.com/kassi] * @copyright Karsten Silkenbäumer 2019 * @license Apache-2.0 */ diff --git a/src/core/operations/BaconCipherEncode.mjs b/src/core/operations/BaconCipherEncode.mjs index e163792e..aba7483b 100644 --- a/src/core/operations/BaconCipherEncode.mjs +++ b/src/core/operations/BaconCipherEncode.mjs @@ -1,7 +1,7 @@ /** * BaconCipher operation. * -* @author kassi [kassi@users.noreply.github.com] +* @author Karsten Silkenbäumer [github.com/kassi] * @copyright Karsten Silkenbäumer 2019 * @license Apache-2.0 */ diff --git a/tests/operations/tests/BaconCipher.mjs b/tests/operations/tests/BaconCipher.mjs index ba3922d7..b4634bd4 100644 --- a/tests/operations/tests/BaconCipher.mjs +++ b/tests/operations/tests/BaconCipher.mjs @@ -1,7 +1,7 @@ /** * BaconCipher tests. * - * @author Karsten Silkenbäumer [kassi@users.noreply.github.com] + * @author Karsten Silkenbäumer [github.com/kassi] * @copyright Karsten Silkenbäumer 2019 * @license Apache-2.0 */ From 7975fadfe91ebd27b36c99d8eb54273f58efd648 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 11:46:27 +0000 Subject: [PATCH 182/687] Add options for min, max and step values for number inputs. --- src/core/Ingredient.mjs | 6 ++++++ src/core/Operation.mjs | 3 +++ src/web/HTMLIngredient.mjs | 6 ++++++ 3 files changed, 15 insertions(+) diff --git a/src/core/Ingredient.mjs b/src/core/Ingredient.mjs index 96cdd400..2c7154d9 100755 --- a/src/core/Ingredient.mjs +++ b/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; } diff --git a/src/core/Operation.mjs b/src/core/Operation.mjs index c0907fe8..c0656151 100755 --- a/src/core/Operation.mjs +++ b/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; }); } diff --git a/src/web/HTMLIngredient.mjs b/src/web/HTMLIngredient.mjs index ab7f682b..98d63be7 100755 --- a/src/web/HTMLIngredient.mjs +++ b/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 ? "" + this.hint + "" : ""} `; From 7b6062a4a287701cb33e4da7b4a70a306305fdf2 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 11:47:50 +0000 Subject: [PATCH 183/687] Set min blur amount to 1, add status message for gaussian blur. --- src/core/operations/BlurImage.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/operations/BlurImage.mjs b/src/core/operations/BlurImage.mjs index 68ae0b0f..562df8c7 100644 --- a/src/core/operations/BlurImage.mjs +++ b/src/core/operations/BlurImage.mjs @@ -32,7 +32,8 @@ class BlurImage extends Operation { { name: "Blur Amount", type: "number", - value: 5 + value: 5, + min: 1 }, { name: "Blur Type", @@ -59,6 +60,8 @@ class BlurImage extends Operation { image.blur(blurAmount); break; case "Gaussian": + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Gaussian blurring image. This will take a while..."); image.gaussian(blurAmount); break; } From d09e6089cac97e5e19c587d608ef3ffef4c03062 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 11:52:54 +0000 Subject: [PATCH 184/687] Add min width and height values --- src/core/operations/ResizeImage.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index aa5cb24b..59d5b2ac 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -32,12 +32,14 @@ class ResizeImage extends Operation { { name: "Width", type: "number", - value: 100 + value: 100, + min: 1 }, { name: "Height", type: "number", - value: 100 + value: 100, + min: 1 }, { name: "Unit type", From f281a32a4e9342d944f8835ae7fcb407089e9cf4 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 13:48:13 +0000 Subject: [PATCH 185/687] Add Wikipedia URLs --- src/core/operations/BlurImage.mjs | 2 +- src/core/operations/ResizeImage.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/operations/BlurImage.mjs b/src/core/operations/BlurImage.mjs index 562df8c7..000f3677 100644 --- a/src/core/operations/BlurImage.mjs +++ b/src/core/operations/BlurImage.mjs @@ -24,7 +24,7 @@ class BlurImage extends Operation { this.name = "Blur Image"; this.module = "Image"; this.description = "Applies a blur effect to the image.

Gaussian blur is much slower than fast blur, but produces better results."; - this.infoURL = ""; + this.infoURL = "https://wikipedia.org/wiki/Gaussian_blur"; this.inputType = "byteArray"; this.outputType = "byteArray"; this.presentType = "html"; diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index 59d5b2ac..ecba7f55 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -24,7 +24,7 @@ class ResizeImage extends Operation { this.name = "Resize Image"; this.module = "Image"; this.description = "Resizes an image to the specified width and height values."; - this.infoURL = ""; + this.infoURL = "https://wikipedia.org/wiki/Image_scaling"; this.inputType = "byteArray"; this.outputType = "byteArray"; this.presentType = "html"; From 588a8b2a3a2fc6cd1feb9ad2d16207356efbf8e7 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 13:48:29 +0000 Subject: [PATCH 186/687] Fix code syntax --- src/core/operations/RotateImage.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs index 1bab6c98..bbeea5c5 100644 --- a/src/core/operations/RotateImage.mjs +++ b/src/core/operations/RotateImage.mjs @@ -30,9 +30,9 @@ class RotateImage extends Operation { this.presentType = "html"; this.args = [ { - "name": "Rotation amount (degrees)", - "type": "number", - "value": 90 + name: "Rotation amount (degrees)", + type: "number", + value: 90 } ]; } From 4f1a897e1876e62039396c4bbfbfe5e0fa6a53cb Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 13:48:48 +0000 Subject: [PATCH 187/687] Add Crop Image operation --- src/core/config/Categories.json | 3 +- src/core/operations/CropImage.mjs | 139 ++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/CropImage.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 9b0f8249..0ab9b1e5 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -365,7 +365,8 @@ "Blur Image", "Dither Image", "Invert Image", - "Flip Image" + "Flip Image", + "Crop Image" ] }, { diff --git a/src/core/operations/CropImage.mjs b/src/core/operations/CropImage.mjs new file mode 100644 index 00000000..9ccc5ec5 --- /dev/null +++ b/src/core/operations/CropImage.mjs @@ -0,0 +1,139 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 crop edges.

Autocrop
Automatically crops same-colour borders from the image.

Autocrop tolerance
A percentage value for the tolerance of colour difference between pixels.

Only autocrop frames
Only crop real frames (all sides must have the same border)

Symmetric autocrop
Force autocrop to be symmetric (top/bottom and left/right are cropped by the same amount)

Autocrop keep border
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 [firstArg, secondArg] = args; + const [xPos, yPos, width, height, autocrop, autoTolerance, autoFrames, autoSymmetric, autoBorder] = args; + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + 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]; + } + + /** + * Displays the cropped image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default CropImage; From 737ce9939823528ba1c79195a78378f8b8bf7483 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 14:24:57 +0000 Subject: [PATCH 188/687] Add image brightness / contrast operation --- src/core/config/Categories.json | 3 +- .../operations/ImageBrightnessContrast.mjs | 91 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/ImageBrightnessContrast.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 0ab9b1e5..411f980f 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -366,7 +366,8 @@ "Dither Image", "Invert Image", "Flip Image", - "Crop Image" + "Crop Image", + "Image Brightness / Contrast" ] }, { diff --git a/src/core/operations/ImageBrightnessContrast.mjs b/src/core/operations/ImageBrightnessContrast.mjs new file mode 100644 index 00000000..51c61c70 --- /dev/null +++ b/src/core/operations/ImageBrightnessContrast.mjs @@ -0,0 +1,91 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 and 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; + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + image.brightness(brightness / 100); + image.contrast(contrast / 100); + + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } + + /** + * Displays the image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default ImageBrightnessContrast; From ec1fd7b923cf1049be2c908ee25e2c66a2e1be1a Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 14:38:25 +0000 Subject: [PATCH 189/687] Add image opacity operation --- src/core/config/Categories.json | 3 +- src/core/operations/ImageOpacity.mjs | 83 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/ImageOpacity.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 411f980f..78270fb0 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -367,7 +367,8 @@ "Invert Image", "Flip Image", "Crop Image", - "Image Brightness / Contrast" + "Image Brightness / Contrast", + "Image Opacity" ] }, { diff --git a/src/core/operations/ImageOpacity.mjs b/src/core/operations/ImageOpacity.mjs new file mode 100644 index 00000000..11a364b8 --- /dev/null +++ b/src/core/operations/ImageOpacity.mjs @@ -0,0 +1,83 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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; + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + image.opacity(opacity / 100); + + const imageBuffer = await image.getBufferAsync(jimp.MIME_PNG); + return [...imageBuffer]; + } + + /** + * Displays the image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default ImageOpacity; From 514eef50debdf8a57ee46082e64ab6038f5dd046 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 4 Mar 2019 14:48:17 +0000 Subject: [PATCH 190/687] Add image filter operation --- src/core/config/Categories.json | 3 +- src/core/operations/ImageFilter.mjs | 90 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/ImageFilter.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 78270fb0..70390c8d 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -368,7 +368,8 @@ "Flip Image", "Crop Image", "Image Brightness / Contrast", - "Image Opacity" + "Image Opacity", + "Image Filter" ] }, { diff --git a/src/core/operations/ImageFilter.mjs b/src/core/operations/ImageFilter.mjs new file mode 100644 index 00000000..370f5e6f --- /dev/null +++ b/src/core/operations/ImageFilter.mjs @@ -0,0 +1,90 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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; + const type = Magic.magicFileType(input); + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + + if (filterType === "Greyscale") { + image.greyscale(); + } else { + image.sepia(); + } + + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } + + /** + * Displays the blurred image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default ImageFilter; From 370ae323f6f0bac878f7987b35e1b23e9dec8ba2 Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 5 Mar 2019 11:49:25 +0000 Subject: [PATCH 191/687] Fix linting --- src/core/operations/ResizeImage.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index ecba7f55..8d46b9cf 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -86,10 +86,11 @@ class ResizeImage extends Operation { "Hermite": jimp.RESIZE_HERMITE, "Bezier": jimp.RESIZE_BEZIER }; - + if (!type || type.mime.indexOf("image") !== 0){ throw new OperationError("Invalid file type."); } + const image = await jimp.read(Buffer.from(input)); if (unit === "Percent") { From 662922be6fd6cb9cd6099444d83d065aeb77adf5 Mon Sep 17 00:00:00 2001 From: j433866 Date: Wed, 6 Mar 2019 10:32:58 +0000 Subject: [PATCH 192/687] Add resizing status message --- src/core/operations/ResizeImage.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index 8d46b9cf..e1ce7d45 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -97,6 +97,9 @@ class ResizeImage extends Operation { 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 { From 833c1cd98f8257e130dafd5554e5080bf62c7566 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 10:02:37 +0000 Subject: [PATCH 193/687] Add Contain Image, Cover Image and Image Hue / Saturation / Lightness ops --- src/core/config/Categories.json | 5 +- src/core/operations/ContainImage.mjs | 140 ++++++++++++++++++ src/core/operations/CoverImage.mjs | 139 +++++++++++++++++ .../ImageHueSaturationLightness.mjs | 126 ++++++++++++++++ 4 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/core/operations/ContainImage.mjs create mode 100644 src/core/operations/CoverImage.mjs create mode 100644 src/core/operations/ImageHueSaturationLightness.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 70390c8d..8430e498 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -369,7 +369,10 @@ "Crop Image", "Image Brightness / Contrast", "Image Opacity", - "Image Filter" + "Image Filter", + "Contain Image", + "Cover Image", + "Image Hue/Saturation/Lightness" ] }, { diff --git a/src/core/operations/ContainImage.mjs b/src/core/operations/ContainImage.mjs new file mode 100644 index 00000000..056244df --- /dev/null +++ b/src/core/operations/ContainImage.mjs @@ -0,0 +1,140 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 type = Magic.magicFileType(input); + + 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 (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + + 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]; + } + + /** + * Displays the contained image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default ContainImage; diff --git a/src/core/operations/CoverImage.mjs b/src/core/operations/CoverImage.mjs new file mode 100644 index 00000000..57258ec3 --- /dev/null +++ b/src/core/operations/CoverImage.mjs @@ -0,0 +1,139 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 type = Magic.magicFileType(input); + + 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 (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + 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]; + } + + /** + * Displays the covered image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } + +} + +export default CoverImage; diff --git a/src/core/operations/ImageHueSaturationLightness.mjs b/src/core/operations/ImageHueSaturationLightness.mjs new file mode 100644 index 00000000..29293fdb --- /dev/null +++ b/src/core/operations/ImageHueSaturationLightness.mjs @@ -0,0 +1,126 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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; + const type = Magic.magicFileType(input); + + if (!type || type.mime.indexOf("image") !== 0){ + throw new OperationError("Invalid file type."); + } + + const image = await jimp.read(Buffer.from(input)); + + 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]; + } + + /** + * Displays the image using HTML for web apps + * @param {byteArray} data + * @returns {html} + */ + present(data) { + if (!data.length) return ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type"); + } + dataURI += "base64," + toBase64(data); + + return ""; + } +} + +export default ImageHueSaturationLightness; From 4a7ea469d483e906bd032fd272e9047f52d3b207 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 10:03:09 +0000 Subject: [PATCH 194/687] Add status messages for image operations --- src/core/operations/CropImage.mjs | 2 ++ src/core/operations/DitherImage.mjs | 2 ++ src/core/operations/FlipImage.mjs | 2 ++ src/core/operations/ImageBrightnessContrast.mjs | 12 ++++++++++-- src/core/operations/ImageFilter.mjs | 3 ++- src/core/operations/ImageOpacity.mjs | 2 ++ src/core/operations/InvertImage.mjs | 2 ++ src/core/operations/RotateImage.mjs | 2 ++ 8 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/core/operations/CropImage.mjs b/src/core/operations/CropImage.mjs index 9ccc5ec5..e29db631 100644 --- a/src/core/operations/CropImage.mjs +++ b/src/core/operations/CropImage.mjs @@ -99,6 +99,8 @@ class CropImage extends Operation { } const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Cropping image..."); if (autocrop) { image.autocrop({ tolerance: (autoTolerance / 100), diff --git a/src/core/operations/DitherImage.mjs b/src/core/operations/DitherImage.mjs index 2cc9ac2d..e6856d4a 100644 --- a/src/core/operations/DitherImage.mjs +++ b/src/core/operations/DitherImage.mjs @@ -41,6 +41,8 @@ class DitherImage extends Operation { if (type && type.mime.indexOf("image") === 0){ const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Applying dither to image..."); image.dither565(); const imageBuffer = await image.getBufferAsync(jimp.AUTO); return [...imageBuffer]; diff --git a/src/core/operations/FlipImage.mjs b/src/core/operations/FlipImage.mjs index fa3054e2..3185df9f 100644 --- a/src/core/operations/FlipImage.mjs +++ b/src/core/operations/FlipImage.mjs @@ -51,6 +51,8 @@ class FlipImage extends Operation { const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Flipping image..."); switch (flipAxis){ case "Horizontal": image.flip(true, false); diff --git a/src/core/operations/ImageBrightnessContrast.mjs b/src/core/operations/ImageBrightnessContrast.mjs index 51c61c70..7d8eca4f 100644 --- a/src/core/operations/ImageBrightnessContrast.mjs +++ b/src/core/operations/ImageBrightnessContrast.mjs @@ -59,8 +59,16 @@ class ImageBrightnessContrast extends Operation { } const image = await jimp.read(Buffer.from(input)); - image.brightness(brightness / 100); - image.contrast(contrast / 100); + 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]; diff --git a/src/core/operations/ImageFilter.mjs b/src/core/operations/ImageFilter.mjs index 370f5e6f..b756b9f2 100644 --- a/src/core/operations/ImageFilter.mjs +++ b/src/core/operations/ImageFilter.mjs @@ -53,7 +53,8 @@ class ImageFilter extends Operation { } const image = await jimp.read(Buffer.from(input)); - + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Applying " + filterType.toLowerCase() + " filter to image..."); if (filterType === "Greyscale") { image.greyscale(); } else { diff --git a/src/core/operations/ImageOpacity.mjs b/src/core/operations/ImageOpacity.mjs index 11a364b8..090a8975 100644 --- a/src/core/operations/ImageOpacity.mjs +++ b/src/core/operations/ImageOpacity.mjs @@ -52,6 +52,8 @@ class ImageOpacity extends Operation { } const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Changing image opacity..."); image.opacity(opacity / 100); const imageBuffer = await image.getBufferAsync(jimp.MIME_PNG); diff --git a/src/core/operations/InvertImage.mjs b/src/core/operations/InvertImage.mjs index 87da0156..99de9f0f 100644 --- a/src/core/operations/InvertImage.mjs +++ b/src/core/operations/InvertImage.mjs @@ -42,6 +42,8 @@ class InvertImage extends Operation { throw new OperationError("Invalid input file format."); } const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Inverting image..."); image.invert(); const imageBuffer = await image.getBufferAsync(jimp.AUTO); return [...imageBuffer]; diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs index bbeea5c5..76947037 100644 --- a/src/core/operations/RotateImage.mjs +++ b/src/core/operations/RotateImage.mjs @@ -48,6 +48,8 @@ class RotateImage extends Operation { if (type && type.mime.indexOf("image") === 0){ const image = await jimp.read(Buffer.from(input)); + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Rotating image..."); image.rotate(degrees); const imageBuffer = await image.getBufferAsync(jimp.AUTO); return [...imageBuffer]; From 1031429550e69f63373e1cc2a5fde2118328c9b3 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 11:19:04 +0000 Subject: [PATCH 195/687] Add error handling --- src/core/operations/BlurImage.mjs | 34 ++++--- src/core/operations/ContainImage.mjs | 22 +++-- src/core/operations/CoverImage.mjs | 21 +++-- src/core/operations/CropImage.mjs | 37 +++++--- src/core/operations/DitherImage.mjs | 21 +++-- src/core/operations/FlipImage.mjs | 34 ++++--- .../operations/ImageBrightnessContrast.mjs | 33 ++++--- src/core/operations/ImageFilter.mjs | 27 ++++-- .../ImageHueSaturationLightness.mjs | 72 ++++++++------- src/core/operations/ImageOpacity.mjs | 21 +++-- src/core/operations/InvertImage.mjs | 22 +++-- src/core/operations/NormaliseImage.mjs | 91 +++++++++++++++++++ src/core/operations/ResizeImage.mjs | 36 +++++--- src/core/operations/RotateImage.mjs | 21 +++-- 14 files changed, 348 insertions(+), 144 deletions(-) create mode 100644 src/core/operations/NormaliseImage.mjs diff --git a/src/core/operations/BlurImage.mjs b/src/core/operations/BlurImage.mjs index 000f3677..fba3c927 100644 --- a/src/core/operations/BlurImage.mjs +++ b/src/core/operations/BlurImage.mjs @@ -53,21 +53,29 @@ class BlurImage extends Operation { const type = Magic.magicFileType(input); if (type && type.mime.indexOf("image") === 0){ - const image = await jimp.read(Buffer.from(input)); - - switch (blurType){ - case "Fast": - image.blur(blurAmount); - break; - case "Gaussian": - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Gaussian blurring image. This will take a while..."); - image.gaussian(blurAmount); - break; + 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 will take a while..."); + image.gaussian(blurAmount); + break; + } - const imageBuffer = await image.getBufferAsync(jimp.AUTO); - return [...imageBuffer]; + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error blurring image. (${err})`); + } } else { throw new OperationError("Invalid file type."); } diff --git a/src/core/operations/ContainImage.mjs b/src/core/operations/ContainImage.mjs index 056244df..a2da5363 100644 --- a/src/core/operations/ContainImage.mjs +++ b/src/core/operations/ContainImage.mjs @@ -106,13 +106,21 @@ class ContainImage extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - - 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]; + 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})`); + } } /** diff --git a/src/core/operations/CoverImage.mjs b/src/core/operations/CoverImage.mjs index 57258ec3..f49e08b7 100644 --- a/src/core/operations/CoverImage.mjs +++ b/src/core/operations/CoverImage.mjs @@ -106,12 +106,21 @@ class CoverImage extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - 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]; + 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})`); + } } /** diff --git a/src/core/operations/CropImage.mjs b/src/core/operations/CropImage.mjs index e29db631..7f1eabdf 100644 --- a/src/core/operations/CropImage.mjs +++ b/src/core/operations/CropImage.mjs @@ -98,22 +98,31 @@ class CropImage extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - 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); + 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]; + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error cropping image. (${err})`); + } } /** diff --git a/src/core/operations/DitherImage.mjs b/src/core/operations/DitherImage.mjs index e6856d4a..f7ef4e33 100644 --- a/src/core/operations/DitherImage.mjs +++ b/src/core/operations/DitherImage.mjs @@ -40,12 +40,21 @@ class DitherImage extends Operation { const type = Magic.magicFileType(input); if (type && type.mime.indexOf("image") === 0){ - const image = await jimp.read(Buffer.from(input)); - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Applying dither to image..."); - image.dither565(); - const imageBuffer = await image.getBufferAsync(jimp.AUTO); - return [...imageBuffer]; + 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})`); + } } else { throw new OperationError("Invalid file type."); } diff --git a/src/core/operations/FlipImage.mjs b/src/core/operations/FlipImage.mjs index 3185df9f..09791ca6 100644 --- a/src/core/operations/FlipImage.mjs +++ b/src/core/operations/FlipImage.mjs @@ -49,21 +49,29 @@ class FlipImage extends Operation { throw new OperationError("Invalid input file type."); } - const image = await jimp.read(Buffer.from(input)); - - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Flipping image..."); - switch (flipAxis){ - case "Horizontal": - image.flip(true, false); - break; - case "Vertical": - image.flip(false, true); - break; + 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]; + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error flipping image. (${err})`); + } } /** diff --git a/src/core/operations/ImageBrightnessContrast.mjs b/src/core/operations/ImageBrightnessContrast.mjs index 7d8eca4f..2f49bab7 100644 --- a/src/core/operations/ImageBrightnessContrast.mjs +++ b/src/core/operations/ImageBrightnessContrast.mjs @@ -58,20 +58,29 @@ class ImageBrightnessContrast extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - 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); + 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]; + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error adjusting image brightness / contrast. (${err})`); + } } /** diff --git a/src/core/operations/ImageFilter.mjs b/src/core/operations/ImageFilter.mjs index b756b9f2..5d7f505d 100644 --- a/src/core/operations/ImageFilter.mjs +++ b/src/core/operations/ImageFilter.mjs @@ -52,17 +52,26 @@ class ImageFilter extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Applying " + filterType.toLowerCase() + " filter to image..."); - if (filterType === "Greyscale") { - image.greyscale(); - } else { - image.sepia(); + 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]; + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error applying filter to image. (${err})`); + } } /** diff --git a/src/core/operations/ImageHueSaturationLightness.mjs b/src/core/operations/ImageHueSaturationLightness.mjs index 29293fdb..9e63a6b3 100644 --- a/src/core/operations/ImageHueSaturationLightness.mjs +++ b/src/core/operations/ImageHueSaturationLightness.mjs @@ -66,40 +66,48 @@ class ImageHueSaturationLightness extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - - if (hue !== 0) { - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Changing image hue..."); - image.colour([ - { - apply: "hue", - params: [hue] - } - ]); + let image; + try { + image = await jimp.read(Buffer.from(input)); + } catch (err) { + throw new OperationError(`Error loading image. (${err})`); } - if (saturation !== 0) { - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Changing image saturation..."); - image.colour([ - { - apply: "saturate", - params: [saturation] - } - ]); + 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})`); } - 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]; } /** diff --git a/src/core/operations/ImageOpacity.mjs b/src/core/operations/ImageOpacity.mjs index 090a8975..76a23f77 100644 --- a/src/core/operations/ImageOpacity.mjs +++ b/src/core/operations/ImageOpacity.mjs @@ -51,13 +51,22 @@ class ImageOpacity extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Changing image opacity..."); - image.opacity(opacity / 100); + 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]; + const imageBuffer = await image.getBufferAsync(jimp.MIME_PNG); + return [...imageBuffer]; + } catch (err) { + throw new OperateionError(`Error changing image opacity. (${err})`); + } } /** diff --git a/src/core/operations/InvertImage.mjs b/src/core/operations/InvertImage.mjs index 99de9f0f..c2625d9a 100644 --- a/src/core/operations/InvertImage.mjs +++ b/src/core/operations/InvertImage.mjs @@ -41,12 +41,22 @@ class InvertImage extends Operation { if (!type || type.mime.indexOf("image") !== 0) { throw new OperationError("Invalid input file format."); } - const image = await jimp.read(Buffer.from(input)); - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Inverting image..."); - image.invert(); - const imageBuffer = await image.getBufferAsync(jimp.AUTO); - return [...imageBuffer]; + + 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})`); + } } /** diff --git a/src/core/operations/NormaliseImage.mjs b/src/core/operations/NormaliseImage.mjs new file mode 100644 index 00000000..1815c7f1 --- /dev/null +++ b/src/core/operations/NormaliseImage.mjs @@ -0,0 +1,91 @@ +/** + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; +import OperationError from "../errors/OperationError"; +import Magic from "../lib/Magic"; +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 = [ + /* Example arguments. See the project wiki for full details. + { + name: "First arg", + type: "string", + value: "Don't Panic" + }, + { + name: "Second arg", + type: "number", + value: 42 + } + */ + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + async run(input, args) { + // const [firstArg, secondArg] = args; + const type = Magic.magicFileType(input); + + if (!type || type.mime.indexOf("image") !== 0){ + 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 ""; + + let dataURI = "data:"; + const type = Magic.magicFileType(data); + if (type && type.mime.indexOf("image") === 0){ + dataURI += type.mime + ";"; + } else { + throw new OperationError("Invalid file type."); + } + dataURI += "base64," + toBase64(data); + + return ""; + + } + +} + +export default NormaliseImage; diff --git a/src/core/operations/ResizeImage.mjs b/src/core/operations/ResizeImage.mjs index e1ce7d45..36b0c805 100644 --- a/src/core/operations/ResizeImage.mjs +++ b/src/core/operations/ResizeImage.mjs @@ -91,23 +91,31 @@ class ResizeImage extends Operation { throw new OperationError("Invalid file type."); } - const image = await jimp.read(Buffer.from(input)); - - if (unit === "Percent") { - width = image.getWidth() * (width / 100); - height = image.getHeight() * (height / 100); + 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]); + 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})`); } - - const imageBuffer = await image.getBufferAsync(jimp.AUTO); - return [...imageBuffer]; } /** diff --git a/src/core/operations/RotateImage.mjs b/src/core/operations/RotateImage.mjs index 76947037..b2b1e059 100644 --- a/src/core/operations/RotateImage.mjs +++ b/src/core/operations/RotateImage.mjs @@ -47,12 +47,21 @@ class RotateImage extends Operation { const type = Magic.magicFileType(input); if (type && type.mime.indexOf("image") === 0){ - const image = await jimp.read(Buffer.from(input)); - if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Rotating image..."); - image.rotate(degrees); - const imageBuffer = await image.getBufferAsync(jimp.AUTO); - return [...imageBuffer]; + 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})`); + } } else { throw new OperationError("Invalid file type."); } From 0c9db5afe9e3dff8f70f05a634326d036b15a397 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 11:36:29 +0000 Subject: [PATCH 196/687] Fix typo --- src/core/operations/ImageOpacity.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/ImageOpacity.mjs b/src/core/operations/ImageOpacity.mjs index 76a23f77..5a547992 100644 --- a/src/core/operations/ImageOpacity.mjs +++ b/src/core/operations/ImageOpacity.mjs @@ -65,7 +65,7 @@ class ImageOpacity extends Operation { const imageBuffer = await image.getBufferAsync(jimp.MIME_PNG); return [...imageBuffer]; } catch (err) { - throw new OperateionError(`Error changing image opacity. (${err})`); + throw new OperationError(`Error changing image opacity. (${err})`); } } From 21a8d0320190644148072e45ed46610fbb14824e Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 13:21:26 +0000 Subject: [PATCH 197/687] Move parsing and generation of QR codes to lib folder. Also rewrote QR code parsing to be more readable and actually error out properly. --- src/core/lib/QRCode.mjs | 90 ++++++++++++++++++++++++++ src/core/operations/GenerateQRCode.mjs | 26 +------- src/core/operations/ParseQRCode.mjs | 60 ++--------------- 3 files changed, 96 insertions(+), 80 deletions(-) create mode 100644 src/core/lib/QRCode.mjs diff --git a/src/core/lib/QRCode.mjs b/src/core/lib/QRCode.mjs new file mode 100644 index 00000000..bf134367 --- /dev/null +++ b/src/core/lib/QRCode.mjs @@ -0,0 +1,90 @@ +/** + * QR code resources + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError"; +import jsQR from "jsqr"; +import qr from "qr-image"; +import jimp from "jimp"; + +/** + * Parses a QR code image from an image + * + * @param {byteArray} input + * @param {boolean} normalise + * @returns {string} + */ +export async function parseQrCode(input, normalise) { + let image; + try { + image = await jimp.read(Buffer.from(input)); + } catch (err) { + throw new OperationError(`Error opening image. (${err})`); + } + + try { + if (normalise) { + image.rgba(false); + image.background(0xFFFFFFFF); + image.normalize(); + image.greyscale(); + } + } catch (err) { + throw new OperationError(`Error normalising iamge. (${err})`); + } + + const qrData = jsQR(image.bitmap.data, image.getWidth(), image.getHeight()); + if (qrData) { + return qrData.data; + } else { + throw new OperationError("Could not read a QR code from the image."); + } +} + +/** + * Generates a QR code from the input string + * + * @param {string} input + * @param {string} format + * @param {number} moduleSize + * @param {number} margin + * @param {string} errorCorrection + * @returns {byteArray} + */ +export function generateQrCode(input, format, moduleSize, margin, errorCorrection) { + const formats = ["SVG", "EPS", "PDF", "PNG"]; + if (!formats.includes(format.toUpperCase())) { + throw new OperationError("Unsupported QR code format."); + } + + let qrImage; + try { + qrImage = qr.imageSync(input, { + type: format, + size: moduleSize, + margin: margin, + "ec_level": errorCorrection.charAt(0).toUpperCase() + }); + } catch (err) { + throw new OperationError(`Error generating QR code. (${err})`); + } + + if (!qrImage) { + throw new OperationError("Error generating QR code."); + } + + switch (format) { + case "SVG": + case "EPS": + case "PDF": + return [...Buffer.from(qrImage)]; + case "PNG": + return [...qrImage]; + default: + throw new OperationError("Unsupported QR code format."); + } +} diff --git a/src/core/operations/GenerateQRCode.mjs b/src/core/operations/GenerateQRCode.mjs index edab6d40..4e1983e5 100644 --- a/src/core/operations/GenerateQRCode.mjs +++ b/src/core/operations/GenerateQRCode.mjs @@ -6,7 +6,7 @@ import Operation from "../Operation"; import OperationError from "../errors/OperationError"; -import qr from "qr-image"; +import { generateQrCode } from "../lib/QRCode"; import { toBase64 } from "../lib/Base64"; import Magic from "../lib/Magic"; import Utils from "../Utils"; @@ -62,29 +62,7 @@ class GenerateQRCode extends Operation { run(input, args) { const [format, size, margin, errorCorrection] = args; - // Create new QR image from the input data, and convert it to a buffer - const qrImage = qr.imageSync(input, { - type: format, - size: size, - margin: margin, - "ec_level": errorCorrection.charAt(0).toUpperCase() - }); - - if (qrImage == null) { - throw new OperationError("Error generating QR code."); - } - - switch (format) { - case "SVG": - case "EPS": - case "PDF": - return [...Buffer.from(qrImage)]; - case "PNG": - // Return the QR image buffer as a byte array - return [...qrImage]; - default: - throw new OperationError("Unsupported QR code format."); - } + return generateQrCode(input, format, size, margin, errorCorrection); } /** diff --git a/src/core/operations/ParseQRCode.mjs b/src/core/operations/ParseQRCode.mjs index 75a24d55..816b6e75 100644 --- a/src/core/operations/ParseQRCode.mjs +++ b/src/core/operations/ParseQRCode.mjs @@ -7,8 +7,7 @@ import Operation from "../Operation"; import OperationError from "../errors/OperationError"; import Magic from "../lib/Magic"; -import jsqr from "jsqr"; -import jimp from "jimp"; +import { parseQrCode } from "../lib/QRCode"; /** * Parse QR Code operation @@ -42,64 +41,13 @@ class ParseQRCode extends Operation { * @returns {string} */ async run(input, args) { - const type = Magic.magicFileType(input); const [normalise] = args; + const type = Magic.magicFileType(input); - // Make sure that the input is an image - if (type && type.mime.indexOf("image") === 0) { - let image = input; - - if (normalise) { - // Process the image to be easier to read by jsqr - // Disables the alpha channel - // Sets the image default background to white - // Normalises the image colours - // Makes the image greyscale - // Converts image to a JPEG - image = await new Promise((resolve, reject) => { - jimp.read(Buffer.from(input)) - .then(image => { - image - .rgba(false) - .background(0xFFFFFFFF) - .normalize() - .greyscale() - .getBuffer(jimp.MIME_JPEG, (error, result) => { - resolve(result); - }); - }) - .catch(err => { - reject(new OperationError("Error reading the image file.")); - }); - }); - } - - if (image instanceof OperationError) { - throw image; - } - - return new Promise((resolve, reject) => { - jimp.read(Buffer.from(image)) - .then(image => { - if (image.bitmap != null) { - const qrData = jsqr(image.bitmap.data, image.getWidth(), image.getHeight()); - if (qrData != null) { - resolve(qrData.data); - } else { - reject(new OperationError("Couldn't read a QR code from the image.")); - } - } else { - reject(new OperationError("Error reading the image file.")); - } - }) - .catch(err => { - reject(new OperationError("Error reading the image file.")); - }); - }); - } else { + if (!type || type.mime.indexOf("image") !== 0) { throw new OperationError("Invalid file type."); } - + return await parseQrCode(input, normalise); } } From 11451ac6b9f42e22a446d6c5e5e95259895dbed3 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 13:35:37 +0000 Subject: [PATCH 198/687] Add image format pattern. ("borrowed" from RenderImage) --- src/core/operations/ParseQRCode.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/core/operations/ParseQRCode.mjs b/src/core/operations/ParseQRCode.mjs index 816b6e75..d929500b 100644 --- a/src/core/operations/ParseQRCode.mjs +++ b/src/core/operations/ParseQRCode.mjs @@ -33,6 +33,14 @@ class ParseQRCode extends Operation { "value": false } ]; + this.patterns = [ + { + "match": "^(?:\\xff\\xd8\\xff|\\x89\\x50\\x4e\\x47|\\x47\\x49\\x46|.{8}\\x57\\x45\\x42\\x50|\\x42\\x4d)", + "flags": "", + "args": [false], + "useful": true + } + ]; } /** From 2b538061e940c70bc3446a9bc772a83e5cb81b2b Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 16:26:42 +0000 Subject: [PATCH 199/687] Fix fork operation not setting ingredient values correctly. --- src/core/operations/Fork.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/Fork.mjs b/src/core/operations/Fork.mjs index 27a1af96..02aba3e8 100644 --- a/src/core/operations/Fork.mjs +++ b/src/core/operations/Fork.mjs @@ -89,7 +89,7 @@ class Fork extends Operation { // Run recipe over each tranche for (i = 0; i < inputs.length; i++) { // Baseline ing values for each tranche so that registers are reset - subOpList.forEach((op, i) => { + recipe.opList.forEach((op, i) => { op.ingValues = JSON.parse(JSON.stringify(ingValues[i])); }); From d923c99975b87fc7869ea26c6c1e4ce9981becb0 Mon Sep 17 00:00:00 2001 From: j433866 Date: Thu, 7 Mar 2019 16:33:38 +0000 Subject: [PATCH 200/687] Fix same bug in subsection --- src/core/operations/Subsection.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/Subsection.mjs b/src/core/operations/Subsection.mjs index 8133d31c..548780c8 100644 --- a/src/core/operations/Subsection.mjs +++ b/src/core/operations/Subsection.mjs @@ -116,7 +116,7 @@ class Subsection extends Operation { } // Baseline ing values for each tranche so that registers are reset - subOpList.forEach((op, i) => { + recipe.opList.forEach((op, i) => { op.ingValues = JSON.parse(JSON.stringify(ingValues[i])); }); From 3e428c044ac6e6bc13b232d8fdb91f0c36875a78 Mon Sep 17 00:00:00 2001 From: j433866 Date: Fri, 8 Mar 2019 13:38:59 +0000 Subject: [PATCH 201/687] Add min values to operation args --- src/core/operations/GenerateQRCode.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/operations/GenerateQRCode.mjs b/src/core/operations/GenerateQRCode.mjs index 4e1983e5..d88eee15 100644 --- a/src/core/operations/GenerateQRCode.mjs +++ b/src/core/operations/GenerateQRCode.mjs @@ -38,12 +38,14 @@ class GenerateQRCode extends Operation { { "name": "Module size (px)", "type": "number", - "value": 5 + "value": 5, + "min": 1 }, { "name": "Margin (num modules)", "type": "number", - "value": 2 + "value": 2, + "min": 0 }, { "name": "Error correction", From 58d41f4458b4f442cf10e10b3bce9e95a7121366 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sat, 9 Mar 2019 05:38:13 +0000 Subject: [PATCH 202/687] 8.24.3 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d1cb4e1..8a35ebb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "8.24.2", + "version": "8.24.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a14af274..f8db4aa5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "8.24.2", + "version": "8.24.3", "description": "The Cyber Swiss Army Knife for encryption, encoding, compression and data analysis.", "author": "n1474335 ", "homepage": "https://gchq.github.io/CyberChef", From 84d31c1d597921ade565f30e60b887f2cc20ed4c Mon Sep 17 00:00:00 2001 From: n1474335 Date: Sat, 9 Mar 2019 06:25:27 +0000 Subject: [PATCH 203/687] Added 'Move to input' button to output file list. Improved zlib extraction efficiency. --- .eslintrc.json | 1 + src/core/Utils.mjs | 31 +++++++++++++-- src/core/lib/FileSignatures.mjs | 50 +++++++++++++----------- src/core/lib/FileType.mjs | 7 +++- src/core/operations/ExtractFiles.mjs | 8 +++- src/web/Manager.mjs | 1 + src/web/OutputWaiter.mjs | 18 +++++++++ src/web/html/index.html | 2 +- src/web/stylesheets/components/_pane.css | 4 ++ 9 files changed, 94 insertions(+), 28 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index d5e4e768..7dcb705c 100755 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -102,6 +102,7 @@ "$": false, "jQuery": false, "log": false, + "app": false, "COMPILE_TIME": false, "COMPILE_MSG": false, diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index f70e2941..8e69b020 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -832,8 +832,9 @@ class Utils { const buff = await Utils.readFile(file); const blob = new Blob( [buff], - {type: "octet/stream"} + {type: file.type || "octet/stream"} ); + const blobURL = URL.createObjectURL(blob); const html = `
@@ -1163,6 +1173,21 @@ String.prototype.count = function(chr) { }; +/** + * Wrapper for self.sendStatusMessage to handle different environments. + * + * @param {string} msg + */ +export function sendStatusMessage(msg) { + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage(msg); + else if (ENVIRONMENT_IS_WEB()) + app.alert(msg, 10000); + else if (ENVIRONMENT_IS_NODE()) + log.debug(msg); +} + + /* * Polyfills */ diff --git a/src/core/lib/FileSignatures.mjs b/src/core/lib/FileSignatures.mjs index 36e6818e..61e37b88 100644 --- a/src/core/lib/FileSignatures.mjs +++ b/src/core/lib/FileSignatures.mjs @@ -1518,26 +1518,26 @@ export function extractELF(bytes, offset) { } +// Construct required Huffman Tables +const fixedLiteralTableLengths = new Array(288); +for (let i = 0; i < fixedLiteralTableLengths.length; i++) { + fixedLiteralTableLengths[i] = + (i <= 143) ? 8 : + (i <= 255) ? 9 : + (i <= 279) ? 7 : + 8; +} +const fixedLiteralTable = buildHuffmanTable(fixedLiteralTableLengths); +const fixedDistanceTableLengths = new Array(30).fill(5); +const fixedDistanceTable = buildHuffmanTable(fixedDistanceTableLengths); +const huffmanOrder = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]; + /** * Steps through a DEFLATE stream * * @param {Stream} stream */ function parseDEFLATE(stream) { - // Construct required Huffman Tables - const fixedLiteralTableLengths = new Uint8Array(288); - for (let i = 0; i < fixedLiteralTableLengths.length; i++) { - fixedLiteralTableLengths[i] = - (i <= 143) ? 8 : - (i <= 255) ? 9 : - (i <= 279) ? 7 : - 8; - } - const fixedLiteralTable = buildHuffmanTable(fixedLiteralTableLengths); - const fixedDistanceTableLengths = new Uint8Array(30).fill(5); - const fixedDistanceTable = buildHuffmanTable(fixedDistanceTableLengths); - const huffmanOrder = new Uint8Array([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]); - // Parse DEFLATE data let finalBlock = 0; @@ -1619,6 +1619,14 @@ function parseDEFLATE(stream) { } +// Static length tables +const lengthExtraTable = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, 0, 0 +]; +const distanceExtraTable = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13 +]; + /** * Parses a Huffman Block given the literal and distance tables * @@ -1627,20 +1635,18 @@ function parseDEFLATE(stream) { * @param {Uint32Array} distTab */ function parseHuffmanBlock(stream, litTab, distTab) { - const lengthExtraTable = new Uint8Array([ - 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, 0, 0 - ]); - const distanceExtraTable = new Uint8Array([ - 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13 - ]); - let code; + let loops = 0; while ((code = readHuffmanCode(stream, litTab))) { // console.log("Code: " + code + " (" + Utils.chr(code) + ") " + Utils.bin(code)); // End of block if (code === 256) break; + // Detect probably infinite loops + if (++loops > 10000) + throw new Error("Caught in probable infinite loop while parsing Huffman Block"); + // Literal if (code < 256) continue; @@ -1657,7 +1663,7 @@ function parseHuffmanBlock(stream, litTab, distTab) { /** * Builds a Huffman table given the relevant code lengths * - * @param {Uint8Array} lengths + * @param {Array} lengths * @returns {Array} result * @returns {Uint32Array} result.table * @returns {number} result.maxCodeLength diff --git a/src/core/lib/FileType.mjs b/src/core/lib/FileType.mjs index e5d990d9..e961a76f 100644 --- a/src/core/lib/FileType.mjs +++ b/src/core/lib/FileType.mjs @@ -7,6 +7,7 @@ * */ import {FILE_SIGNATURES} from "./FileSignatures"; +import {sendStatusMessage} from "../Utils"; /** @@ -148,6 +149,7 @@ export function scanForFileTypes(buf, categories=Object.keys(FILE_SIGNATURES)) { let pos = 0; while ((pos = locatePotentialSig(buf, sig, pos)) >= 0) { if (bytesMatch(sig, buf, pos)) { + sendStatusMessage(`Found potential signature for ${filetype.name} at pos ${pos}`); foundFiles.push({ offset: pos, fileDetails: filetype @@ -249,9 +251,12 @@ export function isImage(buf) { */ export function extractFile(bytes, fileDetail, offset) { if (fileDetail.extractor) { + sendStatusMessage(`Attempting to extract ${fileDetail.name} at pos ${offset}...`); const fileData = fileDetail.extractor(bytes, offset); const ext = fileDetail.extension.split(",")[0]; - return new File([fileData], `extracted_at_0x${offset.toString(16)}.${ext}`); + return new File([fileData], `extracted_at_0x${offset.toString(16)}.${ext}`, { + type: fileDetail.mime + }); } throw new Error(`No extraction algorithm available for "${fileDetail.mime}" files`); diff --git a/src/core/operations/ExtractFiles.mjs b/src/core/operations/ExtractFiles.mjs index d2b87990..b9b260bb 100644 --- a/src/core/operations/ExtractFiles.mjs +++ b/src/core/operations/ExtractFiles.mjs @@ -62,12 +62,13 @@ class ExtractFiles extends Operation { // Extract each file that we support const files = []; + const errors = []; detectedFiles.forEach(detectedFile => { try { files.push(extractFile(bytes, detectedFile.fileDetails, detectedFile.offset)); } catch (err) { if (!ignoreFailedExtractions && err.message.indexOf("No extraction algorithm available") < 0) { - throw new OperationError( + errors.push( `Error while attempting to extract ${detectedFile.fileDetails.name} ` + `at offset ${detectedFile.offset}:\n` + `${err.message}` @@ -76,9 +77,14 @@ class ExtractFiles extends Operation { } }); + if (errors.length) { + throw new OperationError(errors.join("\n\n")); + } + return files; } + /** * Displays the files in HTML for web apps. * diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index 30cb4943..5fa0e8c1 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -173,6 +173,7 @@ class Manager { this.addDynamicListener("#output-file-download", "click", this.output.downloadFile, this.output); this.addDynamicListener("#output-file-slice i", "click", this.output.displayFileSlice, this.output); document.getElementById("show-file-overlay").addEventListener("click", this.output.showFileOverlayClick.bind(this.output)); + this.addDynamicListener(".extract-file,.extract-file i", "click", this.output.extractFileClick, this.output); // Options document.getElementById("options").addEventListener("click", this.options.optionsClick.bind(this.options)); diff --git a/src/web/OutputWaiter.mjs b/src/web/OutputWaiter.mjs index 2d93507c..0a10b8b2 100755 --- a/src/web/OutputWaiter.mjs +++ b/src/web/OutputWaiter.mjs @@ -494,6 +494,24 @@ class OutputWaiter { magicButton.setAttribute("data-original-title", "Magic!"); } + + /** + * Handler for extract file events. + * + * @param {Event} e + */ + async extractFileClick(e) { + e.preventDefault(); + e.stopPropagation(); + + const el = e.target.nodeName === "I" ? e.target.parentNode : e.target; + const blobURL = el.getAttribute("blob-url"); + const fileName = el.getAttribute("file-name"); + + const blob = await fetch(blobURL).then(r => r.blob()); + this.manager.input.loadFile(new File([blob], fileName, {type: blob.type})); + } + } export default OutputWaiter; diff --git a/src/web/html/index.html b/src/web/html/index.html index 74eb0ed8..302355d9 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -271,7 +271,7 @@ content_copy
Rotor stopsPartial plugboardDecryption preview
${setting}${stecker}${decrypt}
"; + html += "
Rotor stopsPartial plugboardDecryption preview
\n"; for (const [setting, stecker, decrypt] of output.result) { - html += `\n`; + html += `\n`; } html += "
Rotor stops Partial plugboard Decryption preview
${setting}${stecker}${decrypt}
${setting} ${stecker} ${decrypt}
"; return html; diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs index 6887bc46..03364a01 100644 --- a/src/core/operations/MultipleBombe.mjs +++ b/src/core/operations/MultipleBombe.mjs @@ -292,9 +292,9 @@ class MultipleBombe extends Operation { for (const run of output.bombeRuns) { html += `\nRotors: ${run.rotors.slice().reverse().join(", ")}\nReflector: ${run.reflector}\n`; - html += ""; + html += "
Rotor stopsPartial plugboardDecryption preview
\n"; for (const [setting, stecker, decrypt] of run.result) { - html += `\n`; + html += `\n`; } html += "
Rotor stops Partial plugboard Decryption preview
${setting}${stecker}${decrypt}
${setting} ${stecker} ${decrypt}
\n"; } diff --git a/tests/operations/tests/Bombe.mjs b/tests/operations/tests/Bombe.mjs index 9e5a79c6..b44e032c 100644 --- a/tests/operations/tests/Bombe.mjs +++ b/tests/operations/tests/Bombe.mjs @@ -11,7 +11,7 @@ TestRegister.addTests([ // Plugboard for this test is BO LC KE GA name: "Bombe: 3 rotor (self-stecker)", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, + expectedMatch: /LGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -31,7 +31,7 @@ TestRegister.addTests([ // This test produces a menu that doesn't use the first letter, which is also a good test name: "Bombe: 3 rotor (other stecker)", input: "JBYALIHDYNUAAVKBYM", - expectedMatch: /LGA<\/td>AG<\/td>QFIMUMAFKMQSKMYNGW<\/td>/, + expectedMatch: /LGA<\/td> {2}AG<\/td> {2}QFIMUMAFKMQSKMYNGW<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -50,7 +50,7 @@ TestRegister.addTests([ { name: "Bombe: crib offset", input: "AAABBYFLTHHYIJQAYBBYS", // first three chars here are faked - expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, + expectedMatch: /LGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -69,7 +69,7 @@ TestRegister.addTests([ { name: "Bombe: multiple stops", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA<\/td>TT<\/td>VFISUSGTKSTMPSUNAK<\/td>/, + expectedMatch: /LGA<\/td> {2}TT<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -88,7 +88,7 @@ TestRegister.addTests([ { name: "Bombe: checking machine", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA<\/td>TT AG BO CL EK FF HH II JJ SS YY<\/td>THISISATESTMESSAGE<\/td>/, + expectedMatch: /LGA<\/td> {2}TT AG BO CL EK FF HH II JJ SS YY<\/td> {2}THISISATESTMESSAGE<\/td>/, recipeConfig: [ { "op": "Bombe", @@ -108,7 +108,7 @@ TestRegister.addTests([ { name: "Bombe: 4 rotor", input: "LUOXGJSHGEDSRDOQQX", - expectedMatch: /LHSC<\/td>SS<\/td>HHHSSSGQUUQPKSEKWK<\/td>/, + expectedMatch: /LHSC<\/td> {2}SS<\/td> {2}HHHSSSGQUUQPKSEKWK<\/td>/, recipeConfig: [ { "op": "Bombe", diff --git a/tests/operations/tests/MultipleBombe.mjs b/tests/operations/tests/MultipleBombe.mjs index 8e2cc685..32d2db08 100644 --- a/tests/operations/tests/MultipleBombe.mjs +++ b/tests/operations/tests/MultipleBombe.mjs @@ -10,7 +10,7 @@ TestRegister.addTests([ { name: "Multi-Bombe: 3 rotor", input: "BBYFLTHHYIJQAYBBYS", - expectedMatch: /LGA<\/td>SS<\/td>VFISUSGTKSTMPSUNAK<\/td>/, + expectedMatch: /LGA<\/td> {2}SS<\/td> {2}VFISUSGTKSTMPSUNAK<\/td>/, recipeConfig: [ { "op": "Multiple Bombe", From cf32372a57e0cf4cf85c3b620979d229c1969895 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 14 Mar 2019 12:08:35 +0000 Subject: [PATCH 230/687] Added Enigma wiki article link to Enigma, Typex, Bombe and Multi-Bombe operation descriptions. --- src/core/operations/Bombe.mjs | 2 +- src/core/operations/Enigma.mjs | 2 +- src/core/operations/MultipleBombe.mjs | 2 +- src/core/operations/Typex.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/operations/Bombe.mjs b/src/core/operations/Bombe.mjs index 00d883ed..c2ea82bf 100644 --- a/src/core/operations/Bombe.mjs +++ b/src/core/operations/Bombe.mjs @@ -23,7 +23,7 @@ class Bombe extends Operation { this.name = "Bombe"; this.module = "Default"; - this.description = "Emulation of the Bombe machine used at Bletchley Park to attack Enigma, based on work by Polish and British cryptanalysts.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops or is too short, a large number of incorrect outputs will usually be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine."; + this.description = "Emulation of the Bombe machine used at Bletchley Park to attack Enigma, based on work by Polish and British cryptanalysts.

To run this you need to have a 'crib', which is some known plaintext for a chunk of the target ciphertext, and know the rotors used. (See the 'Bombe (multiple runs)' operation if you don't know the rotors.) The machine will suggest possible configurations of the Enigma. Each suggestion has the rotor start positions (left to right) and known plugboard pairs.

Choosing a crib: First, note that Enigma cannot encrypt a letter to itself, which allows you to rule out some positions for possible cribs. Secondly, the Bombe does not simulate the Enigma's middle rotor stepping. The longer your crib, the more likely a step happened within it, which will prevent the attack working. However, other than that, longer cribs are generally better. The attack produces a 'menu' which maps ciphertext letters to plaintext, and the goal is to produce 'loops': for example, with ciphertext ABC and crib CAB, we have the mappings A<->C, B<->A, and C<->B, which produces a loop A-B-C-A. The more loops, the better the crib. The operation will output this: if your menu has too few loops or is too short, a large number of incorrect outputs will usually be produced. Try a different crib. If the menu seems good but the right answer isn't produced, your crib may be wrong, or you may have overlapped the middle rotor stepping - try a different crib.

Output is not sufficient to fully decrypt the data. You will have to recover the rest of the plugboard settings by inspection. And the ring position is not taken into account: this affects when the middle rotor steps. If your output is correct for a bit, and then goes wrong, adjust the ring and start position on the right-hand rotor together until the output improves. If necessary, repeat for the middle rotor.

By default this operation runs the checking machine, a manual process to verify the quality of Bombe stops, on each stop, discarding stops which fail. If you want to see how many times the hardware actually stops for a given input, disable the checking machine.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; this.infoURL = "https://wikipedia.org/wiki/Bombe"; this.inputType = "string"; this.outputType = "JSON"; diff --git a/src/core/operations/Enigma.mjs b/src/core/operations/Enigma.mjs index 71593070..542e8281 100644 --- a/src/core/operations/Enigma.mjs +++ b/src/core/operations/Enigma.mjs @@ -22,7 +22,7 @@ class Enigma extends Operation { this.name = "Enigma"; this.module = "Default"; - this.description = "Encipher/decipher with the WW2 Enigma machine.

Enigma was used by the German military, among others, around the WW2 era as a portable cipher machine to protect sensitive military, diplomatic and commercial communications.

The standard set of German military rotors and reflectors are provided. To configure the plugboard, enter a string of connected pairs of letters, e.g. AB CD EF connects A to B, C to D, and E to F. This is also used to create your own reflectors. To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points.
This is deliberately fairly permissive with rotor placements etc compared to a real Enigma (on which, for example, a four-rotor Enigma uses only the thin reflectors and the beta or gamma rotor in the 4th slot)."; + this.description = "Encipher/decipher with the WW2 Enigma machine.

Enigma was used by the German military, among others, around the WW2 era as a portable cipher machine to protect sensitive military, diplomatic and commercial communications.

The standard set of German military rotors and reflectors are provided. To configure the plugboard, enter a string of connected pairs of letters, e.g. AB CD EF connects A to B, C to D, and E to F. This is also used to create your own reflectors. To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points.
This is deliberately fairly permissive with rotor placements etc compared to a real Enigma (on which, for example, a four-rotor Enigma uses only the thin reflectors and the beta or gamma rotor in the 4th slot).

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; this.infoURL = "https://wikipedia.org/wiki/Enigma_machine"; this.inputType = "string"; this.outputType = "string"; diff --git a/src/core/operations/MultipleBombe.mjs b/src/core/operations/MultipleBombe.mjs index 03364a01..b6a48872 100644 --- a/src/core/operations/MultipleBombe.mjs +++ b/src/core/operations/MultipleBombe.mjs @@ -53,7 +53,7 @@ class MultipleBombe extends Operation { this.name = "Multiple Bombe"; this.module = "Default"; - this.description = "Emulation of the Bombe machine used to attack Enigma. This version carries out multiple Bombe runs to handle unknown rotor configurations.

You should test your menu on the single Bombe operation before running it here. See the description of the Bombe operation for instructions on choosing a crib."; + this.description = "Emulation of the Bombe machine used to attack Enigma. This version carries out multiple Bombe runs to handle unknown rotor configurations.

You should test your menu on the single Bombe operation before running it here. See the description of the Bombe operation for instructions on choosing a crib.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; this.infoURL = "https://wikipedia.org/wiki/Bombe"; this.inputType = "string"; this.outputType = "JSON"; diff --git a/src/core/operations/Typex.mjs b/src/core/operations/Typex.mjs index 760914f5..70b5f6c3 100644 --- a/src/core/operations/Typex.mjs +++ b/src/core/operations/Typex.mjs @@ -23,7 +23,7 @@ class Typex extends Operation { this.name = "Typex"; this.module = "Default"; - this.description = "Encipher/decipher with the WW2 Typex machine.

Typex was originally built by the British Royal Air Force prior to WW2, and is based on the Enigma machine with some improvements made, including using five rotors with more stepping points and interchangeable wiring cores. It was used across the British and Commonewealth militaries. A number of later variants were produced; here we simulate a WW2 era Mark 22 Typex with plugboards for the reflector and input. Typex rotors were changed regularly and none are public: a random example set are provided.

To configure the reflector plugboard, enter a string of connected pairs of letters in the reflector box, e.g. AB CD EF connects A to B, C to D, and E to F (you'll need to connect every letter). There is also an input plugboard: unlike Enigma's plugboard, it's not restricted to pairs, so it's entered like a rotor (without stepping). To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points."; + this.description = "Encipher/decipher with the WW2 Typex machine.

Typex was originally built by the British Royal Air Force prior to WW2, and is based on the Enigma machine with some improvements made, including using five rotors with more stepping points and interchangeable wiring cores. It was used across the British and Commonewealth militaries. A number of later variants were produced; here we simulate a WW2 era Mark 22 Typex with plugboards for the reflector and input. Typex rotors were changed regularly and none are public: a random example set are provided.

To configure the reflector plugboard, enter a string of connected pairs of letters in the reflector box, e.g. AB CD EF connects A to B, C to D, and E to F (you'll need to connect every letter). There is also an input plugboard: unlike Enigma's plugboard, it's not restricted to pairs, so it's entered like a rotor (without stepping). To create your own rotor, enter the letters that the rotor maps A to Z to, in order, optionally followed by < then a list of stepping points.

More detailed descriptions of the Enigma, Typex and Bombe operations can be found here."; this.infoURL = "https://wikipedia.org/wiki/Typex"; this.inputType = "string"; this.outputType = "string"; From 33db0e666a5b2a0115118a0e6c6e0ebc94769c07 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 14 Mar 2019 12:11:41 +0000 Subject: [PATCH 231/687] Final tweaks to Bombe svg and preloader css --- src/web/static/images/bombe.svg | 4 ++-- src/web/stylesheets/preloader.css | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/web/static/images/bombe.svg b/src/web/static/images/bombe.svg index 1fd40554..a970903a 100644 --- a/src/web/static/images/bombe.svg +++ b/src/web/static/images/bombe.svg @@ -1,8 +1,8 @@ diff --git a/src/web/stylesheets/preloader.css b/src/web/stylesheets/preloader.css index 690fe5c1..288ffc28 100755 --- a/src/web/stylesheets/preloader.css +++ b/src/web/stylesheets/preloader.css @@ -160,12 +160,3 @@ transform: translate3d(0, 200px, 0); } } - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} From ef38897a010208f5311850351c71218714294a26 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 14 Mar 2019 12:20:05 +0000 Subject: [PATCH 232/687] Updated CHANGELOG --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f3eca29..0d2eca7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All major and minor version changes will be documented in this file. Details of patch-level version changes can be found in [commit messages](https://github.com/gchq/CyberChef/commits/master). +### [8.26.0] - 2019-03-09 +- Various image manipulation operations added [@j433866] | [#506] + +### [8.25.0] - 2019-03-09 +- 'Extract Files' operation added and more file formats supported [@n1474335] | [#440] + ### [8.24.0] - 2019-02-08 - 'DNS over HTTPS' operation added [@h345983745] | [#489] @@ -106,6 +112,8 @@ All major and minor version changes will be documented in this file. Details of +[8.26.0]: https://github.com/gchq/CyberChef/releases/tag/v8.26.0 +[8.25.0]: https://github.com/gchq/CyberChef/releases/tag/v8.25.0 [8.24.0]: https://github.com/gchq/CyberChef/releases/tag/v8.24.0 [8.23.1]: https://github.com/gchq/CyberChef/releases/tag/v8.23.1 [8.23.0]: https://github.com/gchq/CyberChef/releases/tag/v8.23.0 @@ -180,6 +188,7 @@ All major and minor version changes will be documented in this file. Details of [#394]: https://github.com/gchq/CyberChef/pull/394 [#428]: https://github.com/gchq/CyberChef/pull/428 [#439]: https://github.com/gchq/CyberChef/pull/439 +[#440]: https://github.com/gchq/CyberChef/pull/440 [#441]: https://github.com/gchq/CyberChef/pull/441 [#443]: https://github.com/gchq/CyberChef/pull/443 [#446]: https://github.com/gchq/CyberChef/pull/446 @@ -192,3 +201,4 @@ All major and minor version changes will be documented in this file. Details of [#468]: https://github.com/gchq/CyberChef/pull/468 [#476]: https://github.com/gchq/CyberChef/pull/476 [#489]: https://github.com/gchq/CyberChef/pull/489 +[#506]: https://github.com/gchq/CyberChef/pull/506 From c8a2a8b003a31ddf9f0860e63db068972d3f820b Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 14 Mar 2019 12:26:00 +0000 Subject: [PATCH 233/687] Updated CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2eca7e..b21944fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All major and minor version changes will be documented in this file. Details of patch-level version changes can be found in [commit messages](https://github.com/gchq/CyberChef/commits/master). +### [8.27.0] - 2019-03-14 +- 'Enigma', 'Typex', 'Bombe' and 'Multiple Bombe' operations added [@s2224834] | [#516] +- See [this wiki article](https://github.com/gchq/CyberChef/wiki/Enigma,-the-Bombe,-and-Typex) for a full explanation of these operations. +- New Bombe-style loading animation added for long-running operations [@n1474335] +- New operation argument types added: `populateMultiOption` and `argSelector` [@n1474335] + ### [8.26.0] - 2019-03-09 - Various image manipulation operations added [@j433866] | [#506] From 3ff10bfeaebad28c72ceff53f8c78ebb4ebb0755 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Thu, 14 Mar 2019 12:26:07 +0000 Subject: [PATCH 234/687] 8.27.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 425b3f76..9fd4a068 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "8.26.3", + "version": "8.27.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 453c9d96..e650b272 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cyberchef", - "version": "8.26.3", + "version": "8.27.0", "description": "The Cyber Swiss Army Knife for encryption, encoding, compression and data analysis.", "author": "n1474335 ", "homepage": "https://gchq.github.io/CyberChef", From 3ad5f889a0621bdc523452f2d912f45418933b11 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 Mar 2019 13:37:11 +0000 Subject: [PATCH 235/687] Wrote some tests, fixed imports for node --- src/core/lib/Charts.mjs | 4 +- src/core/operations/HTMLToText.mjs | 41 ++++++++++++++++++ src/core/operations/HeatmapChart.mjs | 8 +++- src/core/operations/HexDensityChart.mjs | 12 ++++-- src/core/operations/ScatterChart.mjs | 9 +++- src/core/operations/SeriesChart.mjs | 8 +++- tests/operations/index.mjs | 1 + tests/operations/tests/Charts.mjs | 55 +++++++++++++++++++++++++ 8 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 src/core/operations/HTMLToText.mjs create mode 100644 tests/operations/tests/Charts.mjs diff --git a/src/core/lib/Charts.mjs b/src/core/lib/Charts.mjs index 1b9be128..fa3e5137 100644 --- a/src/core/lib/Charts.mjs +++ b/src/core/lib/Charts.mjs @@ -1,6 +1,6 @@ /** - * @author tlwr [toby@toby.codes] - Original - * @author Matt C [me@mitt.dev] - Conversion to new format + * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] * @copyright Crown Copyright 2019 * @license Apache-2.0 */ diff --git a/src/core/operations/HTMLToText.mjs b/src/core/operations/HTMLToText.mjs new file mode 100644 index 00000000..a47ffc46 --- /dev/null +++ b/src/core/operations/HTMLToText.mjs @@ -0,0 +1,41 @@ +/** + * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import Operation from "../Operation"; + +/** + * HTML To Text operation + */ +class HTMLToText extends Operation { + + /** + * HTMLToText constructor + */ + constructor() { + super(); + + this.name = "HTML To Text"; + this.module = "Default"; + this.description = "Converts a HTML ouput from an operation to a readable string instead of being rendered in the DOM."; + this.infoURL = ""; + this.inputType = "html"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {html} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + return input; + } + +} + +export default HTMLToText; diff --git a/src/core/operations/HeatmapChart.mjs b/src/core/operations/HeatmapChart.mjs index 6620e7aa..4cde1f30 100644 --- a/src/core/operations/HeatmapChart.mjs +++ b/src/core/operations/HeatmapChart.mjs @@ -1,11 +1,12 @@ /** * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] * @copyright Crown Copyright 2019 * @license Apache-2.0 */ -import * as d3 from "d3"; -import * as nodom from "nodom"; +import * as d3temp from "d3"; +import * as nodomtemp from "nodom"; import { getScatterValues, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; @@ -13,6 +14,9 @@ import Operation from "../Operation"; import OperationError from "../errors/OperationError"; import Utils from "../Utils"; +const d3 = d3temp.default ? d3temp.default : d3temp; +const nodom = nodomtemp.default ? nodomtemp.default: nodomtemp; + /** * Heatmap chart operation */ diff --git a/src/core/operations/HexDensityChart.mjs b/src/core/operations/HexDensityChart.mjs index c9912599..6414d97a 100644 --- a/src/core/operations/HexDensityChart.mjs +++ b/src/core/operations/HexDensityChart.mjs @@ -1,17 +1,23 @@ /** * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] * @copyright Crown Copyright 2019 * @license Apache-2.0 */ -import * as d3 from "d3"; -import * as d3hexbin from "d3-hexbin"; -import * as nodom from "nodom"; +import * as d3temp from "d3"; +import * as d3hexbintemp from "d3-hexbin"; +import * as nodomtemp from "nodom"; import { getScatterValues, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; import Operation from "../Operation"; import Utils from "../Utils"; +const d3 = d3temp.default ? d3temp.default : d3temp; +const d3hexbin = d3hexbintemp.default ? d3hexbintemp.default : d3hexbintemp; +const nodom = nodomtemp.default ? nodomtemp.default: nodomtemp; + + /** * Hex Density chart operation */ diff --git a/src/core/operations/ScatterChart.mjs b/src/core/operations/ScatterChart.mjs index fa642449..e6d0ec9d 100644 --- a/src/core/operations/ScatterChart.mjs +++ b/src/core/operations/ScatterChart.mjs @@ -1,16 +1,21 @@ /** * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] * @copyright Crown Copyright 2019 * @license Apache-2.0 */ -import * as d3 from "d3"; -import * as nodom from "nodom"; +import * as d3temp from "d3"; +import * as nodomtemp from "nodom"; + import { getScatterValues, getScatterValuesWithColour, RECORD_DELIMITER_OPTIONS, COLOURS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; import Operation from "../Operation"; import Utils from "../Utils"; +const d3 = d3temp.default ? d3temp.default : d3temp; +const nodom = nodomtemp.default ? nodomtemp.default: nodomtemp; + /** * Scatter chart operation */ diff --git a/src/core/operations/SeriesChart.mjs b/src/core/operations/SeriesChart.mjs index bccbc7ed..cdae32b7 100644 --- a/src/core/operations/SeriesChart.mjs +++ b/src/core/operations/SeriesChart.mjs @@ -1,16 +1,20 @@ /** * @author tlwr [toby@toby.codes] + * @author Matt C [me@mitt.dev] * @copyright Crown Copyright 2019 * @license Apache-2.0 */ -import * as d3 from "d3"; -import * as nodom from "nodom"; +import * as d3temp from "d3"; +import * as nodomtemp from "nodom"; import { getSeriesValues, RECORD_DELIMITER_OPTIONS, FIELD_DELIMITER_OPTIONS } from "../lib/Charts"; import Operation from "../Operation"; import Utils from "../Utils"; +const d3 = d3temp.default ? d3temp.default : d3temp; +const nodom = nodomtemp.default ? nodomtemp.default: nodomtemp; + /** * Series chart operation */ diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index fb68ed9c..817529c8 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -33,6 +33,7 @@ import "./tests/BitwiseOp"; import "./tests/ByteRepr"; import "./tests/CartesianProduct"; import "./tests/CharEnc"; +import "./tests/Charts"; import "./tests/Checksum"; import "./tests/Ciphers"; import "./tests/Code"; diff --git a/tests/operations/tests/Charts.mjs b/tests/operations/tests/Charts.mjs new file mode 100644 index 00000000..3bd5c4fd --- /dev/null +++ b/tests/operations/tests/Charts.mjs @@ -0,0 +1,55 @@ +/** + * Chart tests. + * + * @author Matt C [me@mitt.dev] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ +import TestRegister from "../TestRegister"; + +TestRegister.addTests([ + { + name: "Scatter chart", + input: "100 100\n200 200\n300 300\n400 400\n500 500", + expectedMatch: /^ Date: Thu, 14 Mar 2019 16:08:25 +0000 Subject: [PATCH 236/687] Updated CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b21944fe..11a18d86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ All major and minor version changes will be documented in this file. Details of +[8.27.0]: https://github.com/gchq/CyberChef/releases/tag/v8.27.0 [8.26.0]: https://github.com/gchq/CyberChef/releases/tag/v8.26.0 [8.25.0]: https://github.com/gchq/CyberChef/releases/tag/v8.25.0 [8.24.0]: https://github.com/gchq/CyberChef/releases/tag/v8.24.0 From 2019ae43d7fe12956b5145d11a68713284039782 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Thu, 14 Mar 2019 16:33:09 +0000 Subject: [PATCH 237/687] File shim now translates correctly --- package.json | 2 +- src/core/Dish.mjs | 2 -- src/core/Utils.mjs | 11 +++++------ src/core/dishTranslationTypes/DishFile.mjs | 11 ++--------- src/core/dishTranslationTypes/DishString.mjs | 2 -- src/core/operations/Tar.mjs | 2 -- src/node/File.mjs | 12 ++++++------ 7 files changed, 14 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index caf10d64..c300d179 100644 --- a/package.json +++ b/package.json @@ -144,11 +144,11 @@ "build": "grunt prod", "node": "NODE_ENV=development grunt node", "node-prod": "NODE_ENV=production grunt node", + "repl": "grunt node && node build/node/CyberChef-repl.js", "test": "grunt test", "testui": "grunt testui", "docs": "grunt docs", "lint": "grunt lint", - "repl": "node --experimental-modules --no-warnings src/node/repl-index.mjs", "newop": "node --experimental-modules src/core/config/scripts/newOperation.mjs", "postinstall": "[ -f node_modules/crypto-api/src/crypto-api.mjs ] || npx j2m node_modules/crypto-api/src/crypto-api.js" } diff --git a/src/core/Dish.mjs b/src/core/Dish.mjs index 104bbc2b..452be80d 100755 --- a/src/core/Dish.mjs +++ b/src/core/Dish.mjs @@ -336,13 +336,11 @@ class Dish { // Node environment => translate is sync if (Utils.isNode()) { - console.log('Running in node'); this._toByteArray(); this._fromByteArray(toType, notUTF8); // Browser environment => translate is async } else { - console.log('Running in browser'); return new Promise((resolve, reject) => { this._toByteArray() .then(() => this.type = Dish.BYTE_ARRAY) diff --git a/src/core/Utils.mjs b/src/core/Utils.mjs index b61357de..979b6482 100755 --- a/src/core/Utils.mjs +++ b/src/core/Utils.mjs @@ -472,7 +472,6 @@ class Utils { const str = Utils.byteArrayToChars(byteArray); try { const utf8Str = utf8.decode(str); - if (str.length !== utf8Str.length) { if (ENVIRONMENT_IS_WORKER()) { self.setOption("attemptHighlight", false); @@ -966,12 +965,12 @@ class Utils { if (!Utils.isNode()) { throw new TypeError("Browser environment cannot support readFileSync"); } + let bytes = []; + for (const byte of file.data.values()) { + bytes = bytes.concat(byte); + } - console.log('readFileSync:'); - console.log(file); - console.log(Buffer.from(file.data).toString()); - - return Buffer.from(file.data).buffer; + return bytes; } diff --git a/src/core/dishTranslationTypes/DishFile.mjs b/src/core/dishTranslationTypes/DishFile.mjs index b04bd462..9e1df730 100644 --- a/src/core/dishTranslationTypes/DishFile.mjs +++ b/src/core/dishTranslationTypes/DishFile.mjs @@ -19,12 +19,7 @@ class DishFile extends DishTranslationType { static toByteArray() { DishFile.checkForValue(this.value); if (Utils.isNode()) { - console.log('toByteArray original value:'); - console.log(this.value); - // this.value = Utils.readFileSync(this.value); - this.value = Array.prototype.slice.call(Utils.readFileSync(this.value)); - console.log('toByteArray value:'); - console.log(this.value); + this.value = Utils.readFileSync(this.value); } else { return new Promise((resolve, reject) => { Utils.readFile(this.value) @@ -42,9 +37,7 @@ class DishFile extends DishTranslationType { */ static fromByteArray() { DishFile.checkForValue(this.value); - this.value = new File(this.value, "unknown"); - console.log('from Byte array'); - console.log(this.value); + this.value = new File(this.value, "file.txt"); } } diff --git a/src/core/dishTranslationTypes/DishString.mjs b/src/core/dishTranslationTypes/DishString.mjs index 40b23001..78c273c6 100644 --- a/src/core/dishTranslationTypes/DishString.mjs +++ b/src/core/dishTranslationTypes/DishString.mjs @@ -17,10 +17,8 @@ class DishString extends DishTranslationType { * convert the given value to a ByteArray */ static toByteArray() { - console.log('string to byte array'); DishString.checkForValue(this.value); this.value = this.value ? Utils.strToByteArray(this.value) : []; - console.log(this.value); } /** diff --git a/src/core/operations/Tar.mjs b/src/core/operations/Tar.mjs index 10748340..84674bff 100644 --- a/src/core/operations/Tar.mjs +++ b/src/core/operations/Tar.mjs @@ -132,8 +132,6 @@ class Tar extends Operation { tarball.writeBytes(input); tarball.writeEndBlocks(); - console.log('Tar bytes'); - console.log(tarball.bytes); return new File([new Uint8Array(tarball.bytes)], args[0]); } diff --git a/src/node/File.mjs b/src/node/File.mjs index 938c8fd4..33c0dc73 100644 --- a/src/node/File.mjs +++ b/src/node/File.mjs @@ -19,21 +19,21 @@ class File { /** * Constructor * + * https://w3c.github.io/FileAPI/#file-constructor + * * @param {String|Array|ArrayBuffer|Buffer} bits - file content * @param {String} name (optional) - file name * @param {Object} stats (optional) - file stats e.g. lastModified */ constructor(data, name="", stats={}) { - // Look at File API definition to see how to handle this. - this.data = Buffer.from(data[0]); + const buffers = data.map(d => Buffer.from(d)); + const totalLength = buffers.reduce((p, c) => p + c.length, 0); + this.data = Buffer.concat(buffers, totalLength); + this.name = name; this.lastModified = stats.lastModified || Date.now(); this.type = stats.type || mime.getType(this.name); - console.log('File constructor'); - console.log(typeof data); - console.log(data); - console.log(this.data); } /** From b8cb7e9ba828fe4e9a15c0b9d8f5ffa4d5b2d147 Mon Sep 17 00:00:00 2001 From: d98762625 Date: Thu, 14 Mar 2019 17:54:06 +0000 Subject: [PATCH 238/687] add tests for File and test based operations. Only unzip to go --- src/node/File.mjs | 8 +++++ src/node/config/excludedOperations.mjs | 6 ---- tests/node/index.mjs | 1 + tests/node/tests/File.mjs | 20 +++++++++++ tests/node/tests/nodeApi.mjs | 2 +- tests/node/tests/ops.mjs | 46 ++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/node/tests/File.mjs diff --git a/src/node/File.mjs b/src/node/File.mjs index 33c0dc73..1d0234ae 100644 --- a/src/node/File.mjs +++ b/src/node/File.mjs @@ -42,6 +42,14 @@ class File { get size() { return this.data.length; } + + /** + * Return lastModified as Date + */ + get lastModifiedDate() { + return new Date(this.lastModified); + } + } export default File; diff --git a/src/node/config/excludedOperations.mjs b/src/node/config/excludedOperations.mjs index 53cdfa95..e4ec6390 100644 --- a/src/node/config/excludedOperations.mjs +++ b/src/node/config/excludedOperations.mjs @@ -13,12 +13,6 @@ export default [ "Label", "Comment", - // Exclude file ops until HTML5 File Object can be mimicked - // "Tar", - // "Untar", - "Unzip", - "Zip", - // esprima doesn't work in .mjs "JavaScriptBeautify", "JavaScriptMinify", diff --git a/tests/node/index.mjs b/tests/node/index.mjs index 112525cc..c78a2d9c 100644 --- a/tests/node/index.mjs +++ b/tests/node/index.mjs @@ -30,6 +30,7 @@ global.ENVIRONMENT_IS_WEB = function() { import TestRegister from "../lib/TestRegister"; import "./tests/nodeApi"; import "./tests/ops"; +import "./tests/File"; const testStatus = { allTestsPassing: true, diff --git a/tests/node/tests/File.mjs b/tests/node/tests/File.mjs new file mode 100644 index 00000000..dc9ddffc --- /dev/null +++ b/tests/node/tests/File.mjs @@ -0,0 +1,20 @@ +import assert from "assert"; +import it from "../assertionHandler"; +import TestRegister from "../../lib/TestRegister"; +import File from "../../../src/node/File"; + +TestRegister.addApiTests([ + it("File: should exist", () => { + assert(File); + }), + + it("File: Should have same properties as DOM File object", () => { + const uint8Array = new Uint8Array(Buffer.from("hello")); + const file = new File([uint8Array], "name.txt"); + assert.equal(file.name, "name.txt"); + assert(typeof file.lastModified, "number"); + assert(file.lastModifiedDate instanceof Date); + assert.equal(file.size, uint8Array.length); + assert.equal(file.type, "text/plain"); + }), +]); diff --git a/tests/node/tests/nodeApi.mjs b/tests/node/tests/nodeApi.mjs index 11361893..2bd07231 100644 --- a/tests/node/tests/nodeApi.mjs +++ b/tests/node/tests/nodeApi.mjs @@ -387,7 +387,7 @@ TestRegister.addApiTests([ it("Operation arguments: should be accessible from operation object if op has array arg", () => { assert.ok(chef.toCharcode.argOptions); - assert.equal(chef.unzip.argOptions, undefined); + assert.deepEqual(chef.unzip.argOptions, {}); }), it("Operation arguments: should have key for each array-based argument in operation", () => { diff --git a/tests/node/tests/ops.mjs b/tests/node/tests/ops.mjs index 9f621cec..8952cfde 100644 --- a/tests/node/tests/ops.mjs +++ b/tests/node/tests/ops.mjs @@ -35,6 +35,9 @@ import { } from "../../../src/node/index"; import chef from "../../../src/node/index"; import TestRegister from "../../lib/TestRegister"; +import File from "../../../src/node/File"; + +global.File = File; TestRegister.addApiTests([ @@ -971,5 +974,48 @@ ExifImageWidth: 57 ExifImageHeight: 57`); }), + it("Tar", () => { + const tarred = chef.tar("some file content", { + filename: "test.txt" + }); + assert.strictEqual(tarred.type, 7); + assert.strictEqual(tarred.value.size, 2048); + assert.strictEqual(tarred.value.data.toString().substr(0, 8), "test.txt"); + }), + + it("Untar", () => { + const tarred = chef.tar("some file content", { + filename: "filename.txt", + }); + const untarred = chef.untar(tarred); + assert.strictEqual(untarred.type, 8); + assert.strictEqual(untarred.value.length, 1); + assert.strictEqual(untarred.value[0].name, "filename.txt"); + assert.strictEqual(untarred.value[0].data.toString(), "some file content"); + }), + + it("Zip", () => { + const zipped = chef.zip("some file content", { + filename: "sample.zip", + comment: "added", + operaringSystem: "Unix", + }); + + assert.strictEqual(zipped.type, 7); + assert.equal(zipped.value.data.toString().indexOf("sample.zip"), 30); + assert.equal(zipped.value.data.toString().indexOf("added"), 122); + }), + + // it("Unzip", () => { + // const zipped = chef.zip("some file content", { + // filename: "zipped.zip", + // comment: "zippy", + // }); + // const unzipped = chef.unzip(zipped); + + // assert.equal(unzipped.type, 8); + // assert.equal(unzipped.value = "zipped.zip"); + // }), + ]); From a5703cb4f151a5d75f6bee5cdc6af77463f61529 Mon Sep 17 00:00:00 2001 From: n1474335 Date: Fri, 15 Mar 2019 15:17:15 +0000 Subject: [PATCH 239/687] Updated CHANGELOG --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11a18d86..06a5eb63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -157,6 +157,7 @@ All major and minor version changes will be documented in this file. Details of [@j433866]: https://github.com/j433866 [@GCHQ77703]: https://github.com/GCHQ77703 [@h345983745]: https://github.com/h345983745 +[@s2224834]: https://github.com/s2224834 [@artemisbot]: https://github.com/artemisbot [@picapi]: https://github.com/picapi [@Dachande663]: https://github.com/Dachande663 @@ -209,3 +210,4 @@ All major and minor version changes will be documented in this file. Details of [#476]: https://github.com/gchq/CyberChef/pull/476 [#489]: https://github.com/gchq/CyberChef/pull/489 [#506]: https://github.com/gchq/CyberChef/pull/506 +[#516]: https://github.com/gchq/CyberChef/pull/516 From 8e74acbf3e56a2e00b7f9abac6559bd91352d929 Mon Sep 17 00:00:00 2001 From: j433866 Date: Mon, 18 Mar 2019 09:43:37 +0000 Subject: [PATCH 240/687] Add opaque background option --- src/core/operations/ContainImage.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/core/operations/ContainImage.mjs b/src/core/operations/ContainImage.mjs index c6df81ef..4cb7cfdf 100644 --- a/src/core/operations/ContainImage.mjs +++ b/src/core/operations/ContainImage.mjs @@ -72,6 +72,11 @@ class ContainImage extends Operation { "Bezier" ], defaultIndex: 1 + }, + { + name: "Opaque background", + type: "boolean", + value: true } ]; } @@ -82,7 +87,7 @@ class ContainImage extends Operation { * @returns {byteArray} */ async run(input, args) { - const [width, height, hAlign, vAlign, alg] = args; + const [width, height, hAlign, vAlign, alg, opaqueBg] = args; const resizeMap = { "Nearest Neighbour": jimp.RESIZE_NEAREST_NEIGHBOR, @@ -115,6 +120,13 @@ class ContainImage extends Operation { if (ENVIRONMENT_IS_WORKER()) self.sendStatusMessage("Containing image..."); image.contain(width, height, alignMap[hAlign] | alignMap[vAlign], resizeMap[alg]); + + if (opaqueBg) { + const newImage = await jimp.read(width, height, 0x000000FF); + newImage.blit(image, 0, 0); + image = newImage; + } + const imageBuffer = await image.getBufferAsync(jimp.AUTO); return [...imageBuffer]; } catch (err) { From b3d92b04cb4a2e2bcd5c30ad20880aa64015811c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 19 Mar 2019 11:24:29 +0000 Subject: [PATCH 241/687] Updated nodom dependency to upstream --- package-lock.json | 27 +++++++++++++++++++-------- package.json | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5026aec..fca640e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5438,12 +5438,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5458,17 +5460,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -5585,7 +5590,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -5597,6 +5603,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5611,6 +5618,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -5722,7 +5730,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -5855,6 +5864,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -9253,8 +9263,9 @@ } }, "nodom": { - "version": "github:artemisbot/nodom#0071b2fa25cbc74e14c7d911cda9b03ea26eac7b", - "from": "github:artemisbot/nodom" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/nodom/-/nodom-2.2.0.tgz", + "integrity": "sha512-+W3jlsobV3NNkO15xQXkWoboeq1RPa/SKi8NMHmWF33SCMX4ALcM5dpPLEnUs69Gu+uZoCX9wcWXy866LXvd8w==" }, "nomnom": { "version": "1.5.2", diff --git a/package.json b/package.json index 61e9d4ed..dda5a279 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "ngeohash": "^0.6.3", "node-forge": "^0.7.6", "node-md6": "^0.1.0", - "nodom": "github:artemisbot/nodom", + "nodom": "^2.2.0", "notepack.io": "^2.2.0", "nwmatcher": "^1.4.4", "otp": "^0.1.3", From ce72acdd613cd281da622e0c3bfe71338ee06f51 Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Mar 2019 13:53:09 +0000 Subject: [PATCH 242/687] Add 'add text to image' operation. Included variants of the Roboto fonts as bitmap fonts for jimp. Changed webpack config to import the font files. --- src/core/config/Categories.json | 3 +- src/core/operations/AddTextToImage.mjs | 257 +++++++++ .../static/fonts/bmfonts/Roboto72White.fnt | 485 +++++++++++++++++ .../static/fonts/bmfonts/Roboto72White.png | Bin 0 -> 52730 bytes .../fonts/bmfonts/RobotoBlack72White.fnt | 488 +++++++++++++++++ .../fonts/bmfonts/RobotoBlack72White.png | Bin 0 -> 50192 bytes .../fonts/bmfonts/RobotoMono72White.fnt | 103 ++++ .../fonts/bmfonts/RobotoMono72White.png | Bin 0 -> 52580 bytes .../fonts/bmfonts/RobotoSlab72White.fnt | 492 ++++++++++++++++++ .../fonts/bmfonts/RobotoSlab72White.png | Bin 0 -> 54282 bytes webpack.config.js | 2 +- 11 files changed, 1828 insertions(+), 2 deletions(-) create mode 100644 src/core/operations/AddTextToImage.mjs create mode 100644 src/web/static/fonts/bmfonts/Roboto72White.fnt create mode 100644 src/web/static/fonts/bmfonts/Roboto72White.png create mode 100644 src/web/static/fonts/bmfonts/RobotoBlack72White.fnt create mode 100644 src/web/static/fonts/bmfonts/RobotoBlack72White.png create mode 100644 src/web/static/fonts/bmfonts/RobotoMono72White.fnt create mode 100644 src/web/static/fonts/bmfonts/RobotoMono72White.png create mode 100644 src/web/static/fonts/bmfonts/RobotoSlab72White.fnt create mode 100644 src/web/static/fonts/bmfonts/RobotoSlab72White.png diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 92f74212..4bd40aa4 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -376,7 +376,8 @@ "Cover Image", "Image Hue/Saturation/Lightness", "Sharpen Image", - "Convert image format" + "Convert Image Format", + "Add Text To Image" ] }, { diff --git a/src/core/operations/AddTextToImage.mjs b/src/core/operations/AddTextToImage.mjs new file mode 100644 index 00000000..f8ee3485 --- /dev/null +++ b/src/core/operations/AddTextToImage.mjs @@ -0,0 +1,257 @@ +/** + * @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"; + +/** + * Add Text To Image operation + */ +class AddTextToImage extends Operation { + + /** + * AddTextToImage constructor + */ + constructor() { + super(); + + this.name = "Add Text To Image"; + this.module = "Image"; + this.description = "Adds text onto an image.

Text can be horizontally or vertically aligned, or the position can be manually specified.
Variants of the Roboto font face are available in any size or colour.

Note: This may cause a degradation in image quality, especially when using font sizes larger than 72."; + this.infoURL = ""; + this.inputType = "byteArray"; + this.outputType = "byteArray"; + this.presentType = "html"; + this.args = [ + { + name: "Text", + type: "string", + value: "" + }, + { + name: "Horizontal align", + type: "option", + value: ["None", "Left", "Center", "Right"] + }, + { + name: "Vertical align", + type: "option", + value: ["None", "Top", "Middle", "Bottom"] + }, + { + name: "X position", + type: "number", + value: 0 + }, + { + name: "Y position", + type: "number", + value: 0 + }, + { + name: "Size", + type: "number", + value: 32, + min: 8 + }, + { + name: "Font face", + type: "option", + value: [ + "Roboto", + "Roboto Black", + "Roboto Mono", + "Roboto Slab" + ] + }, + { + name: "Red", + type: "number", + value: 255, + min: 0, + max: 255 + }, + { + name: "Green", + type: "number", + value: 255, + min: 0, + max: 255 + }, + { + name: "Blue", + type: "number", + value: 255, + min: 0, + max: 255 + }, + { + name: "Alpha", + type: "number", + value: 255, + min: 0, + max: 255 + } + ]; + } + + /** + * @param {byteArray} input + * @param {Object[]} args + * @returns {byteArray} + */ + async run(input, args) { + const text = args[0], + hAlign = args[1], + vAlign = args[2], + size = args[5], + fontFace = args[6], + red = args[7], + green = args[8], + blue = args[9], + alpha = args[10]; + + let xPos = args[3], + yPos = args[4]; + + 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("Adding text to image..."); + + const fontsMap = { + "Roboto": await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/Roboto72White.fnt"), + "Roboto Black": await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoBlack72White.fnt"), + "Roboto Mono": await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoMono72White.fnt"), + "Roboto Slab": await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoSlab72White.fnt") + }; + + // Make Webpack load the png font images + await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/Roboto72White.png"); + await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoSlab72White.png"); + await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoMono72White.png"); + await import(/* webpackMode: "eager" */ "../../web/static/fonts/bmfonts/RobotoBlack72White.png"); + + const font = fontsMap[fontFace]; + + // LoadFont needs an absolute url, so append the font name to self.docURL + const jimpFont = await jimp.loadFont(self.docURL + "/" + font.default); + + jimpFont.pages.forEach(function(page) { + if (page.bitmap) { + // Adjust the RGB values of the image pages to change the font colour. + const pageWidth = page.bitmap.width; + const pageHeight = page.bitmap.height; + for (let ix = 0; ix < pageWidth; ix++) { + for (let iy = 0; iy < pageHeight; iy++) { + const idx = (iy * pageWidth + ix) << 2; + + const newRed = page.bitmap.data[idx] - (255 - red); + const newGreen = page.bitmap.data[idx + 1] - (255 - green); + const newBlue = page.bitmap.data[idx + 2] - (255 - blue); + const newAlpha = page.bitmap.data[idx + 3] - (255 - alpha); + + // Make sure the bitmap values don't go below 0 as that makes jimp very unhappy + page.bitmap.data[idx] = (newRed > 0) ? newRed : 0; + page.bitmap.data[idx + 1] = (newGreen > 0) ? newGreen : 0; + page.bitmap.data[idx + 2] = (newBlue > 0) ? newBlue : 0; + page.bitmap.data[idx + 3] = (newAlpha > 0) ? newAlpha : 0; + } + } + } + }); + + // Scale the image to a factor of 72, so we can print the text at any size + const scaleFactor = 72 / size; + if (size !== 72) { + // Use bicubic for decreasing size + if (size > 72) { + image.scale(scaleFactor, jimp.RESIZE_BICUBIC); + } else { + image.scale(scaleFactor, jimp.RESIZE_BILINEAR); + } + } + + // If using the alignment options, calculate the pixel values AFTER the image has been scaled + switch (hAlign) { + case "Left": + xPos = 0; + break; + case "Center": + xPos = (image.getWidth() / 2) - (jimp.measureText(jimpFont, text) / 2); + break; + case "Right": + xPos = image.getWidth() - jimp.measureText(jimpFont, text); + break; + default: + // Adjust x position for the scaled image + xPos = xPos * scaleFactor; + } + + switch (vAlign) { + case "Top": + yPos = 0; + break; + case "Middle": + yPos = (image.getHeight() / 2) - (jimp.measureTextHeight(jimpFont, text) / 2); + break; + case "Bottom": + yPos = image.getHeight() - jimp.measureTextHeight(jimpFont, text); + break; + default: + // Adjust y position for the scaled image + yPos = yPos * scaleFactor; + } + + image.print(jimpFont, xPos, yPos, text); + + if (size !== 72) { + if (size > 72) { + image.scale(1 / scaleFactor, jimp.RESIZE_BILINEAR); + } else { + image.scale(1 / scaleFactor, jimp.RESIZE_BICUBIC); + } + } + + const imageBuffer = await image.getBufferAsync(jimp.AUTO); + return [...imageBuffer]; + } catch (err) { + throw new OperationError(`Error adding text 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 ``; + } + +} + +export default AddTextToImage; diff --git a/src/web/static/fonts/bmfonts/Roboto72White.fnt b/src/web/static/fonts/bmfonts/Roboto72White.fnt new file mode 100644 index 00000000..fd186892 --- /dev/null +++ b/src/web/static/fonts/bmfonts/Roboto72White.fnt @@ -0,0 +1,485 @@ +info face="Roboto" size=72 bold=0 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=1,1,1,1 spacing=-2,-2 +common lineHeight=85 base=67 scaleW=512 scaleH=512 pages=1 packed=0 +page id=0 file="images/Roboto72White.png" +chars count=98 +char id=0 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=66 xadvance=0 page=0 chnl=0 +char id=10 x=0 y=0 width=70 height=99 xoffset=2 yoffset=-11 xadvance=74 page=0 chnl=0 +char id=32 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=66 xadvance=18 page=0 chnl=0 +char id=33 x=493 y=99 width=10 height=55 xoffset=5 yoffset=14 xadvance=19 page=0 chnl=0 +char id=34 x=446 y=319 width=16 height=19 xoffset=4 yoffset=12 xadvance=23 page=0 chnl=0 +char id=35 x=204 y=265 width=41 height=54 xoffset=3 yoffset=14 xadvance=44 page=0 chnl=0 +char id=36 x=269 y=0 width=35 height=69 xoffset=3 yoffset=6 xadvance=40 page=0 chnl=0 +char id=37 x=31 y=155 width=48 height=56 xoffset=3 yoffset=13 xadvance=53 page=0 chnl=0 +char id=38 x=79 y=155 width=43 height=56 xoffset=3 yoffset=13 xadvance=45 page=0 chnl=0 +char id=39 x=503 y=99 width=7 height=19 xoffset=3 yoffset=12 xadvance=13 page=0 chnl=0 +char id=40 x=70 y=0 width=21 height=78 xoffset=4 yoffset=7 xadvance=25 page=0 chnl=0 +char id=41 x=91 y=0 width=22 height=78 xoffset=-1 yoffset=7 xadvance=25 page=0 chnl=0 +char id=42 x=342 y=319 width=32 height=32 xoffset=-1 yoffset=14 xadvance=31 page=0 chnl=0 +char id=43 x=242 y=319 width=37 height=40 xoffset=2 yoffset=23 xadvance=41 page=0 chnl=0 +char id=44 x=433 y=319 width=13 height=21 xoffset=-1 yoffset=58 xadvance=14 page=0 chnl=0 +char id=45 x=27 y=360 width=19 height=8 xoffset=0 yoffset=41 xadvance=19 page=0 chnl=0 +char id=46 x=17 y=360 width=10 height=11 xoffset=4 yoffset=58 xadvance=19 page=0 chnl=0 +char id=47 x=355 y=0 width=30 height=58 xoffset=-1 yoffset=14 xadvance=30 page=0 chnl=0 +char id=48 x=449 y=99 width=34 height=56 xoffset=3 yoffset=13 xadvance=40 page=0 chnl=0 +char id=49 x=474 y=211 width=22 height=54 xoffset=5 yoffset=14 xadvance=40 page=0 chnl=0 +char id=50 x=195 y=155 width=37 height=55 xoffset=2 yoffset=13 xadvance=41 page=0 chnl=0 +char id=51 x=379 y=99 width=35 height=56 xoffset=2 yoffset=13 xadvance=40 page=0 chnl=0 +char id=52 x=128 y=265 width=39 height=54 xoffset=1 yoffset=14 xadvance=41 page=0 chnl=0 +char id=53 x=232 y=155 width=35 height=55 xoffset=4 yoffset=14 xadvance=40 page=0 chnl=0 +char id=54 x=267 y=155 width=35 height=55 xoffset=4 yoffset=14 xadvance=41 page=0 chnl=0 +char id=55 x=167 y=265 width=37 height=54 xoffset=2 yoffset=14 xadvance=41 page=0 chnl=0 +char id=56 x=414 y=99 width=35 height=56 xoffset=3 yoffset=13 xadvance=40 page=0 chnl=0 +char id=57 x=302 y=155 width=34 height=55 xoffset=3 yoffset=13 xadvance=41 page=0 chnl=0 +char id=58 x=495 y=265 width=10 height=41 xoffset=4 yoffset=28 xadvance=18 page=0 chnl=0 +char id=59 x=496 y=211 width=13 height=52 xoffset=0 yoffset=28 xadvance=15 page=0 chnl=0 +char id=60 x=279 y=319 width=31 height=35 xoffset=2 yoffset=27 xadvance=37 page=0 chnl=0 +char id=61 x=402 y=319 width=31 height=23 xoffset=4 yoffset=31 xadvance=39 page=0 chnl=0 +char id=62 x=310 y=319 width=32 height=35 xoffset=4 yoffset=27 xadvance=38 page=0 chnl=0 +char id=63 x=0 y=155 width=31 height=56 xoffset=2 yoffset=13 xadvance=34 page=0 chnl=0 +char id=64 x=210 y=0 width=59 height=69 xoffset=3 yoffset=15 xadvance=65 page=0 chnl=0 +char id=65 x=336 y=155 width=49 height=54 xoffset=-1 yoffset=14 xadvance=47 page=0 chnl=0 +char id=66 x=385 y=155 width=37 height=54 xoffset=5 yoffset=14 xadvance=45 page=0 chnl=0 +char id=67 x=0 y=99 width=42 height=56 xoffset=3 yoffset=13 xadvance=46 page=0 chnl=0 +char id=68 x=422 y=155 width=39 height=54 xoffset=5 yoffset=14 xadvance=47 page=0 chnl=0 +char id=69 x=461 y=155 width=35 height=54 xoffset=5 yoffset=14 xadvance=41 page=0 chnl=0 +char id=70 x=0 y=211 width=34 height=54 xoffset=5 yoffset=14 xadvance=40 page=0 chnl=0 +char id=71 x=42 y=99 width=42 height=56 xoffset=3 yoffset=13 xadvance=49 page=0 chnl=0 +char id=72 x=34 y=211 width=41 height=54 xoffset=5 yoffset=14 xadvance=51 page=0 chnl=0 +char id=73 x=496 y=155 width=9 height=54 xoffset=5 yoffset=14 xadvance=19 page=0 chnl=0 +char id=74 x=122 y=155 width=34 height=55 xoffset=1 yoffset=14 xadvance=40 page=0 chnl=0 +char id=75 x=75 y=211 width=41 height=54 xoffset=5 yoffset=14 xadvance=45 page=0 chnl=0 +char id=76 x=116 y=211 width=33 height=54 xoffset=5 yoffset=14 xadvance=39 page=0 chnl=0 +char id=77 x=149 y=211 width=53 height=54 xoffset=5 yoffset=14 xadvance=63 page=0 chnl=0 +char id=78 x=202 y=211 width=41 height=54 xoffset=5 yoffset=14 xadvance=51 page=0 chnl=0 +char id=79 x=84 y=99 width=43 height=56 xoffset=3 yoffset=13 xadvance=49 page=0 chnl=0 +char id=80 x=243 y=211 width=39 height=54 xoffset=5 yoffset=14 xadvance=45 page=0 chnl=0 +char id=81 x=304 y=0 width=44 height=64 xoffset=3 yoffset=13 xadvance=49 page=0 chnl=0 +char id=82 x=282 y=211 width=40 height=54 xoffset=5 yoffset=14 xadvance=45 page=0 chnl=0 +char id=83 x=127 y=99 width=39 height=56 xoffset=2 yoffset=13 xadvance=43 page=0 chnl=0 +char id=84 x=322 y=211 width=42 height=54 xoffset=1 yoffset=14 xadvance=44 page=0 chnl=0 +char id=85 x=156 y=155 width=39 height=55 xoffset=4 yoffset=14 xadvance=47 page=0 chnl=0 +char id=86 x=364 y=211 width=47 height=54 xoffset=-1 yoffset=14 xadvance=46 page=0 chnl=0 +char id=87 x=411 y=211 width=63 height=54 xoffset=1 yoffset=14 xadvance=64 page=0 chnl=0 +char id=88 x=0 y=265 width=44 height=54 xoffset=1 yoffset=14 xadvance=45 page=0 chnl=0 +char id=89 x=44 y=265 width=45 height=54 xoffset=-1 yoffset=14 xadvance=43 page=0 chnl=0 +char id=90 x=89 y=265 width=39 height=54 xoffset=2 yoffset=14 xadvance=43 page=0 chnl=0 +char id=91 x=161 y=0 width=16 height=72 xoffset=4 yoffset=7 xadvance=19 page=0 chnl=0 +char id=92 x=385 y=0 width=30 height=58 xoffset=0 yoffset=14 xadvance=30 page=0 chnl=0 +char id=93 x=177 y=0 width=16 height=72 xoffset=0 yoffset=7 xadvance=20 page=0 chnl=0 +char id=94 x=374 y=319 width=28 height=28 xoffset=1 yoffset=14 xadvance=30 page=0 chnl=0 +char id=95 x=46 y=360 width=34 height=8 xoffset=0 yoffset=65 xadvance=34 page=0 chnl=0 +char id=96 x=0 y=360 width=17 height=13 xoffset=1 yoffset=11 xadvance=22 page=0 chnl=0 +char id=97 x=268 y=265 width=34 height=42 xoffset=3 yoffset=27 xadvance=39 page=0 chnl=0 +char id=98 x=415 y=0 width=34 height=57 xoffset=4 yoffset=12 xadvance=40 page=0 chnl=0 +char id=99 x=302 y=265 width=34 height=42 xoffset=2 yoffset=27 xadvance=38 page=0 chnl=0 +char id=100 x=449 y=0 width=34 height=57 xoffset=2 yoffset=12 xadvance=40 page=0 chnl=0 +char id=101 x=336 y=265 width=34 height=42 xoffset=2 yoffset=27 xadvance=38 page=0 chnl=0 +char id=102 x=483 y=0 width=25 height=57 xoffset=1 yoffset=11 xadvance=26 page=0 chnl=0 +char id=103 x=166 y=99 width=34 height=56 xoffset=2 yoffset=27 xadvance=40 page=0 chnl=0 +char id=104 x=200 y=99 width=32 height=56 xoffset=4 yoffset=12 xadvance=40 page=0 chnl=0 +char id=105 x=483 y=99 width=10 height=55 xoffset=4 yoffset=13 xadvance=18 page=0 chnl=0 +char id=106 x=193 y=0 width=17 height=71 xoffset=-4 yoffset=13 xadvance=17 page=0 chnl=0 +char id=107 x=232 y=99 width=34 height=56 xoffset=4 yoffset=12 xadvance=37 page=0 chnl=0 +char id=108 x=266 y=99 width=9 height=56 xoffset=4 yoffset=12 xadvance=17 page=0 chnl=0 +char id=109 x=439 y=265 width=56 height=41 xoffset=4 yoffset=27 xadvance=64 page=0 chnl=0 +char id=110 x=0 y=319 width=32 height=41 xoffset=4 yoffset=27 xadvance=40 page=0 chnl=0 +char id=111 x=370 y=265 width=37 height=42 xoffset=2 yoffset=27 xadvance=41 page=0 chnl=0 +char id=112 x=275 y=99 width=34 height=56 xoffset=4 yoffset=27 xadvance=40 page=0 chnl=0 +char id=113 x=309 y=99 width=34 height=56 xoffset=2 yoffset=27 xadvance=41 page=0 chnl=0 +char id=114 x=32 y=319 width=21 height=41 xoffset=4 yoffset=27 xadvance=25 page=0 chnl=0 +char id=115 x=407 y=265 width=32 height=42 xoffset=2 yoffset=27 xadvance=37 page=0 chnl=0 +char id=116 x=245 y=265 width=23 height=51 xoffset=0 yoffset=18 xadvance=25 page=0 chnl=0 +char id=117 x=53 y=319 width=32 height=41 xoffset=4 yoffset=28 xadvance=40 page=0 chnl=0 +char id=118 x=85 y=319 width=35 height=40 xoffset=0 yoffset=28 xadvance=35 page=0 chnl=0 +char id=119 x=120 y=319 width=54 height=40 xoffset=0 yoffset=28 xadvance=54 page=0 chnl=0 +char id=120 x=174 y=319 width=36 height=40 xoffset=0 yoffset=28 xadvance=36 page=0 chnl=0 +char id=121 x=343 y=99 width=36 height=56 xoffset=-1 yoffset=28 xadvance=34 page=0 chnl=0 +char id=122 x=210 y=319 width=32 height=40 xoffset=2 yoffset=28 xadvance=35 page=0 chnl=0 +char id=123 x=113 y=0 width=24 height=73 xoffset=1 yoffset=9 xadvance=25 page=0 chnl=0 +char id=124 x=348 y=0 width=7 height=63 xoffset=5 yoffset=14 xadvance=17 page=0 chnl=0 +char id=125 x=137 y=0 width=24 height=73 xoffset=-1 yoffset=9 xadvance=24 page=0 chnl=0 +char id=126 x=462 y=319 width=42 height=16 xoffset=4 yoffset=38 xadvance=50 page=0 chnl=0 +char id=127 x=0 y=0 width=70 height=99 xoffset=2 yoffset=-11 xadvance=74 page=0 chnl=0 +kernings count=382 +kerning first=70 second=74 amount=-9 +kerning first=34 second=97 amount=-2 +kerning first=34 second=101 amount=-2 +kerning first=34 second=113 amount=-2 +kerning first=34 second=99 amount=-2 +kerning first=70 second=99 amount=-1 +kerning first=88 second=113 amount=-1 +kerning first=84 second=46 amount=-8 +kerning first=84 second=119 amount=-2 +kerning first=87 second=97 amount=-1 +kerning first=90 second=117 amount=-1 +kerning first=39 second=97 amount=-2 +kerning first=69 second=111 amount=-1 +kerning first=87 second=41 amount=1 +kerning first=76 second=86 amount=-6 +kerning first=121 second=34 amount=1 +kerning first=40 second=86 amount=1 +kerning first=85 second=65 amount=-1 +kerning first=89 second=89 amount=1 +kerning first=72 second=65 amount=1 +kerning first=104 second=39 amount=-4 +kerning first=114 second=102 amount=1 +kerning first=89 second=42 amount=-2 +kerning first=114 second=34 amount=1 +kerning first=84 second=115 amount=-4 +kerning first=84 second=71 amount=-1 +kerning first=89 second=101 amount=-2 +kerning first=89 second=45 amount=-2 +kerning first=122 second=99 amount=-1 +kerning first=78 second=88 amount=1 +kerning first=68 second=89 amount=-2 +kerning first=122 second=103 amount=-1 +kerning first=78 second=84 amount=-1 +kerning first=86 second=103 amount=-2 +kerning first=89 second=67 amount=-1 +kerning first=89 second=79 amount=-1 +kerning first=75 second=111 amount=-1 +kerning first=111 second=120 amount=-1 +kerning first=87 second=44 amount=-4 +kerning first=91 second=74 amount=-1 +kerning first=120 second=111 amount=-1 +kerning first=84 second=111 amount=-3 +kerning first=102 second=113 amount=-1 +kerning first=80 second=88 amount=-1 +kerning first=66 second=84 amount=-1 +kerning first=65 second=87 amount=-2 +kerning first=86 second=100 amount=-2 +kerning first=122 second=100 amount=-1 +kerning first=75 second=118 amount=-1 +kerning first=70 second=118 amount=-1 +kerning first=73 second=88 amount=1 +kerning first=70 second=121 amount=-1 +kerning first=65 second=34 amount=-4 +kerning first=39 second=101 amount=-2 +kerning first=75 second=101 amount=-1 +kerning first=84 second=99 amount=-3 +kerning first=84 second=65 amount=-3 +kerning first=112 second=39 amount=-1 +kerning first=76 second=39 amount=-12 +kerning first=78 second=65 amount=1 +kerning first=88 second=45 amount=-2 +kerning first=65 second=121 amount=-2 +kerning first=34 second=111 amount=-2 +kerning first=89 second=85 amount=-3 +kerning first=114 second=99 amount=-1 +kerning first=86 second=125 amount=1 +kerning first=70 second=111 amount=-1 +kerning first=89 second=120 amount=-1 +kerning first=90 second=119 amount=-1 +kerning first=120 second=99 amount=-1 +kerning first=89 second=117 amount=-1 +kerning first=82 second=89 amount=-2 +kerning first=75 second=117 amount=-1 +kerning first=34 second=34 amount=-4 +kerning first=89 second=110 amount=-1 +kerning first=88 second=101 amount=-1 +kerning first=107 second=103 amount=-1 +kerning first=34 second=115 amount=-3 +kerning first=98 second=39 amount=-1 +kerning first=70 second=65 amount=-6 +kerning first=70 second=46 amount=-8 +kerning first=98 second=34 amount=-1 +kerning first=70 second=84 amount=1 +kerning first=114 second=100 amount=-1 +kerning first=88 second=79 amount=-1 +kerning first=39 second=113 amount=-2 +kerning first=114 second=103 amount=-1 +kerning first=77 second=65 amount=1 +kerning first=120 second=103 amount=-1 +kerning first=114 second=121 amount=1 +kerning first=89 second=100 amount=-2 +kerning first=80 second=65 amount=-5 +kerning first=121 second=111 amount=-1 +kerning first=84 second=74 amount=-8 +kerning first=122 second=111 amount=-1 +kerning first=114 second=118 amount=1 +kerning first=102 second=41 amount=1 +kerning first=122 second=113 amount=-1 +kerning first=89 second=122 amount=-1 +kerning first=89 second=38 amount=-1 +kerning first=81 second=89 amount=-1 +kerning first=114 second=111 amount=-1 +kerning first=46 second=34 amount=-6 +kerning first=84 second=112 amount=-4 +kerning first=112 second=34 amount=-1 +kerning first=76 second=34 amount=-12 +kerning first=102 second=125 amount=1 +kerning first=39 second=115 amount=-3 +kerning first=76 second=118 amount=-5 +kerning first=86 second=99 amount=-2 +kerning first=84 second=84 amount=1 +kerning first=86 second=65 amount=-3 +kerning first=87 second=101 amount=-1 +kerning first=67 second=125 amount=-1 +kerning first=120 second=113 amount=-1 +kerning first=118 second=46 amount=-4 +kerning first=88 second=103 amount=-1 +kerning first=111 second=122 amount=-1 +kerning first=77 second=84 amount=-1 +kerning first=114 second=46 amount=-4 +kerning first=34 second=39 amount=-4 +kerning first=114 second=44 amount=-4 +kerning first=69 second=84 amount=1 +kerning first=89 second=46 amount=-7 +kerning first=97 second=39 amount=-2 +kerning first=34 second=100 amount=-2 +kerning first=70 second=100 amount=-1 +kerning first=84 second=120 amount=-3 +kerning first=90 second=118 amount=-1 +kerning first=70 second=114 amount=-1 +kerning first=34 second=112 amount=-1 +kerning first=109 second=34 amount=-4 +kerning first=86 second=113 amount=-2 +kerning first=88 second=71 amount=-1 +kerning first=66 second=89 amount=-2 +kerning first=102 second=103 amount=-1 +kerning first=88 second=67 amount=-1 +kerning first=39 second=110 amount=-1 +kerning first=75 second=110 amount=-1 +kerning first=88 second=117 amount=-1 +kerning first=89 second=118 amount=-1 +kerning first=97 second=118 amount=-1 +kerning first=87 second=65 amount=-2 +kerning first=73 second=89 amount=-1 +kerning first=89 second=74 amount=-3 +kerning first=102 second=101 amount=-1 +kerning first=86 second=111 amount=-2 +kerning first=65 second=119 amount=-1 +kerning first=84 second=100 amount=-3 +kerning first=104 second=34 amount=-4 +kerning first=86 second=41 amount=1 +kerning first=111 second=34 amount=-5 +kerning first=40 second=89 amount=1 +kerning first=121 second=39 amount=1 +kerning first=68 second=90 amount=-1 +kerning first=114 second=113 amount=-1 +kerning first=68 second=88 amount=-1 +kerning first=98 second=120 amount=-1 +kerning first=110 second=34 amount=-4 +kerning first=119 second=44 amount=-4 +kerning first=119 second=46 amount=-4 +kerning first=118 second=44 amount=-4 +kerning first=84 second=114 amount=-3 +kerning first=86 second=97 amount=-2 +kerning first=68 second=86 amount=-1 +kerning first=86 second=93 amount=1 +kerning first=97 second=34 amount=-2 +kerning first=34 second=65 amount=-4 +kerning first=84 second=118 amount=-3 +kerning first=76 second=84 amount=-10 +kerning first=107 second=99 amount=-1 +kerning first=121 second=46 amount=-4 +kerning first=123 second=85 amount=-1 +kerning first=65 second=63 amount=-2 +kerning first=89 second=44 amount=-7 +kerning first=80 second=118 amount=1 +kerning first=112 second=122 amount=-1 +kerning first=79 second=65 amount=-1 +kerning first=80 second=121 amount=1 +kerning first=118 second=34 amount=1 +kerning first=87 second=45 amount=-2 +kerning first=69 second=100 amount=-1 +kerning first=87 second=103 amount=-1 +kerning first=112 second=120 amount=-1 +kerning first=68 second=44 amount=-4 +kerning first=86 second=45 amount=-1 +kerning first=39 second=34 amount=-4 +kerning first=68 second=46 amount=-4 +kerning first=65 second=89 amount=-3 +kerning first=69 second=118 amount=-1 +kerning first=88 second=99 amount=-1 +kerning first=87 second=46 amount=-4 +kerning first=47 second=47 amount=-8 +kerning first=73 second=65 amount=1 +kerning first=123 second=74 amount=-1 +kerning first=69 second=102 amount=-1 +kerning first=87 second=111 amount=-1 +kerning first=39 second=112 amount=-1 +kerning first=89 second=116 amount=-1 +kerning first=70 second=113 amount=-1 +kerning first=77 second=88 amount=1 +kerning first=84 second=32 amount=-1 +kerning first=90 second=103 amount=-1 +kerning first=65 second=86 amount=-3 +kerning first=75 second=112 amount=-1 +kerning first=39 second=109 amount=-1 +kerning first=75 second=81 amount=-1 +kerning first=89 second=115 amount=-2 +kerning first=84 second=83 amount=-1 +kerning first=89 second=87 amount=1 +kerning first=114 second=101 amount=-1 +kerning first=116 second=111 amount=-1 +kerning first=90 second=100 amount=-1 +kerning first=84 second=122 amount=-2 +kerning first=68 second=84 amount=-1 +kerning first=32 second=84 amount=-1 +kerning first=84 second=117 amount=-3 +kerning first=74 second=65 amount=-1 +kerning first=107 second=101 amount=-1 +kerning first=75 second=109 amount=-1 +kerning first=80 second=46 amount=-11 +kerning first=89 second=93 amount=1 +kerning first=89 second=65 amount=-3 +kerning first=87 second=117 amount=-1 +kerning first=89 second=81 amount=-1 +kerning first=39 second=103 amount=-2 +kerning first=86 second=101 amount=-2 +kerning first=86 second=117 amount=-1 +kerning first=84 second=113 amount=-3 +kerning first=34 second=110 amount=-1 +kerning first=89 second=84 amount=1 +kerning first=84 second=110 amount=-4 +kerning first=39 second=99 amount=-2 +kerning first=88 second=121 amount=-1 +kerning first=65 second=39 amount=-4 +kerning first=110 second=39 amount=-4 +kerning first=75 second=67 amount=-1 +kerning first=88 second=118 amount=-1 +kerning first=86 second=114 amount=-1 +kerning first=80 second=74 amount=-7 +kerning first=84 second=97 amount=-4 +kerning first=82 second=84 amount=-3 +kerning first=91 second=85 amount=-1 +kerning first=102 second=99 amount=-1 +kerning first=66 second=86 amount=-1 +kerning first=120 second=101 amount=-1 +kerning first=102 second=93 amount=1 +kerning first=75 second=100 amount=-1 +kerning first=84 second=79 amount=-1 +kerning first=111 second=121 amount=-1 +kerning first=75 second=121 amount=-1 +kerning first=81 second=87 amount=-1 +kerning first=107 second=113 amount=-1 +kerning first=120 second=100 amount=-1 +kerning first=90 second=79 amount=-1 +kerning first=89 second=114 amount=-1 +kerning first=122 second=101 amount=-1 +kerning first=111 second=118 amount=-1 +kerning first=82 second=86 amount=-1 +kerning first=67 second=84 amount=-1 +kerning first=70 second=101 amount=-1 +kerning first=89 second=83 amount=-1 +kerning first=114 second=97 amount=-1 +kerning first=70 second=97 amount=-1 +kerning first=89 second=102 amount=-1 +kerning first=78 second=89 amount=-1 +kerning first=70 second=44 amount=-8 +kerning first=44 second=39 amount=-6 +kerning first=84 second=45 amount=-8 +kerning first=89 second=121 amount=-1 +kerning first=84 second=86 amount=1 +kerning first=87 second=99 amount=-1 +kerning first=98 second=122 amount=-1 +kerning first=89 second=112 amount=-1 +kerning first=89 second=103 amount=-2 +kerning first=88 second=81 amount=-1 +kerning first=102 second=34 amount=1 +kerning first=109 second=39 amount=-4 +kerning first=81 second=84 amount=-2 +kerning first=121 second=97 amount=-1 +kerning first=89 second=99 amount=-2 +kerning first=89 second=125 amount=1 +kerning first=81 second=86 amount=-1 +kerning first=114 second=116 amount=2 +kerning first=114 second=119 amount=1 +kerning first=84 second=44 amount=-8 +kerning first=102 second=39 amount=1 +kerning first=44 second=34 amount=-6 +kerning first=34 second=109 amount=-1 +kerning first=75 second=119 amount=-2 +kerning first=76 second=65 amount=1 +kerning first=84 second=81 amount=-1 +kerning first=76 second=121 amount=-5 +kerning first=69 second=101 amount=-1 +kerning first=89 second=111 amount=-2 +kerning first=80 second=90 amount=-1 +kerning first=89 second=97 amount=-3 +kerning first=89 second=109 amount=-1 +kerning first=90 second=99 amount=-1 +kerning first=89 second=86 amount=1 +kerning first=79 second=88 amount=-1 +kerning first=70 second=103 amount=-1 +kerning first=34 second=103 amount=-2 +kerning first=84 second=67 amount=-1 +kerning first=76 second=79 amount=-2 +kerning first=89 second=41 amount=1 +kerning first=65 second=118 amount=-2 +kerning first=75 second=71 amount=-1 +kerning first=76 second=87 amount=-5 +kerning first=77 second=89 amount=-1 +kerning first=90 second=113 amount=-1 +kerning first=79 second=89 amount=-2 +kerning first=118 second=111 amount=-1 +kerning first=118 second=97 amount=-1 +kerning first=88 second=100 amount=-1 +kerning first=90 second=121 amount=-1 +kerning first=89 second=113 amount=-2 +kerning first=84 second=87 amount=1 +kerning first=39 second=111 amount=-2 +kerning first=80 second=44 amount=-11 +kerning first=39 second=100 amount=-2 +kerning first=75 second=113 amount=-1 +kerning first=88 second=111 amount=-1 +kerning first=84 second=89 amount=1 +kerning first=84 second=103 amount=-3 +kerning first=70 second=117 amount=-1 +kerning first=67 second=41 amount=-1 +kerning first=89 second=71 amount=-1 +kerning first=121 second=44 amount=-4 +kerning first=97 second=121 amount=-1 +kerning first=87 second=113 amount=-1 +kerning first=73 second=84 amount=-1 +kerning first=84 second=101 amount=-3 +kerning first=75 second=99 amount=-1 +kerning first=65 second=85 amount=-1 +kerning first=76 second=67 amount=-2 +kerning first=76 second=81 amount=-2 +kerning first=75 second=79 amount=-1 +kerning first=39 second=65 amount=-4 +kerning first=76 second=117 amount=-2 +kerning first=65 second=84 amount=-5 +kerning first=90 second=101 amount=-1 +kerning first=84 second=121 amount=-3 +kerning first=69 second=99 amount=-1 +kerning first=114 second=39 amount=1 +kerning first=84 second=109 amount=-4 +kerning first=76 second=119 amount=-3 +kerning first=76 second=85 amount=-2 +kerning first=65 second=116 amount=-1 +kerning first=76 second=71 amount=-2 +kerning first=79 second=90 amount=-1 +kerning first=107 second=100 amount=-1 +kerning first=90 second=111 amount=-1 +kerning first=79 second=44 amount=-4 +kerning first=75 second=45 amount=-2 +kerning first=40 second=87 amount=1 +kerning first=79 second=86 amount=-1 +kerning first=102 second=100 amount=-1 +kerning first=72 second=89 amount=-1 +kerning first=72 second=88 amount=1 +kerning first=79 second=46 amount=-4 +kerning first=76 second=89 amount=-8 +kerning first=68 second=65 amount=-1 +kerning first=79 second=84 amount=-1 +kerning first=87 second=100 amount=-1 +kerning first=75 second=103 amount=-1 +kerning first=90 second=67 amount=-1 +kerning first=69 second=103 amount=-1 +kerning first=90 second=71 amount=-1 +kerning first=86 second=44 amount=-8 +kerning first=69 second=121 amount=-1 +kerning first=87 second=114 amount=-1 +kerning first=118 second=39 amount=1 +kerning first=46 second=39 amount=-6 +kerning first=72 second=84 amount=-1 +kerning first=86 second=46 amount=-8 +kerning first=69 second=113 amount=-1 +kerning first=69 second=119 amount=-1 +kerning first=39 second=39 amount=-4 +kerning first=69 second=117 amount=-1 +kerning first=111 second=39 amount=-5 +kerning first=90 second=81 amount=-1 diff --git a/src/web/static/fonts/bmfonts/Roboto72White.png b/src/web/static/fonts/bmfonts/Roboto72White.png new file mode 100644 index 0000000000000000000000000000000000000000..423a3a7e942054465e40a69e813b5e1fcf993fbb GIT binary patch literal 52730 zcmZs?1z6MT{|3A+)R1;`cX!u2 zJ?H#?=e+NGU0fGj+xR~B^Lg&)llQ`(Xetxlro9aS00>o86m$RpEXr0Qzt2pQCIMQ7J*{nN8Zw1eKYr{;PhK-_s283+OsO=gh91-*w>pcpORiYH#2 zaruj0Pw?DqXkPNJe7yg4AG#}zW1KPfGWE2U+ypvbz8v7hn;ohpz4wFbx-GoPbKWHF zl!;4hyG>v}SL#$!II3psO}-mQeO@Nr^L1uVmF-c`$YAiP9uudd{p z>W1kix@p4hIKfMwAN(@xwO~(3FML8=i6f3t&F$b#+g2R3vA&z_`v1=!PSU7&82Gh z4wt`SFEYpibrb)s$!zwFP?VS-5y^?!m9r`s04n*h(|yjUw6CspX)8*+X|Oj(-lT{l z0t(vx6;%1fB^z#?aT*ctG!r1X7m#);!GluMUW1hG8NUu*umR?4+qIMnh$m2*Fk7a4 zDpGoCZKpR$@GDc4hIZmikh&-!X0jm%$<`%UcDYP`BX(+!M zuUUcpLIR}sgmvHyI%|-AZ$3S_k{{w)!yl0*0uP1qr0m(BHxPt^NXWGtIe8!11vW9% zqoTIT+ST&p zTPp=^`RjG!w!g7HuOs;3$d>~cJDu`Ku!GUph_0J8N+P)#eR(roW>>SMg1l& zNXhHd-5XmnaRBlnw~#C6ZWB1k1t7^POsvI%!IWa>qk<4bBR!Yn)`xP zSpEfO?Md}}0S0OsC}`$WK39*SXw8tX#v!f1-Ehn1`~U2|*JH0`0XdU?T9em9Mf-`S z^oNWcDsOZ6_D)9839yOdrI~rYZx44C$@(iDwDOm3>(ig)P3gP%zmATbJ`(7G0ZsU= zP3)-M)5Q5uYsjv_RR(#0MsFVx+>oSUpzroI-A%~iV_vrUr8ep2A9#~+olhvjldo&d z&3i-)Qwl7gw=U&Yk2Iep-XX)+fDasW~iF*hux6sVJ+*=Y2lDUx(p4hClIM zRd%O#q9^3U8K*N^zTv3>JD&F+*|AtEo(PMNV|TeSGA4aH^2^s?clMGYONV3(@>D4W z`^7PI!|V_A&#Lo(^9R5v1C3IDE+s!oO5z8{Y+&|c+gN8-Io?vY@yyp7w?cqSvCdVY z__RNKCyrW0w+KDtT>}4YSv*LmXTe-R#;MMnH>t|9;xu6M^(g~SiAljBrOKo_u?e3P z+=x)KAxut?V2lumIpvlUe~Pq=nGXgGFm9bNsVf$q5Y?)VAOlo0{^JGho3D6VDpX&(eE z`-)<-^0x|=EKSxlJr*IcEFCEveN*zWnoHsp<19%`q(2KU6Y%8xhmJATUPR6|ZoPW< z2It)d?7Js}>U=A6pe%cphMt;^nru|lAIszSdaUjlC47{CCr-GAUBH8$~- zMYhBjQ{@G&ps#4E-YxVk-4(U7Ha8LRXCfX#E48;nv73(DlsSxD29cTXgfG_Zh5E@P zR{-5rBNZe~0l`!%-ce`+=ISJ4^N8Y5u;N&pPs)do-LlH9tglHdzB|fslQ+hb^MF!r zq1f>_xj%NT!2k4^YGVGb4~$<|PJJ)k*}xq>9-+jLVyYO22Qu+G&Om!5*pJD&^=Oul)mxd# zPx!_sTHW5xzWDoc1YOJo8PEfq>VdICuN;hYbXzad!adAX4HZ`dtgzx%v z-dJT#rbu&0wNQ!aNyP_Ix9ld|&0yKYjX$TpzXX`w^e4vsU@)=4N`zB}OgX(*H*35n ze$(nZMa>-aglq>b2$0oOLc4UM;KDP)2oah#5_1B&A0&4$KIM zD2{6bH&HVt+!M!~B!*oT%5iK=kD;L56lA8+sBzDF<5i}H z`omWWT(2Dry3?ddZDq6ZP0KC|va5M=8(#j6#exBFKCe~?>`la$8~bU!YZB|hNgV2S zCSEhL+Sa^#;@a73m>lR(l}=ew1T0J&7dvJampd6*>{eQXm~LC*H#l@f;Gh&5VjgoO zcqyn&JizdLz%}T4dCa9Zre&pOz`9YbH+<|@+S8BW7bjsHdJ2+rk^UohzCR|8c3z|& z@Mr%V#PT}-4o~6SRd1$IEO6tqWt~BkHo`v+zyHa7`Rb4dnV04adQUN4SZY?)j{qtL zuy+utw62m9A#0K4_o{=vddD6< z0Hl@tnkV!{#;EIEGg_$D>%x=mF$7#T_UVGp+uUN;=8Gttg>s@FK7P&@6D<3Aq5KK=^-#fiS@LlG}dTdLq>31$Kd z2mE+JnlWopfr$-Bl35!-Jj*v^^N5t@*aeVWPQ5JuEG(95Bg_s-HTQm1Tc$3rX_Mm3 z_MMd(C@wgiU5~=lB`1M0H5)m*+`Eb7b_sRx)^`FPRT-CARx9UJ7$p}bZ53y#4JXSYyQ-)Iznw{G5WXMm?%iE{nN{or&L8YO~K zwr=ZAW2QJWJ}}ZTALOX2=NK}uiMaDCD1~Cnk*Wu)0c!bqjMQ!?ZsEW_cj?h4Q;GB1 z6s6XCV)})z6UDG|1HOjJwRN61ghyqd7#-3l>d&3{_%qF|$z5wDX3zQ@?JD~MZYf;s zUL{TAPWkNse4q5n*zD-Ah^yrrH7fh&NR5KTEYY20J}$m?fE2!J|~S7So~=v;lX{i~U~ zglNr85;qiwy*kTJ5+b5R*YORylNr%_+-dG!f^8LKH-BMC=;c0A`?%Yds;>@b(w!ql zGlfP_LTemlNiBek=FVV&`YD7+(!-p}aa^wJze38D+<1A*8Pc>I9y2Ki>q~IvGO*E8 z;zLWAKLwg~L_*3F2Wrnz36u!zl3Gbm{;_y$tI*o5+n9qUq1mCF!^;q&TetLv$h>KD zj`})Hu$!pi+g!%o!Qzoy`r(l^vVA={+&X%S3)a~hq=VidMYW0qf4!XxnjzThgzXm< zE&ZY72(qIMEGIm&d@I?*tV{UuMTGdoM?Wb2w58pDh#3l+<@ixVG^9$lBIV_!p=J;h z(@VuY2_L*aU;o3o9i$1JGdkL>PFOmMovT#CHwZzZB@Cw1FSYjs(sDBoDKSR=?0Ms? zRA|)P?lVRJU5hDuc7*PCU1v^BY-;~;>*)i0x9ypU1|iUv;gtUBZm9E{Tgssv@}ttP zF%cR+j#I%9xKU84*L(lL);`uA@ajipWSYZIIOBCmMG`7@D&K@n{uKM+|HYg+fLD2_ zwThmV0_C|9>(@%lL$rOFosz-%N>LIisCX;~n_R|kuTzwG znw$VWv5SUB9hGr>`4!he^VNrq89I&s-8u{x{@CJIGLDPuO|qNPs-ywP0{r|jznFwz z@KrV^#y0}uSW-HiYDO*h9gDArhv~5> zPv$jRjqfRgxKLplD%wY{ZAF&Hf#-PxsbiR{1Blf76|!)0TAJPhM*+wF3=#=H6l{`~ zKc~qr4G}MQH{r07{8HqRZ8MI&p~d*I@jiMCJ%E%<>xJ`D2|Wqfb*<>ymqjpa3h+2I zU{XV{#FrSe3ec=#gmXbOgpXC7HC3>SZuRH;{32h%;MUANqWlK_p~%t+SqU|K`P0o! zG-?PJl~-52IaT%dE7F1M?4m3=LFij_M4S|fa{IzHvS2b z*$etBp>GT}-lrmzRv%MA)%RCh>=3^Nc9$*|2Bj1JHsB{@}h|q8=$O?BN;x zPB`vR&~xR6o;ARrw*jU?)ow~lA`o9#Qv)M1h3a2+yFgVkh;xJDSf(^7(9T?Cep`CJ zI_D!n9}C8R;IH*^7e`coF53EDD3tfKI(~^2e@gx+Gr{7F`NEA`2F&;m4KXLnuU>^L z1{fGCIJFO!`4L-a2Xp2>?`6@AYGU59tRnD{nW#WB>DZd#W~4DB`)OjXcjfL*-^&u1 z(Q|ZkS-G*(7|^RB0dXIbXk`0~!>a(k4TGdSPo%+p z>pEoxbz8TNcFXDfL>9_oP=})ca(;Lo3u;QB#7X6RX;{U^L>m31xqr$dx~^R|HL!;ue-7K-JA?iTYo-<|Rpjr4=`}v7RNT?(?$w07PI?p` zgLa>SI9%PGD@cs6{9t4B?IVvN&&+;R@Pa{3#Rw+xC-%Z5{x)FbM8IU3@<+?Ks@_yg zGLUYe@F{5DNf!F~gGI+nDDnia~5f@SYqkWXK^)=Tl{oyLGSuH z{XbyJ7+>Uvb801V!1Rkw_~P@DryUx7YvgjaQpg|z8|CD?)DsbQ8qzYYyS7#K* zfPYf}Y2ZQ=dDu(&)x!+LgILjIb&OTTd&QD)MazRS)V4KKwUyM4fmm>P{^h_S@_Zw=&a~0R z-R(XQy_?gYI^)HE)i6qBM^`s0tJQ3l-VHfdVS~+`v5s7~?W*JK?YfPT>ej;cS+A)) zoI&u1?|);nD=ZSMS4mW7y8n(V7sB44_=uIte#;^7A8^|oV8D#$ZhykCyW)<>e0p#- zoE{KEj5`w;4`jtVtS8KU_bxkdzLsGP0tAi%bB{1F@Y~)RO3|*~P;KHa-TKigL>S1F zIOy?LH{=l+mt3vN>gs=)>1gVW#d?gu8B3(pQ(YR(I^4 zuO#V}OQDmQbPCD*8U+8MB~o3f+)`k=zO2edl{NJ&e63sftI+FE4=Usg=LBj$B!TJb zbs#kO``}wkP>5TF9e^g!NtU|-;~-yKgok{wBu9n#Na};N**5K-{jTR{R6a()`}eGP zP#YXu$_YeDA#UQ@x~-ax`o&ZtRgMx2aA;m(rI@z5idi+>}S(kapRg}!tb`)>}ByGp}B=n;xaauKwFRE zLrVUW78@|*`k_;XFZ2n){DK%D)@<=^;r2`*PACLLJ_(Etgp)H7n^At|88Bp8# zcG4?jvDEt#%=fUNX0>@L^S+EP@$3cedb}}e`)%P`wVHuF)DSv{_TG}gJV=pX)W=KS z>x^Iyl>X=RPcyHw7p<*(-jsO=w|qb7FHe9uIon8xj~O?#?E|1IR=g{$)R0aI>rp5T zjG=)*g7u|`niJ5Kk1^$yr#hzYCO@+3gkN}w11sXD%kXwdbAuet9Howgng7L0_U!Tx zY?+_GSlfiXvTxnt2A^VTPIc|dh^i7gn_hycezZ2(>^<_O`D&ApbyfTDec` zvVX4lZwLAP=U6kiFClc3tVP5A3d_w24OySb;`!RS&k--h9^EqfZ+S+N@NgfYNP6axy}#T!DEs=LBZ256H@9WVznxIB>GA>-WoJRa zlkPG>1x`L9JX^(=E|IUPmPJ_~=>NI#$`b&ScH zVN?cx`epFb<529zCey8t$pw$pcPP|n6LrSde>apH1GS&+zf%KWGy-?&c~Dy{|FZGz z>=1L9bu`2bQAlad9ATAApaS@A`R>_POKP-dxT{fHF9K)l1ux+hK-qU?O8vI(#GMaI zLA(UG{d$G}GPAQYgrvD_$=hP*mjjOjjUE?4wUB_^==j(n1w;)of?M`s*~)7m%!czl zUMFJ>VDnJgwDLf14Kh0!u?!l2G1sJSJJF`r0I?u7Tl}GGVD!7a{Aiadc;O&1+9vCl zD{a2=zsL-1Ai5tkozleNpMx%M56sJkKCde!suUe4%`UQ5DzD|!f}?P#W)t=P+WnAa)U7KO7H?mo9SJW)8oltdo`+q8C?A9@6so2Q)1+Z1X~2))ZjMyQ01;r-{q0z9kY&qA<6dtsNk8(T zFf>iMPtQ;@<>|@o{A-p_$vQ&nY^fCkjk>XySy zk(EsgA6^E0D%k$z>ITyW@pfjhBJCd6*(pVXp3vVNRw39%q&+YUeY^%CnZ%C}oQxer zq{uc%T70-e;Cs*DxzjCsz8-}p3G=lF=TUi!s;)QMjTEpPHv=s=Q!LYTum!w&9|F7!9 z>?2bWf@!DZDZ6=SgKy35+;IB>+O@y07DymP_fi1_6+L4{OWOs|A086KKhFg22>Mpp zdZJb@001e9gsCi1@*3WOvRkl|!K=Z%LCnyCYRBOAm}TzWARlPge%=dG(&tWu0i4%D zbfi1l6(1}_`Fm>uX!DYK7lxN4mV=$$bpNhfpSAT^;`i%~2?`4=SeKSFJVtPEMr;;R zFx-+~9Hr2x5ME%|6M)ELdM_kWGE}_(G%^5vUlehwARh$YV-06YNB*#)cczc6J`Qpr zVuONuB179&pPrnDCoUPyrXfy-)e$EQUNyF!F{7czAe`ivcw>2YS7e#WofnBMWLzPC z#v4znLeu#aYT1t*SuPlRLKVio_z0bX@y36V^zzZ?;G&qfC#6%PRtab~3FH+fF+Gh} zx1hu?|F@BPRWhPZ`x0haSk-cLsC(gliRg6_=sv!l+Tf|_%AtJ^+pbnBmIx(pqXm_ZA7o+3mnsvWBiOCtPTlTeOB5bjt`%G4;*$?VwK?W zeOS12Cp?gVINa3%+@vm&K35^mCUzwmD3$ebaxq1`au^b(`GmdzesXz{6;8miCO){7=gAPcoCm5nTXZs>rdVX z#bZ{?LE{bu-){O4RnJ}V9;h0OP;=}&#^WLUkCpkrv+d&+SYluU$0+Ch(e2v+mH>tI zP4|z<5HmZ&t+aOUHRr;uqkCVWM+k{tFT+$j@y^s4Tm00aZPGaYE_8lX7QFlcX5c?N ziErS4rT?aTJNM}hZW$ge&Ssa=FBELZd`o-S09G5(sk(Ii=Hn*!Y>H*5pLbK!UwtI* zY;exBw&Cp`F}a=5;#)tyl0L30E%6mDau;(*W4Rbamw~H48;0xxYfaAx#x~dlH#VL=plO;mP|dOAeGcM_56A2t|ikQF;?|*sT*{w z98Y_bt(tSD>=YKG{pzjorS3NezRVmnK-N(4jggekXH7!f))^EZ24x_QzxdFUT7;~S zX>Pw@Hu=GzMRsEHcvcIS0T>!6 z%$X_=UI^NPX$LE^4XrTKKTg8>Gat}pdgKzZr0{e{rOb$9=!?%+W1NQGm=xEYHVVC~ z7ZWXuSJLb&4i%Ef{mD(!q06vfqCfsUw%l{D3|_|d+Rm_w6169T^UX0SWg3M$+){#{ zCMaZq2UDkX#&o*pK$!8y^TDXxyBTJ|QX!HIfPQ>4YvRQkuC+-!H!Epp^?gztxARdY zYM_uqUT#tWm8ykEFQqsZi61gEsE=YnBT&?i{w9qkEYH6UBepQz#*U*Qe()$eDQ`N? zqTJ{(_xzF!$AB@Ng*`-BJD*0^F$g5Mbrdt|b@PbBlU~y5vS4OHaFrD^gxO>g<_Ruw zHKC0OV>2X;?w&q1{B>Vwj55>NdC0B1C)WMpz(Y!<<+fQ|J-fQn&%QWrIcW5~zbofs z55VZzDw`InjynzNC$o-RoX=mBfMq>RXTTz;r{?BgIFXxXe8M#3g2(z`3$mjtakqW% zPxieRodY2oRq7uW|LBaCu#hsReB&`%nhLVUC!!;IApgRDsP5MnkdU`M_T@uS@k3pm z)_}ggs>%|DCN4!LNR_Rr&wSmBq3j;_0{CFrpk+$fuC zXw}$Hkxx7?bg(cKGOKcl?c&kfLT?w1v)P_0n^zu$*wozGt7qFQ z!4bKQg5~^f*}f98R=_Td#tfOaWf4yo_j2b!BgCRNy3Say!Q7j`)YmU2*rJj;@$;kG zT&ja?srYGUb-HvlQ-sa8I$KZFOlo?XTm2pFCiJDmOK*8SQG=HFhV;3C0YX@O@dr?y z@#zDC?K__jB(>@N$!%&V_U@x$GyXnc3=>gI#Prn#iZ;EViRcMEC-6wiL~98=Pze?t2E$DY00OGI^)nKA>TZ4z-&Zf z15R`W0&()--pP4vWFLo|`TqJ_cV8HsdmC%GT-UtuoNt-bv}RQRHKqr7)_w?xXj|`Vw|GuKg60gsD%NCw&BaC+G zpJnGb&=Ypx?9!nerFm=}G+iZruiFVpuS;{LMobgCR65tylF|fCgo`yNs)I$4are2{ z-)jOMW?p?-6o&D(GAs?dm~P`eoVUcf8D1ko&cI!%K(N7#k50|eOgYq}5Z%4dUx%g+IuaPYA7GRU#bOnzn}4aHT+QWYm1X3d^9@_f zu9kyeoAcCXZ_Y|KsLMt8d(y|bt$04BAiRi>*_omSjF#xPE@DxZS(}V#{!zDg#7hU| zoU_>h^f&pf=K7>64t)&iGhV9(qbWH;2FBw+dO)M+H&A7OV6SdqJ-CIA%i`a(& z_G>4rn_9MFrp{918Ni^Qq-(dAeP|EiZtkDH(4ZHN%?xKKiC6iwSmB{U`brCHz zSe@8Nfqq@%lnikZyw1L69qh8+xWmvD2C$HWDsv9K^KbR!doU3_HQ>UWLu7uac>qbz zpg;7sShz35qZzgWGpT8hJibj||afF{bihpt#kMS^#-P?V$mTLK1?Pv=I zn6TEdEHV<&i#X2;5h3K&$u`0b(7Y!efJrSEtI^T>PIs8T6@|#Ah~V{N5y@5;Ia@t( z#Md5~$O$&Wf|X|mYBh%#+_ihQbYhwVf)!qx#JRp*xin;3!4~Jg&c8K-kp3+w?b974 zDhgbE08N8QGQb|s81C3m^}r-E{jvmSvZL-a%5{rjRAA8XLJtv5Twylb9yqZwZ~%)P zDI9tsi2=&r1H|A%240ZOyY&#Zk(ToY(_==#fY7PY{g0yq_o=BHvOmnxOFq369J_W& z6*5KTyLB`CuqE{F*yqBy7|g%g1#cyYhdG!OX(Abkke_mSzon#Yy%lzLf-r6E|H#uo zz^Ci&-(Wt75x=^%65UNxSblSlU&Lp#nOOD9mk7FX>hZ< zi}Bu-gfsVOKOy4Z=k?d(Bx5Y1+0M${VxhZ#z|&XrnSzmlZM>eu^-sHNLogBL_xgWc zZB}?Yp>kQD;7+jg=S2}`6C@E#oMc9qDbOUoY;*4K#kcWCfzDk1-|RtBO?IfscYpBD zBK?5kfbGsCbtZp|B*0_g-;X#*9Q@ruc=hrxYlBX(HvwM2Mtv8rjD(Tx-oGp`+X%z- zHWhqNIa{36y?-kH_6V4FCaFq6v)E^n^9!E26%EvX@9hH115nTJ)J5AAaI`bh{;wyL z1WGeoUplQgQLX$7!bn?^*o_C|Upv_K|M&YR;{Sf|jQ79RzWO)59nXJnq3PgX=DSGy zmm#Ki{$A>DBK!^T_s6dTu;{RMW>?ZrpL6*Sg_p!1yu5B)XiG{v{l(?~+4b>9kh>iC zP-etV(>FU+TK&?2t8uoCj>{kJYV;oD?tdKKC%LES;@G#-cy3a$bD()izw(ijXz)Cj zOX{YLD&tgNbi+3{)m>_%Enjrx+1M9Chqs6t?|Dz7J&dyXXL!cS=*ur|{FlUUis^oA z6bD!-g_fd0cXbvN0?psUt@5dS%qmoij-CeeK zG8YJ4{H+=>3O)LZ*y!kz*aPbK{zoVV@%jsb@nYn@vgNS+i$n|+!Mtpnd>mbeDF|O0RPV3Ni_;bsAG@saBho-O2 zzD$SPO52KxUOEcL!5O{#_A+-k(8DhxRh-nIH-M)OTMgV=4BB-ckrv8kGkb;lkr}Yj z*7W=#ffqW!@Ny>FwurQk2q#WUm0@*Z+IwCohqvS1z}~&%o!i9fdRF(EbVc&CzCEhI z50jv^!yg#FLDcKdnaiiQzq~@+; z>o%M1MS1L5PYDJ*+7F6%g5i37=9ifeyX~1ddeRC)-vED zF&bbE@La@(iDn@tH$Pp}w);QdG z;24p->l(PdAOrrMEq3_r!yYka#@)gO{caaY24`bxMr{u&Pr&RKE6EnhU_#Kt)wY-@ zy+cN%M)N1&;p#2Y`Ll{hYW&B?0V zn(3%JpmF=$+RBaHo}b&4XSrmPNeqUYR#%wq<`q12BK{O_0!GBJ!#0bs+fXD^I>Lf=KcQf_ej3*I2h{ZV^oCMpw97RAd?{9S4MIRyS1+23mib zV@jO*K@S^9JV_`!OG5}<|^F|9(E zP8v+73}iTyKW4J#W+12|LXH^S^h8Dn6T%5@eqJx&%o_2f0&-i)`g$`kBBk3^o6&kcRT*th;nR(g{>y;x+w+#!F3AL;sAM*>}f4A*|S+c zKl?^zSRLbwf$%3TIMiNolKMTg#bHTv&E!t%+rWVEinUueBflz1rC3+ELF!&|FYX(E z?fEFNrHfUhtt<>8!mGcqzWru=F_i=QwAZYJwv^LwC`4m|*59NzTr8b480f?Y$NSIW zL}6#~&G4cIq&5ex$d`Q6j4(kSn$gsKSq9qN`#70N>3E1AdY4(7oZve%(W$x%{Qd8rK7w6UFr zLvOEzvFPRNq0>Zn5^m&m0$%85oGe$4-ImVU^zZ~+E0`<-q$TJMX#}b-vF{V@W}6q| zyJcPL=U~1FaqTiUr{q-|($o5Y{NxT0biS^rO2ROt{N@cngyC2$@6HK>yG}Atf?d=5 z4y)(9YIvZ+m#*mn9BXbYV?=09;Ru_Um&N{z&u;viVgM7*DInVVw;o|}lBT~}-i!NYN6lZXs2{6LDd}W0(o-Jc;(xJD8}cKNU+mh;;i1__`g?V& zCqo{XlI$*Oz|E$e75F~=5+kwC+)QLSPaC~(V{4&YKSgY;k z2qOiR1Dz;b2?uE1pQyYg5PD7CW*-G)sp#q*p!`Y1|6z0NTU2f+$$#9k(8BZiVQm&p zBI*Jf5~B)zYt|-itoSo~KVjB;62aM^$W&5=>q5V%QP*Brns`LffIPWLN&iq&wo}-- zngYHg#FQc&S(Z`sO(|8K%#4PL9uiZ?JA(AkGmM-B@J z%{$HIGxOyp_uUMdt-<8@kVwS%<_tcb)tNtD=6{X~858!@KOXmya&pVC8km}}8i}7% z$fYhKNTC$Ed}BmYJa8WUeOLUvDY0lENb-VUblG8}i|iGswGWk;Ht<%J5|kB}GKT2j z);Vz8tAyD@x#P(l7--O0Ux%EwSDn9Xdbk3zDlH33^e>F(+p~8iiD1e03~4scj^%;b zsR=0}Z6QFKiVtbo zm67!Xq7WnS?4SHVxdjJW8&O7gM%S_jC$D@#5(*Yz?n1$5x`}TG4tx&RW02plfQfEO zbG4&aWy^{~*RCd19)3~|kQ}3i4CvU|L2Z;?-O5t^fZ0m{UhCbgK@|m48veLSG48~v z`$RGoltDwuw`J+VUvbb(-+Sp1-Mh-sqVBXo)4ckiq-)OEjdi$PE~)cfse7bpLfv#me#c5YN$IuQY60F=Hvk~BF?M6n*K-_oI{7oe#gXugD_FRxf zG$%6N-atj%EL)!3Tb)2^ZvuL1t6C(Y^yoF|n?azsd#}(xr@TqR@ zo!8xT0{Ww8pw%Gzu_6m{LTHPLiluUfq~63G-Rpu#D<5SNEb2BTHw0q}6~4wClQI{= z$P3mnLMQl@ltn3oXRdvK=BM$RtO#9rE^)|&i{*-qCAdO@(~`k}hJ)`aXHbb)pLB`Y zk=-v2V{XzQ+NHnpl2>e+V+@f9Yrnuj(swsd^n|2UIzx{S~%h~ zIxN*(HQMZ>;wMCNpdsTbQ67$x<{-td2l}JcI_!C#yIb?G-YZcvBy>khLCX;LN-wo( z0^_W!$IJMWwdH2RXM3KYdR!Woq6h-s&b2HgH=o5*Odc zVh$1Te0-D_Onqc9^#6xb`*2k`F}i9@`fi1ziB*q;IAb;xN%`E)DSWGmz)`1g5VQgs zkcz>~Q7n-0C1|oSbK73{gnWqYO5z|HVLw||cbw2Z+5Zb?Mrp`Pq(%{mU@We;z^pUT zw+}^lF}F7q+iFRJ(*0mf7KFvHNFRf3MvJXrg}W1Z%<9Q!mOLm%S;VhHp4_M{3O%y- zII)c?YG8SXC-T&^45)ic^l2@Hpf0$`K_=}w!;>2lE4M#}{veXs-nw&-!CsU+?!JLB zpHLsstBM3|=tD;c+>ZIKsRI}1VI+Id3ZN7h8D?1AIq4y2m>)3qHltJN{P9%b@tA*0)A|9ma_Xx!V zAL_hb<(QbbQO#W#I*xfsE&91jLOe38O~90?u5zR;`Zlp!k=SQzifr}{TklpIGxEfA z$rJbh<@}mGMK1OPg=8U-z_`jWE13^`m;;Q&fFGSqRhBe@i0Fjf|dbxw`S!8Ttp(%%oI%3p^nIX|T z14kJ{NIzD=(m}Lt0RGPGJV3U-a6=cvk;?4-i-YbZtvBhKb2WSO28#=B$HO~4h& zDT@jZgR4riQA7MR{VE3DS{M_n5LH-K36A@R9)K$-DFx2mG4$uC8{ta$3vk}1POKMS zUPE}q)3tgI`XBB|d4p%HbJUgBy06^EM5LHv3C+5hKSD_j;Nlqa=7&b z(eCT&T2H_IRYO8}R7DcHr{I;6Y$y1~rfVGHi%ZgEVoK~uKQR<09TQ}kReBf9wKvCY zY*)ws#rl?01Th>I*$Dx$enVVRVg%~E>S1aq6U^5bt8#g(l0-6^TW5IwqQT>_T;@wz z0;aF(+H1j&mgkIzx}t}tUTOI$AeIk<)HXB<#ic4A8UR0w?JQ8WeyI@92yG-_^@}4D zbKYt_W_o1ag{6gd+Tog~>r2wsu&aR6FwoFpre5ribv0M356j5~#7qmraE0trXx}6DVI$HPaQ>Y zz=_ChMWaYHt_d-CSL#%n8W-7?M5JL|@2=|Jo@4Iw52E*lc3s#$|1^G-phhP@Iwl<7 zsDj5Z@Vyw2g(c!n7_LzGssc7e;h;J^BUn(DvOq9CVEOlTI)o(!N2^PV3zvb+>xFi+ z4U5*eE!uG;4X7b(_em9$@h3=c9o|nVBgX@3;YZ9y`5KRYDHYpG`9E+>gyF6eTaOJ> z)wskTV39`7*Q!5Vvk}8a((loG8S*h4zNIx2jTE+xAt^a;JjYIV%qX zMCL6T^}|H4xp1+vg_u4?U~5TpID_F?_6MrKU6rNC=*TD$OV9nSKccbYP2Tb*Am+G* z=TixB>Ih(l^u)L(6D}|9`-jvY)?vSBYRJYmSR4gJ1~^SUWY{XzC0td*Z|z(;CS`dd zwkpKo`E$^N2lqry*`s3WnV6n=UuRP7Cp9jV{kwbvjA~X-`Akdg+Y+cnTnqn_(FSVK zIN?(JrQ&-Uh4j5%_p}_o_`x{fI03ZK;f!(Md%1>(>G)T8f%K55Vt6^S0`kCINx5Aq zwM@K=hUyeZ!J9ZaKXOP8Fw?>e%pOJJpE6f~hlfBAO| z59<8`5{@OE>sE^RLB;Iw2PI8eCugdHLx)w@uN~S4>)~O-Q2IyF+%Ff~utt~l=|}I_ zgcIq1+G7#j84E3svan+(W&KXB-G!@Le2*SC_nq0I>nCsHf~ZMh^@&{5k{8t82U8lW z^&Gvw?|T=0;(*D`x45FU4Ph=N-)}HcLE%}ko(g!wBIMb4DdBKyXO7lyK9KGjgl?1o zv$v|GV+*8xq_f$|17lRZqfmb{A)cekUv~{7OIx_3$32PgdEzQ6gqj3h&xF5ifK2UNPPAiBcPEv#7HibdN6J~?5PL%jT&R?$UO#L zGa|1i7wEXd@T`8Ls(g1U#-;wD0_-E7iz@NXnk(%n5j6{ zLq`1YPy`^<-#AQKU?bbKJHc&15=$o|-B4V?AL--R+;br3uvn}&ejgiJ+0yxGXE|Do z7@S$p*~*uAKr5}T)(x1x3etf~s(-qlHE)72UKG@7Zf1;386w!VzF#Fwz z95DXLqnlI+6F24)raEn&WT1}|iDEghu)d_YzGIsyv9Von+LAT|hI=9ClNfvu7);LV zIgm$#Z*R>PmHUS%=M-w$nNjae_WpkkjMMFS`$csHh<~4=jDU%hDTN)$D-gCoA2LmK z^4Prl!FX1-+**fLeA|vz8Z{;{Z@Z8+S!_Sg?j+bI-S~>wrubdNjt*!iOsOCUiIT_V z)&SWbh`%TH!)MeGbe}tbfH9r`0!Q;t9Ym5|#=DPkAM<{zm+MvTSa^okUzUVH-qJuu zH*SVqp5t8-t~{4uUoj#sKOpW{OL5uWNX~wcErO$uA^u);Ur+tU|F~x8gLL`gRt8=~ zh>UPlCxMVScQ#Q~w8!k4o(GvR>GZ0>a0(VDphD*7|cwP<2%4CPR_S%6yw5unqb;R0zNjj>aSK12QFC zjHwk?WK0$-T#md^Y)lgV)RWsJF!#|>zv5b7^FpvjI9wg+d@SvXtS}sDO z4Oq^>TJAERmx->6;?6P4wY>u|bNhyaii>={jgdT$OVKqy%ewGIsh zAnOXy(s}~-<5RnZ=xE+JnIu`n6{lnWkQ_n%fKKL#e^bw>4Rg2o#{WarSB6E|K5qj{ zcL>s*!b;cDT>=s!-5rukEh*g~ozkFybV#RkcP%B|NGbI$PyPPympzXCbkBXw%sJ=G zH8b}B{_odE-@9Uq2UToLJf(+fAcIhF8~w07Tm`=cETl-6z1!PqE_I_loCOFEY&K^P z<8J{>f7787jQzs6Fwx zj`+9KZ-L#+*Nm@KiU{l^F#Etl2@)P3wNb39wvPw>iKxW#>kKoFrl+|@nI^QbmRURR z`ti!_1&h&(f;PDO;BV`0Rn#KUC}3p)qz2~v4ik#%Fxu+3`&GUCI5h#tKGZQ-D(?_M zT;|;KB;nFac9I`vria7`sTT6T)=PhudenVxs8zzuYIyq!IP#s$%SXuNKzNTqXNf8+ z_v$Z-OHtjjMJ!Xqy~GNU%ChsI4mv^X7=2VT*$Z=hW$qa<0ViC-NQA+yxy60 zv+SP*D`xo=3&4rrG|m!$xM0`xgk;%j6X*gMeOjVLJ!W<(zK8;7Jo)b?)y$GK!@MhC zmkU>QT%hTveMOAnBdel^F0|Tb%#7&hy|j4F5c2)( zf#;=Q+1*StNF>k$2WFhO5i*zpgCi;5Q4*4R&SVr1!DsLro~n{$*!WB6KiiB^;Qva- zA*1l&4TJEvY*iHL@#Pg zC#>X!tMh}=(pr}Z^>hM&-(vqA5)V@^HO5v9D5PB0eAQdLRH>1LR+!Skt6)(0gD>I- z>7R&~W6W!>Jx&vz^TwXGpKvRmT)o819t{AeD!;;_@If`$o%)Q+IT;e3*FjTwu$idS z+8_!!*BtSk#zThX^Wd%O3WsFCWgW+o!Igs|MvGkQ=Y|nOSv?7(uXP;#xM-`ee_Tvk z4O0@IzYth*>wV~A)Hmn_{WQ&X;3(KK#51i0RX-)?SqW*gl9U5*DXhFA;IGlnIR&W| zSzSw=By)hS^owkUOuaMV?!6Kcw@21k+T(re)#lMg39tQGC@^m#0mlBU*CZCkg8^e) zc?GlZaGdh%xXcY^vH&k~vDe#<@89m1;*F1$(L+D-0RV*VueEnsel1^3=^bnP_%0A!ngxejh$&h4T9nD3=CrAyPd~4r2$9joFN-N^gG=J9(0;!R z_iL3H8e>OllfTy>3JH z-6AmocWqL8P`mcNHO+zlx2(IA(@(#C0e06V1RWh}uerH93~)BGJjFi*)GMp)ca2fg zcj{O+N?HzF0=%ot(*8V1MAuoPP%l~3ZC6#NPfFhiDx&xntE7$EFCt|+fhPg3KtReu8;kyQQ!R&K3WXgfQHv#K z$H(T|TcM&L8q%4XLUx~gKYEU(3ro|p$N+-rBi`2ppau{ejBV?GOkevJb|ft>pfJZL zQxql`dir9wSw)s0{v|7r?U$eVra|F$@zlpXYyi2G-Un7^>X$uMtj}qs316+nOZ$(_ z@eF&$)UJsikmE(-p~k$478(eRMdfDnFoS{HEHWkDceXR->miT(oCsT^kUZ9Fu{MCC z6#g5fgyM$20p0ty3Pxv4-xjdn2mC1jq@6_71gL|z*s~C z(^w*5u5OeBRKAGqqXWCqvc230O#;Wz!U`zTpmWJnyb9xzCwQf>$~svgR;Qdmp#O{X znJ~lwqd_ z?JRv{201A?wndFy5%X?DH)#5On?k6O7Vi|I{u*Nc1$Z&SG^IsN<16n2`IRNw|l?ph&T?!8rgz6-U<|-;y+iBKGhoxFs4Nn z6bfU)(qfa30u9L~^$iMc-GRnB$(<$UtpvEo6;{}AJXIFh#=Sw!X{B3X zaG=0N&-KCucOUTrrGF?8TY zR@an67upW1w0^{VPZHAjB5!x+a<1llFdZ`L~3I#5ri|#cV&l!EY;hTkXpY_%p z3)30IcMES;%wG^8dw0u>C;dWkrZv8sJ7^YXLcJrcdLrXig*b%r(~ClkLsdSU6N~J@ z;TyH94;|8O()_@y#DlVKgAFrH;ld^R;JW+TxDkEQdNNl>I;nY*kFCw}zVC0*#`M-w zJmgGpfVubLVk=xh;@Cne!GIDDf!LC| z$P<^Jq+$|q>+BRD>88(t?CZ%iIpP>9MDT47vA_o|#wg{Nz2L{f2F&~;3o&8yk&j`h%&$Nn zcUa6PXkw^tKuH1u4tn>a_Tne<){%M~gQj;TcG4lb*>@Fg9MQxxdl(%W4@TSugK9oD zub-3HqLhul-?1d3C&rL|*P6JyA?=IoI0KaB4|ZexUIc!UNG^M~xBGBfKqBRPJj{HC zA&N5&y9jb!41rgyC?Boi3ljB#6T7pFM5Jlq-wys(ZHx7YE;gr<#8BqEbdU^p6&@;g zLLx}Q&2SQf7ylL>x8ZPd@u0kJ^JP)FYhR?6w7=-<)GmC|EQLCnwSw7dYxon7*~M4? zz}l&{7``e=q;%DDl_Uuis}s6}PiUeWn}ZBq07GU^ zbmC+C`e2!z+$Np+ToHJ}N^#CzZN@_I4G_s0ktClJDwryY+Z6GRxz{X61()W6=(GX_ z_31^9SAj}!zmB^#`$SHf!9p`SV?8a;;^uMVdR(6uO_w-XicBlGvO+wV`i}@~DG3a| z*1kV71p^Mlqa2;cEt1BMduDtQZq9@VOV|jwh8L=Sz=yu!oX_(}kbG3x+8)B8YK(n0ZiR6LR~=Pp@)qi-i1s zBx?^P3>r5A zKJ0fT4^drXeBbaKTUmH9OQxLBEd7K=*+Tu7vi2sW(A5@s)N_z zYyCfc5G#QlG8o~iP4jb?bh)R!I&cm8e_L-D--U|0kL1i`H%mBOsU$s!Iy(FJr#Va> z>N!!$Pc^OaWNsh#_6%8fNvwteNP6~!K}?zGeGW4b;c&vUM?uJ8KdT8~3leqSx!cMJ zb)rfB@FcR9YYn%SOIc_#RVO1Spih0Qr~^2JvB!oi&i)HXFH1X8a5zNt{(6l=DwV$} zLI&@8TQiM?NT$N2XDr9p??<81SiT1UMa#QYOf7p*Md-Pgv-1MVc{_<6#LvbK^ zp(`Wh6w3B}7uHcvue&TJJ^yyrLmU9CwTo*m_;xo-A}*b4=@Rnuo|d{b?pTlqJz*QJ z;&#>f2ZH-MVYb~u#`_cPX*nTNCk3qA6pE#{IDW%x@xSl`v1#(RZtJpo?&IXA(gDeT z-4pt5uxt9)Aq#H<%wd)6!g7c}a;y@^uVow4_+IP`!ObvuBEXzbp+lJ-cSH{6`G*Yy z*|i`K%;j%JuXk@KeIcL&0oR}u_7ruDKa@ZHB$W0hAS<~@XcxpQyPP<>XKNE5J9O^i z|7mCYr;F~nBTlF%ln#Nm_kvvTGes;fO0ejMA;F`-30n|(QaM@P+3v95Po6LcZhIW| z#dp-iKO}hFa?BWm3u(uyIvqK(hBKDcBqhB?A5?KF8dm^u)xmv#ZC8UAv#%&HAJq z&Ed8*%au5?*=|Kg4}(jWPY{l{ic4;uD>pB}*nGGi>r2F)Zt49CcGU5pSy(&-1B~`Q2AIklzODRXdWE;B68lP~!5PQqt=w zEU6Gs^bZhJRHZ`a5=qQLLrmzovy<~p%(c>5^0n5aY{q=)C~ZN}BD}cE8zO->NOFso zI8mK`^A-Vo;58i`C=y0;4acIYl1&Rnc0+CNgJnQgfR~XI)|1kZ_t!Vp6oVtw!%mgY zTLid`xUvF0;7_HawosTlvBL0`*Aw5*8zS%mGn`U%u`RntFd`h%+|~``Ht8??z3J!{ zrF7xwDL;}lb*SYQqzbUH2I;9AF3W&%FgsXw*XX6BV-w)+q+DX1ETDi^Zq_i%0LG(n zxg5-~e6Pa@f9BuTFGS-t>!wHGmt;8$iU#4uYmBh8TUdno1Uss5uLfI+Uqcx!q&E9+ z0}r+y?TM=M!90H{cL#2L zc@1x(YeV|Yo!(e)%LPUhZy4DeLxwn?lgwu;Xs`J2n^uo>#QC_P--0+Ozr&@6ff0e_ zPMB}5onEzi$fK4qP|(dY7notxD&<;RK^$Jrig4KbG9^R>?pYuTU<~sL*-A?8p%h{t zsZ`Iatf=X6bL$cnpt`(1^EjSGPeTeZ5$rqdfbHZ1^{LFp(DP$>+BfLO?Fj(a19e-% z7n*suI2Asia5-3!c(oIAl(%i)5gp>AI_uuW6IK+YlbDlS`+0FKS$6Bh1kfeaDya~= z*~S_qtZZ9n3|9RPmj3~he`JSF_;nEZ3ptD>euQ?p&o-G$7fhen=_vHVfwn;F98^!u zn@)~{Y*?>-iq#fEhSSIWeg@OwlJPP6tA~^`E|RlkNxN}kAPvuG4nhTlgL{DM=;6Y= zF0dZHh`5}f*OBO<*a=r_o>-ZCBbQ5i*@Rx#^0B=4_BBi8esKXqMl1D_RubM>^6s#H zZlyMDbZjh8+HDq)qAPM=lVri8u?pxJ@VFT5uFi6Hl2EQ6#}Q-R$-uBJ<0`P=jHKRe ztHjh+VtJ(+Kl*VJ?V#CNJ4l9auuo4^d@8rz?7pJRjt4iV&K)V)PwD8&$|*4dRbRT@Skm8+03c4i4=GM{6+K_h->6;7PQNi{g>;IWID{ow!&mcu zg1;g4i>U~c9p(-OYkaxC(4QKGwX{EsX7apx=!8C_2=RBEEpcZxq>Oj zK}n?rY#RqUS?;z}1)Kj*O5j1XGjR%R)>bY5U`U2HzE6%`f=MuBdRpjoIjNpAmo%BTpJHZ?sYABUgD?hrLZX#txZK)!!2}MJJ^|b zR;SFRa7{F$JUbVZT~(GW!n6^V!^T) zcW;goH}#jr&EvV>v?R)BUdAhvR(;I=5 zfn#19*T2Pk#8*DN%8mv#zx>iHvQR_oUB#Co-&;FCzYEm-18fUN4sgXRcaV1UGW)T@ zSxEeURcxigVUPXYFd_SG*x81NzxaYyu{F0m!Op(!P&0>?@YLLj6N&<{hLlk^srfhWg z#H`c;L#no=VRJ}4ZNdFJ4!nx6A=xr+LYcR%6=_LD>UmXe;pH-E zfiQc!_GBU{HlMVcLad47*l4R87R7|vu%)tpRN&mTqs0+DS2);k zuw+3MDpFtZzoU{29#;deHTWmpd*4^ELa?q!?cH?wmI7V^UB%_Qa*y+SnSxPCw*Uu`i%%_1=y~-@oYpPYca$f?-q#s>y*gn4W92DB z4U7My(gvloxZeI(331k@;W=faJi2}|KI@p#n@f|Ly6c^gPOV{>Or{JQ4$zVyP}8-|Hg}mo(W>cOuLD~2(vbl9LVUB)`+!EG;O%Xt*j}oIXhE>-a>O* z6~L5ScNLuS%b$|Z`DE0dP<)-BWw z&Y=EAc&Bm~Z!{?zlZ7N=tAY0Iv-x6W-H2!poO*J`2JSq&d8vG$Tzg{L2KW6}{Y3-q zM7hw@CX_nhe`0T9yun=a%dc3DLV!^n4vw*Y$9zC9nwLO)U(aPSeV9OSKLSNTy;ALz zI~1>nbT8aWZv>H2@98@nw%!*RX=YYkIqrXB-LbN80p(e1IkKf3fe@0Ds4VgLs@>15 zJyH>WIB4GI_*yArULsB1dp>Y^`7Dn0Z^vCq(Ft43R7h5Xiukc1Dh2BX-j%c{V-^~D%h&ZY& z6cy2*eC((Uq9kYbgGMZ$UH)8UbD8w^2YS!4%%YLJhBSLK=KY{Zu7io}u;~kltZtzQ z64d261hzau^&6nYG6LVNS$ushV(>)G;%$<22ZPFisqvsb>44=2?0H?r`dHJyJ%7-8 zInjuf{8n;t2s+u@@G%DA!8&fL`;&gP`Au%psUG{=*w`d5-Bt?ePFQ4_9~8+UX~0SX zn&{TFn}L(uLSYcu(3(h$lV4C7xQTk0&1*3*wtw3K5nt|(PU&O*C=lX-8)W-)dn3;P zxia_~gK3_AWiM^&BW`^db88Dy+Bp}Hjg^x5Brg#n%x(Ya@AOT-$CdT1gJ~!S+xe z*(=-qcvZ`*cJu4!%A)auANCc^NchjT&xx+;iETd&9nYJR+zsCBeIC3ZBVLy#tnC8Y zPk=tAV<(!Nz7#5OV29#i^RFCRH@E3mvqyrhi?)4Phsn>zpee4G>twKRRn)FqcXI%O zwqKc+)cKqaxx^?7ibk@b*F zO=0iAd6pd7bQb6ua94ehb!^ynuM@GKiJQA2mI);S`NZfL6lO( zr@rJ^kMf@rrHvi6aI%`Cge7R`PWr`hj~t9Vs~8WxVB;e0JPQw$fa8Shz_YeH4(!rQ zJZ=h%@|f=+@AMgejf+k$6p*Ff4@;OMY^J2{8hTdC5pWA3yRQ#R=cCaW`iRS>wq#WB zpQZG6>YkXF)ftn#MtFph*+elC2(WkB!dH_IIOz?6YI@mtyZuQiNNNxjjiCow3>Q(v zhOdj-lwesivP*XkyKlxxh~FltYASuArVMbFT}lJjaPbz$$7~hvTp1k2@pGmp_)%yI z`)HK%pfmujg6{kF_V<^DM+rV}1isQ71T(f83FxgpLNM%fmZpD|)>V<*M3bq{B{0xp zv*{wd`OaBv&??o=lQGv@84ifCy?tLmC$)o_KVH*7Qd2Z9mOM3A)Esu$omprw4SnbZ z(#Tsc6R>8Z0b2y&v>T%We&*1&Xt+wP;=w8j8q61k+0;zJjF|7Lbjc6Zrj zW?3()&q-75>)08@&LE~9o=i<$+E$678ol3&8)*%9pyP`K*yM|LY!j@o7A?@p9Ms|B zCLN7`jQ6~l@J2nw$JQn~wRT9WvZ@=4iE0dpV7)?Z_5Vf> z&yYzF^(W!48|7GFZqwpT3d|aC=ufT*DzGfvwUS&m`TB=GT61mt|C3~~aE8(w)f)g$Y^QOZ@i7}LciCR0 z6`YD$jU4ip4PV1@7yc=#{HKr$wA-pOUH(#zsu}LXuz?=^gUkRuQcWSPTP|+}g!A73 z;b4(;j>AS_5Cee0X3bEEm4c0lxZy`&Z7Qr8-zvfV9N!;Uq693+1DL?v43> z(V?3R_9~rGMXjMO@x^S5P^)!}Bv?~s2ZdxmrV&vchNu7!K099fuT5AuPnkrE$5FId z>t@!e-fLug=7jeKUCZ5p4qxZ<20v|U`5aMBG;sVwx`L0$opNoCn+s0D4PCIhGv=_P z=Pc>U+c_*R=>%E2FX8FC_nM5UL1bCmraqEQb8Y`6>E01VjC{iYDBCtK4T`mGo`=(1 zbmen1(xW?n@K%>%OD+{@;G)hiQF`Ro@_ebp(J4HGss<5rHS{b+D~I>bNzjD{5?;Nz z_qlT9;e`1h><=;jP1W>p&dF#udjl$LuQOip+uAbZaE7M70;(>G8N&Mp z!L-X(gXCkEZ?L!Ti)JhcCy3;#6JG3f8ySop51ckYcVi-YCWUbx7Y$B(43wcFJfqQm zmGcI*4TOJHJT_mb5L-0rEyq)UA<`}pSB&RRZS=h;^$7?nn8A%=MnO+~okOB%g4;;U z576%b?tBYWO^t((Z1v1w?1I#AxkD=WA_r@%mF%L zkU%FEUORnDcpEi{A*#(Iw6qWtXl?{^vzyEP_YFj8JR@93yeal*8|vCX6kQnfESQN$ z-I%gvCM1ea2zGB6BT%3fm{OCcPRx5k>kUrEi@1fsKCY zQVqM$x`X2^JnJ}~^AH{_p^Ph8%U(@NwuZq{K}(K(u_hR{{^w={vdpHGbh`05se!HF zxT;Cak>qOxMMeXz6#+-9q>Weaqgq$W;;?n-ZOlviRFB9w7732Lpkt;Eo~qeNTiANK zK$A-Nm<`A`wP~qtPT#T((t5&wD8B$rw*sn-j_Go2J=UUHV=+Io+@L#jO6Hi=g4
DJ*`trAiv3hD_b^$+XeDqKt`D1&GH5qx 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%0Dy&l2@60*gnqhoo4E%7d;zi&Vj6A+ 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 Kkt&xk4*EZn*WASb literal 0 HcmV?d00001 diff --git a/src/web/static/fonts/bmfonts/RobotoMono72White.fnt b/src/web/static/fonts/bmfonts/RobotoMono72White.fnt new file mode 100644 index 00000000..f280314d --- /dev/null +++ b/src/web/static/fonts/bmfonts/RobotoMono72White.fnt @@ -0,0 +1,103 @@ +info face="Roboto Mono" size=72 bold=0 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=1,1,1,1 spacing=-2,-2 +common lineHeight=96 base=76 scaleW=512 scaleH=512 pages=1 packed=0 +page id=0 file="images/RobotoMono72White.png" +chars count=98 +char id=0 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=75 xadvance=0 page=0 chnl=0 +char id=10 x=0 y=0 width=45 height=99 xoffset=-1 yoffset=-2 xadvance=43 page=0 chnl=0 +char id=32 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=75 xadvance=43 page=0 chnl=0 +char id=33 x=498 y=99 width=10 height=55 xoffset=16 yoffset=23 xadvance=43 page=0 chnl=0 +char id=34 x=434 y=319 width=20 height=19 xoffset=11 yoffset=21 xadvance=43 page=0 chnl=0 +char id=35 x=175 y=265 width=41 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=36 x=200 y=0 width=35 height=69 xoffset=5 yoffset=15 xadvance=43 page=0 chnl=0 +char id=37 x=0 y=155 width=42 height=56 xoffset=1 yoffset=22 xadvance=44 page=0 chnl=0 +char id=38 x=42 y=155 width=41 height=56 xoffset=3 yoffset=22 xadvance=44 page=0 chnl=0 +char id=39 x=502 y=211 width=7 height=19 xoffset=16 yoffset=21 xadvance=43 page=0 chnl=0 +char id=40 x=45 y=0 width=21 height=78 xoffset=12 yoffset=16 xadvance=44 page=0 chnl=0 +char id=41 x=66 y=0 width=22 height=78 xoffset=9 yoffset=16 xadvance=43 page=0 chnl=0 +char id=42 x=256 y=319 width=37 height=37 xoffset=4 yoffset=32 xadvance=43 page=0 chnl=0 +char id=43 x=219 y=319 width=37 height=40 xoffset=3 yoffset=32 xadvance=43 page=0 chnl=0 +char id=44 x=421 y=319 width=13 height=22 xoffset=11 yoffset=67 xadvance=43 page=0 chnl=0 +char id=45 x=17 y=360 width=29 height=8 xoffset=7 yoffset=49 xadvance=44 page=0 chnl=0 +char id=46 x=496 y=319 width=12 height=13 xoffset=16 yoffset=65 xadvance=43 page=0 chnl=0 +char id=47 x=319 y=0 width=31 height=58 xoffset=7 yoffset=23 xadvance=43 page=0 chnl=0 +char id=48 x=431 y=99 width=35 height=56 xoffset=4 yoffset=22 xadvance=43 page=0 chnl=0 +char id=49 x=36 y=265 width=23 height=54 xoffset=6 yoffset=23 xadvance=44 page=0 chnl=0 +char id=50 x=189 y=155 width=37 height=55 xoffset=2 yoffset=22 xadvance=44 page=0 chnl=0 +char id=51 x=361 y=99 width=35 height=56 xoffset=2 yoffset=22 xadvance=43 page=0 chnl=0 +char id=52 x=59 y=265 width=39 height=54 xoffset=2 yoffset=23 xadvance=44 page=0 chnl=0 +char id=53 x=226 y=155 width=35 height=55 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=54 x=261 y=155 width=35 height=55 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=55 x=98 y=265 width=37 height=54 xoffset=3 yoffset=23 xadvance=44 page=0 chnl=0 +char id=56 x=396 y=99 width=35 height=56 xoffset=5 yoffset=22 xadvance=43 page=0 chnl=0 +char id=57 x=296 y=155 width=34 height=55 xoffset=4 yoffset=22 xadvance=43 page=0 chnl=0 +char id=58 x=490 y=211 width=12 height=43 xoffset=18 yoffset=35 xadvance=43 page=0 chnl=0 +char id=59 x=486 y=0 width=14 height=55 xoffset=16 yoffset=35 xadvance=43 page=0 chnl=0 +char id=60 x=293 y=319 width=32 height=35 xoffset=5 yoffset=36 xadvance=43 page=0 chnl=0 +char id=61 x=388 y=319 width=33 height=23 xoffset=5 yoffset=41 xadvance=43 page=0 chnl=0 +char id=62 x=325 y=319 width=33 height=35 xoffset=5 yoffset=36 xadvance=43 page=0 chnl=0 +char id=63 x=466 y=99 width=32 height=56 xoffset=6 yoffset=22 xadvance=43 page=0 chnl=0 +char id=64 x=135 y=265 width=40 height=54 xoffset=1 yoffset=23 xadvance=42 page=0 chnl=0 +char id=65 x=330 y=155 width=42 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=66 x=372 y=155 width=35 height=54 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=67 x=448 y=0 width=38 height=56 xoffset=3 yoffset=22 xadvance=43 page=0 chnl=0 +char id=68 x=407 y=155 width=37 height=54 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=69 x=444 y=155 width=34 height=54 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=70 x=0 y=211 width=34 height=54 xoffset=6 yoffset=23 xadvance=44 page=0 chnl=0 +char id=71 x=0 y=99 width=38 height=56 xoffset=3 yoffset=22 xadvance=44 page=0 chnl=0 +char id=72 x=34 y=211 width=36 height=54 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=73 x=478 y=155 width=33 height=54 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=74 x=83 y=155 width=36 height=55 xoffset=2 yoffset=23 xadvance=43 page=0 chnl=0 +char id=75 x=70 y=211 width=38 height=54 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=76 x=108 y=211 width=34 height=54 xoffset=6 yoffset=23 xadvance=43 page=0 chnl=0 +char id=77 x=142 y=211 width=36 height=54 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=78 x=178 y=211 width=35 height=54 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=79 x=38 y=99 width=38 height=56 xoffset=3 yoffset=22 xadvance=43 page=0 chnl=0 +char id=80 x=213 y=211 width=36 height=54 xoffset=6 yoffset=23 xadvance=43 page=0 chnl=0 +char id=81 x=242 y=0 width=40 height=64 xoffset=2 yoffset=22 xadvance=43 page=0 chnl=0 +char id=82 x=249 y=211 width=36 height=54 xoffset=5 yoffset=23 xadvance=43 page=0 chnl=0 +char id=83 x=76 y=99 width=38 height=56 xoffset=3 yoffset=22 xadvance=44 page=0 chnl=0 +char id=84 x=285 y=211 width=40 height=54 xoffset=2 yoffset=23 xadvance=44 page=0 chnl=0 +char id=85 x=119 y=155 width=36 height=55 xoffset=4 yoffset=23 xadvance=43 page=0 chnl=0 +char id=86 x=325 y=211 width=41 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=87 x=366 y=211 width=42 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=88 x=408 y=211 width=41 height=54 xoffset=2 yoffset=23 xadvance=43 page=0 chnl=0 +char id=89 x=449 y=211 width=41 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=90 x=0 y=265 width=36 height=54 xoffset=3 yoffset=23 xadvance=43 page=0 chnl=0 +char id=91 x=88 y=0 width=16 height=72 xoffset=14 yoffset=16 xadvance=43 page=0 chnl=0 +char id=92 x=350 y=0 width=30 height=58 xoffset=7 yoffset=23 xadvance=43 page=0 chnl=0 +char id=93 x=104 y=0 width=17 height=72 xoffset=13 yoffset=16 xadvance=44 page=0 chnl=0 +char id=94 x=358 y=319 width=30 height=30 xoffset=7 yoffset=23 xadvance=43 page=0 chnl=0 +char id=95 x=46 y=360 width=34 height=8 xoffset=4 yoffset=74 xadvance=43 page=0 chnl=0 +char id=96 x=0 y=360 width=17 height=12 xoffset=13 yoffset=22 xadvance=43 page=0 chnl=0 +char id=97 x=251 y=265 width=35 height=42 xoffset=4 yoffset=36 xadvance=43 page=0 chnl=0 +char id=98 x=380 y=0 width=34 height=57 xoffset=5 yoffset=21 xadvance=43 page=0 chnl=0 +char id=99 x=286 y=265 width=35 height=42 xoffset=4 yoffset=36 xadvance=43 page=0 chnl=0 +char id=100 x=414 y=0 width=34 height=57 xoffset=4 yoffset=21 xadvance=43 page=0 chnl=0 +char id=101 x=321 y=265 width=36 height=42 xoffset=4 yoffset=36 xadvance=43 page=0 chnl=0 +char id=102 x=282 y=0 width=37 height=58 xoffset=4 yoffset=19 xadvance=43 page=0 chnl=0 +char id=103 x=114 y=99 width=34 height=56 xoffset=4 yoffset=36 xadvance=43 page=0 chnl=0 +char id=104 x=148 y=99 width=34 height=56 xoffset=5 yoffset=21 xadvance=43 page=0 chnl=0 +char id=105 x=155 y=155 width=34 height=55 xoffset=6 yoffset=22 xadvance=43 page=0 chnl=0 +char id=106 x=121 y=0 width=26 height=71 xoffset=6 yoffset=22 xadvance=44 page=0 chnl=0 +char id=107 x=182 y=99 width=36 height=56 xoffset=5 yoffset=21 xadvance=43 page=0 chnl=0 +char id=108 x=218 y=99 width=34 height=56 xoffset=6 yoffset=21 xadvance=43 page=0 chnl=0 +char id=109 x=428 y=265 width=39 height=41 xoffset=2 yoffset=36 xadvance=43 page=0 chnl=0 +char id=110 x=467 y=265 width=34 height=41 xoffset=5 yoffset=36 xadvance=43 page=0 chnl=0 +char id=111 x=357 y=265 width=37 height=42 xoffset=3 yoffset=36 xadvance=43 page=0 chnl=0 +char id=112 x=252 y=99 width=34 height=56 xoffset=5 yoffset=36 xadvance=43 page=0 chnl=0 +char id=113 x=286 y=99 width=34 height=56 xoffset=4 yoffset=36 xadvance=43 page=0 chnl=0 +char id=114 x=0 y=319 width=29 height=41 xoffset=11 yoffset=36 xadvance=44 page=0 chnl=0 +char id=115 x=394 y=265 width=34 height=42 xoffset=5 yoffset=36 xadvance=43 page=0 chnl=0 +char id=116 x=216 y=265 width=35 height=51 xoffset=4 yoffset=27 xadvance=43 page=0 chnl=0 +char id=117 x=29 y=319 width=33 height=41 xoffset=5 yoffset=37 xadvance=43 page=0 chnl=0 +char id=118 x=62 y=319 width=39 height=40 xoffset=2 yoffset=37 xadvance=43 page=0 chnl=0 +char id=119 x=101 y=319 width=43 height=40 xoffset=0 yoffset=37 xadvance=43 page=0 chnl=0 +char id=120 x=144 y=319 width=40 height=40 xoffset=2 yoffset=37 xadvance=43 page=0 chnl=0 +char id=121 x=320 y=99 width=41 height=56 xoffset=1 yoffset=37 xadvance=43 page=0 chnl=0 +char id=122 x=184 y=319 width=35 height=40 xoffset=5 yoffset=37 xadvance=44 page=0 chnl=0 +char id=123 x=147 y=0 width=26 height=71 xoffset=10 yoffset=19 xadvance=43 page=0 chnl=0 +char id=124 x=235 y=0 width=7 height=68 xoffset=18 yoffset=23 xadvance=43 page=0 chnl=0 +char id=125 x=173 y=0 width=27 height=71 xoffset=10 yoffset=19 xadvance=44 page=0 chnl=0 +char id=126 x=454 y=319 width=42 height=16 xoffset=1 yoffset=47 xadvance=44 page=0 chnl=0 +char id=127 x=0 y=0 width=45 height=99 xoffset=-1 yoffset=-2 xadvance=43 page=0 chnl=0 +kernings count=0 diff --git a/src/web/static/fonts/bmfonts/RobotoMono72White.png b/src/web/static/fonts/bmfonts/RobotoMono72White.png new file mode 100644 index 0000000000000000000000000000000000000000..ed192363be386b4fc5f532b629a71b37802323fe GIT binary patch literal 52580 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 I*(&&d0pzTe1^@s6 literal 0 HcmV?d00001 diff --git a/src/web/static/fonts/bmfonts/RobotoSlab72White.fnt b/src/web/static/fonts/bmfonts/RobotoSlab72White.fnt new file mode 100644 index 00000000..71e1b131 --- /dev/null +++ b/src/web/static/fonts/bmfonts/RobotoSlab72White.fnt @@ -0,0 +1,492 @@ +info face="Roboto Slab Regular" size=72 bold=0 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=1,1,1,1 spacing=-2,-2 +common lineHeight=96 base=76 scaleW=512 scaleH=512 pages=1 packed=0 +page id=0 file="images/RobotoSlab72White.png" +chars count=98 +char id=0 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=75 xadvance=0 page=0 chnl=0 +char id=10 x=0 y=0 width=70 height=98 xoffset=0 yoffset=-1 xadvance=70 page=0 chnl=0 +char id=32 x=0 y=0 width=0 height=0 xoffset=-1 yoffset=75 xadvance=18 page=0 chnl=0 +char id=33 x=497 y=156 width=9 height=54 xoffset=4 yoffset=23 xadvance=17 page=0 chnl=0 +char id=34 x=191 y=362 width=19 height=20 xoffset=5 yoffset=20 xadvance=28 page=0 chnl=0 +char id=35 x=406 y=266 width=41 height=54 xoffset=1 yoffset=23 xadvance=43 page=0 chnl=0 +char id=36 x=212 y=0 width=35 height=69 xoffset=2 yoffset=15 xadvance=39 page=0 chnl=0 +char id=37 x=174 y=156 width=48 height=56 xoffset=2 yoffset=22 xadvance=52 page=0 chnl=0 +char id=38 x=222 y=156 width=44 height=56 xoffset=2 yoffset=22 xadvance=46 page=0 chnl=0 +char id=39 x=210 y=362 width=8 height=20 xoffset=5 yoffset=20 xadvance=17 page=0 chnl=0 +char id=40 x=70 y=0 width=21 height=77 xoffset=3 yoffset=17 xadvance=23 page=0 chnl=0 +char id=41 x=91 y=0 width=21 height=77 xoffset=-1 yoffset=17 xadvance=23 page=0 chnl=0 +char id=42 x=100 y=362 width=31 height=33 xoffset=1 yoffset=23 xadvance=33 page=0 chnl=0 +char id=43 x=0 y=362 width=37 height=40 xoffset=2 yoffset=32 xadvance=41 page=0 chnl=0 +char id=44 x=492 y=320 width=13 height=21 xoffset=-1 yoffset=67 xadvance=14 page=0 chnl=0 +char id=45 x=287 y=362 width=19 height=8 xoffset=4 yoffset=50 xadvance=27 page=0 chnl=0 +char id=46 x=278 y=362 width=9 height=9 xoffset=4 yoffset=68 xadvance=17 page=0 chnl=0 +char id=47 x=470 y=0 width=30 height=58 xoffset=-1 yoffset=23 xadvance=29 page=0 chnl=0 +char id=48 x=139 y=156 width=35 height=56 xoffset=3 yoffset=22 xadvance=41 page=0 chnl=0 +char id=49 x=305 y=266 width=25 height=54 xoffset=3 yoffset=23 xadvance=30 page=0 chnl=0 +char id=50 x=357 y=156 width=36 height=55 xoffset=2 yoffset=22 xadvance=40 page=0 chnl=0 +char id=51 x=0 y=156 width=34 height=56 xoffset=2 yoffset=22 xadvance=39 page=0 chnl=0 +char id=52 x=330 y=266 width=39 height=54 xoffset=1 yoffset=23 xadvance=42 page=0 chnl=0 +char id=53 x=393 y=156 width=33 height=55 xoffset=2 yoffset=23 xadvance=37 page=0 chnl=0 +char id=54 x=34 y=156 width=35 height=56 xoffset=3 yoffset=22 xadvance=40 page=0 chnl=0 +char id=55 x=369 y=266 width=37 height=54 xoffset=2 yoffset=23 xadvance=40 page=0 chnl=0 +char id=56 x=69 y=156 width=35 height=56 xoffset=2 yoffset=22 xadvance=39 page=0 chnl=0 +char id=57 x=104 y=156 width=35 height=56 xoffset=2 yoffset=22 xadvance=41 page=0 chnl=0 +char id=58 x=500 y=0 width=9 height=40 xoffset=4 yoffset=37 xadvance=15 page=0 chnl=0 +char id=59 x=447 y=266 width=13 height=52 xoffset=0 yoffset=37 xadvance=15 page=0 chnl=0 +char id=60 x=37 y=362 width=31 height=35 xoffset=2 yoffset=39 xadvance=36 page=0 chnl=0 +char id=61 x=160 y=362 width=31 height=23 xoffset=4 yoffset=40 xadvance=39 page=0 chnl=0 +char id=62 x=68 y=362 width=32 height=35 xoffset=3 yoffset=39 xadvance=37 page=0 chnl=0 +char id=63 x=480 y=98 width=31 height=55 xoffset=1 yoffset=22 xadvance=33 page=0 chnl=0 +char id=64 x=247 y=0 width=60 height=68 xoffset=1 yoffset=25 xadvance=64 page=0 chnl=0 +char id=65 x=426 y=156 width=51 height=54 xoffset=1 yoffset=23 xadvance=53 page=0 chnl=0 +char id=66 x=0 y=212 width=44 height=54 xoffset=1 yoffset=23 xadvance=47 page=0 chnl=0 +char id=67 x=191 y=98 width=42 height=56 xoffset=1 yoffset=22 xadvance=46 page=0 chnl=0 +char id=68 x=44 y=212 width=46 height=54 xoffset=1 yoffset=23 xadvance=50 page=0 chnl=0 +char id=69 x=90 y=212 width=42 height=54 xoffset=1 yoffset=23 xadvance=46 page=0 chnl=0 +char id=70 x=132 y=212 width=42 height=54 xoffset=1 yoffset=23 xadvance=44 page=0 chnl=0 +char id=71 x=233 y=98 width=43 height=56 xoffset=1 yoffset=22 xadvance=49 page=0 chnl=0 +char id=72 x=174 y=212 width=52 height=54 xoffset=1 yoffset=23 xadvance=55 page=0 chnl=0 +char id=73 x=477 y=156 width=20 height=54 xoffset=1 yoffset=23 xadvance=22 page=0 chnl=0 +char id=74 x=266 y=156 width=39 height=55 xoffset=1 yoffset=23 xadvance=41 page=0 chnl=0 +char id=75 x=226 y=212 width=48 height=54 xoffset=1 yoffset=23 xadvance=50 page=0 chnl=0 +char id=76 x=274 y=212 width=39 height=54 xoffset=1 yoffset=23 xadvance=42 page=0 chnl=0 +char id=77 x=313 y=212 width=64 height=54 xoffset=1 yoffset=23 xadvance=66 page=0 chnl=0 +char id=78 x=377 y=212 width=52 height=54 xoffset=1 yoffset=23 xadvance=54 page=0 chnl=0 +char id=79 x=276 y=98 width=47 height=56 xoffset=2 yoffset=22 xadvance=51 page=0 chnl=0 +char id=80 x=429 y=212 width=43 height=54 xoffset=1 yoffset=23 xadvance=45 page=0 chnl=0 +char id=81 x=307 y=0 width=48 height=64 xoffset=2 yoffset=22 xadvance=51 page=0 chnl=0 +char id=82 x=0 y=266 width=46 height=54 xoffset=1 yoffset=23 xadvance=48 page=0 chnl=0 +char id=83 x=323 y=98 width=38 height=56 xoffset=3 yoffset=22 xadvance=43 page=0 chnl=0 +char id=84 x=46 y=266 width=45 height=54 xoffset=0 yoffset=23 xadvance=45 page=0 chnl=0 +char id=85 x=305 y=156 width=52 height=55 xoffset=1 yoffset=23 xadvance=54 page=0 chnl=0 +char id=86 x=91 y=266 width=50 height=54 xoffset=1 yoffset=23 xadvance=52 page=0 chnl=0 +char id=87 x=141 y=266 width=67 height=54 xoffset=0 yoffset=23 xadvance=67 page=0 chnl=0 +char id=88 x=208 y=266 width=49 height=54 xoffset=1 yoffset=23 xadvance=51 page=0 chnl=0 +char id=89 x=257 y=266 width=48 height=54 xoffset=1 yoffset=23 xadvance=50 page=0 chnl=0 +char id=90 x=472 y=212 width=38 height=54 xoffset=2 yoffset=23 xadvance=42 page=0 chnl=0 +char id=91 x=180 y=0 width=16 height=72 xoffset=5 yoffset=16 xadvance=21 page=0 chnl=0 +char id=92 x=0 y=98 width=31 height=58 xoffset=0 yoffset=23 xadvance=30 page=0 chnl=0 +char id=93 x=196 y=0 width=16 height=72 xoffset=-1 yoffset=16 xadvance=19 page=0 chnl=0 +char id=94 x=131 y=362 width=29 height=28 xoffset=1 yoffset=23 xadvance=30 page=0 chnl=0 +char id=95 x=306 y=362 width=34 height=8 xoffset=3 yoffset=74 xadvance=40 page=0 chnl=0 +char id=96 x=260 y=362 width=18 height=12 xoffset=1 yoffset=22 xadvance=20 page=0 chnl=0 +char id=97 x=0 y=320 width=36 height=42 xoffset=3 yoffset=36 xadvance=41 page=0 chnl=0 +char id=98 x=363 y=0 width=41 height=58 xoffset=-2 yoffset=20 xadvance=42 page=0 chnl=0 +char id=99 x=36 y=320 width=34 height=42 xoffset=2 yoffset=36 xadvance=39 page=0 chnl=0 +char id=100 x=404 y=0 width=40 height=58 xoffset=2 yoffset=20 xadvance=43 page=0 chnl=0 +char id=101 x=70 y=320 width=34 height=42 xoffset=2 yoffset=36 xadvance=39 page=0 chnl=0 +char id=102 x=444 y=0 width=26 height=58 xoffset=1 yoffset=19 xadvance=25 page=0 chnl=0 +char id=103 x=31 y=98 width=34 height=57 xoffset=2 yoffset=36 xadvance=40 page=0 chnl=0 +char id=104 x=65 y=98 width=44 height=57 xoffset=1 yoffset=20 xadvance=46 page=0 chnl=0 +char id=105 x=109 y=98 width=20 height=57 xoffset=2 yoffset=20 xadvance=23 page=0 chnl=0 +char id=106 x=112 y=0 width=18 height=73 xoffset=-2 yoffset=20 xadvance=20 page=0 chnl=0 +char id=107 x=129 y=98 width=42 height=57 xoffset=1 yoffset=20 xadvance=44 page=0 chnl=0 +char id=108 x=171 y=98 width=20 height=57 xoffset=1 yoffset=20 xadvance=22 page=0 chnl=0 +char id=109 x=171 y=320 width=66 height=41 xoffset=1 yoffset=36 xadvance=68 page=0 chnl=0 +char id=110 x=237 y=320 width=44 height=41 xoffset=1 yoffset=36 xadvance=46 page=0 chnl=0 +char id=111 x=104 y=320 width=36 height=42 xoffset=2 yoffset=36 xadvance=40 page=0 chnl=0 +char id=112 x=361 y=98 width=40 height=56 xoffset=1 yoffset=36 xadvance=43 page=0 chnl=0 +char id=113 x=401 y=98 width=39 height=56 xoffset=2 yoffset=36 xadvance=40 page=0 chnl=0 +char id=114 x=484 y=266 width=27 height=41 xoffset=2 yoffset=36 xadvance=30 page=0 chnl=0 +char id=115 x=140 y=320 width=31 height=42 xoffset=3 yoffset=36 xadvance=36 page=0 chnl=0 +char id=116 x=460 y=266 width=24 height=51 xoffset=1 yoffset=27 xadvance=26 page=0 chnl=0 +char id=117 x=281 y=320 width=43 height=41 xoffset=0 yoffset=37 xadvance=44 page=0 chnl=0 +char id=118 x=324 y=320 width=39 height=40 xoffset=0 yoffset=37 xadvance=40 page=0 chnl=0 +char id=119 x=363 y=320 width=57 height=40 xoffset=1 yoffset=37 xadvance=59 page=0 chnl=0 +char id=120 x=420 y=320 width=40 height=40 xoffset=1 yoffset=37 xadvance=42 page=0 chnl=0 +char id=121 x=440 y=98 width=40 height=56 xoffset=0 yoffset=37 xadvance=41 page=0 chnl=0 +char id=122 x=460 y=320 width=32 height=40 xoffset=3 yoffset=37 xadvance=38 page=0 chnl=0 +char id=123 x=130 y=0 width=25 height=73 xoffset=1 yoffset=18 xadvance=25 page=0 chnl=0 +char id=124 x=355 y=0 width=8 height=63 xoffset=4 yoffset=23 xadvance=16 page=0 chnl=0 +char id=125 x=155 y=0 width=25 height=73 xoffset=-1 yoffset=18 xadvance=25 page=0 chnl=0 +char id=126 x=218 y=362 width=42 height=16 xoffset=3 yoffset=47 xadvance=49 page=0 chnl=0 +char id=127 x=0 y=0 width=70 height=98 xoffset=0 yoffset=-1 xadvance=70 page=0 chnl=0 +kernings count=389 +kerning first=86 second=45 amount=-1 +kerning first=114 second=46 amount=-4 +kerning first=40 second=87 amount=1 +kerning first=70 second=99 amount=-1 +kerning first=84 second=110 amount=-3 +kerning first=114 second=116 amount=1 +kerning first=39 second=65 amount=-4 +kerning first=104 second=34 amount=-1 +kerning first=89 second=71 amount=-1 +kerning first=107 second=113 amount=-1 +kerning first=78 second=88 amount=1 +kerning first=109 second=39 amount=-1 +kerning first=120 second=100 amount=-1 +kerning first=84 second=100 amount=-3 +kerning first=68 second=90 amount=-1 +kerning first=68 second=44 amount=-4 +kerning first=84 second=103 amount=-3 +kerning first=34 second=97 amount=-2 +kerning first=70 second=97 amount=-1 +kerning first=76 second=81 amount=-2 +kerning first=73 second=89 amount=-1 +kerning first=84 second=44 amount=-8 +kerning first=68 second=65 amount=-3 +kerning first=97 second=34 amount=-2 +kerning first=111 second=121 amount=-1 +kerning first=79 second=90 amount=-1 +kerning first=75 second=121 amount=-1 +kerning first=75 second=118 amount=-1 +kerning first=111 second=118 amount=-1 +kerning first=89 second=65 amount=-9 +kerning first=75 second=71 amount=-4 +kerning first=39 second=99 amount=-2 +kerning first=75 second=99 amount=-1 +kerning first=90 second=121 amount=-1 +kerning first=44 second=39 amount=-6 +kerning first=89 second=46 amount=-7 +kerning first=89 second=74 amount=-7 +kerning first=34 second=103 amount=-2 +kerning first=70 second=103 amount=-1 +kerning first=112 second=39 amount=-1 +kerning first=122 second=113 amount=-1 +kerning first=86 second=113 amount=-2 +kerning first=68 second=84 amount=-1 +kerning first=89 second=110 amount=-1 +kerning first=34 second=100 amount=-2 +kerning first=68 second=86 amount=-1 +kerning first=87 second=45 amount=-2 +kerning first=39 second=34 amount=-4 +kerning first=114 second=100 amount=-1 +kerning first=84 second=81 amount=-1 +kerning first=70 second=101 amount=-1 +kerning first=68 second=89 amount=-2 +kerning first=88 second=117 amount=-1 +kerning first=112 second=34 amount=-1 +kerning first=76 second=67 amount=-2 +kerning first=76 second=34 amount=-5 +kerning first=88 second=111 amount=-1 +kerning first=66 second=86 amount=-1 +kerning first=66 second=89 amount=-2 +kerning first=122 second=101 amount=-1 +kerning first=86 second=101 amount=-2 +kerning first=76 second=121 amount=-5 +kerning first=84 second=119 amount=-2 +kerning first=84 second=112 amount=-3 +kerning first=87 second=111 amount=-1 +kerning first=69 second=118 amount=-1 +kerning first=65 second=117 amount=-2 +kerning first=65 second=89 amount=-9 +kerning first=72 second=89 amount=-1 +kerning first=119 second=44 amount=-4 +kerning first=69 second=121 amount=-1 +kerning first=84 second=109 amount=-3 +kerning first=84 second=122 amount=-2 +kerning first=89 second=99 amount=-2 +kerning first=76 second=118 amount=-5 +kerning first=90 second=99 amount=-1 +kerning first=90 second=103 amount=-1 +kerning first=79 second=89 amount=-2 +kerning first=90 second=79 amount=-1 +kerning first=84 second=115 amount=-4 +kerning first=76 second=65 amount=1 +kerning first=90 second=100 amount=-1 +kerning first=118 second=46 amount=-4 +kerning first=87 second=117 amount=-1 +kerning first=118 second=34 amount=1 +kerning first=69 second=103 amount=-1 +kerning first=97 second=121 amount=-1 +kerning first=39 second=111 amount=-2 +kerning first=72 second=88 amount=1 +kerning first=76 second=87 amount=-5 +kerning first=69 second=119 amount=-1 +kerning first=121 second=97 amount=-1 +kerning first=75 second=45 amount=-8 +kerning first=65 second=86 amount=-9 +kerning first=46 second=34 amount=-6 +kerning first=76 second=84 amount=-10 +kerning first=116 second=111 amount=-1 +kerning first=87 second=113 amount=-1 +kerning first=69 second=100 amount=-1 +kerning first=97 second=118 amount=-1 +kerning first=65 second=85 amount=-2 +kerning first=90 second=71 amount=-1 +kerning first=68 second=46 amount=-4 +kerning first=65 second=79 amount=-3 +kerning first=98 second=122 amount=-1 +kerning first=86 second=41 amount=1 +kerning first=84 second=118 amount=-3 +kerning first=70 second=118 amount=-1 +kerning first=121 second=111 amount=-1 +kerning first=81 second=87 amount=-1 +kerning first=70 second=100 amount=-1 +kerning first=102 second=93 amount=1 +kerning first=114 second=101 amount=-1 +kerning first=88 second=45 amount=-2 +kerning first=39 second=103 amount=-2 +kerning first=75 second=103 amount=-1 +kerning first=88 second=101 amount=-1 +kerning first=89 second=103 amount=-2 +kerning first=110 second=39 amount=-1 +kerning first=89 second=89 amount=1 +kerning first=87 second=65 amount=-2 +kerning first=119 second=46 amount=-4 +kerning first=34 second=34 amount=-4 +kerning first=88 second=79 amount=-2 +kerning first=79 second=86 amount=-1 +kerning first=76 second=119 amount=-3 +kerning first=75 second=111 amount=-1 +kerning first=65 second=116 amount=-4 +kerning first=86 second=65 amount=-9 +kerning first=70 second=84 amount=1 +kerning first=75 second=117 amount=-1 +kerning first=80 second=65 amount=-9 +kerning first=34 second=112 amount=-1 +kerning first=102 second=99 amount=-1 +kerning first=118 second=97 amount=-1 +kerning first=89 second=81 amount=-1 +kerning first=118 second=111 amount=-1 +kerning first=102 second=101 amount=-1 +kerning first=114 second=44 amount=-4 +kerning first=90 second=119 amount=-1 +kerning first=75 second=81 amount=-4 +kerning first=88 second=121 amount=-1 +kerning first=34 second=110 amount=-1 +kerning first=86 second=100 amount=-2 +kerning first=122 second=100 amount=-1 +kerning first=89 second=67 amount=-1 +kerning first=90 second=118 amount=-1 +kerning first=84 second=84 amount=1 +kerning first=121 second=34 amount=1 +kerning first=91 second=74 amount=-1 +kerning first=88 second=113 amount=-1 +kerning first=77 second=88 amount=1 +kerning first=75 second=119 amount=-2 +kerning first=114 second=104 amount=-1 +kerning first=68 second=88 amount=-2 +kerning first=121 second=44 amount=-4 +kerning first=81 second=89 amount=-1 +kerning first=102 second=39 amount=1 +kerning first=74 second=65 amount=-2 +kerning first=114 second=118 amount=1 +kerning first=84 second=46 amount=-8 +kerning first=111 second=34 amount=-1 +kerning first=88 second=71 amount=-2 +kerning first=88 second=99 amount=-1 +kerning first=84 second=74 amount=-8 +kerning first=39 second=109 amount=-1 +kerning first=98 second=34 amount=-1 +kerning first=86 second=114 amount=-1 +kerning first=88 second=81 amount=-2 +kerning first=70 second=74 amount=-11 +kerning first=89 second=83 amount=-1 +kerning first=87 second=41 amount=1 +kerning first=89 second=97 amount=-3 +kerning first=89 second=87 amount=1 +kerning first=67 second=125 amount=-1 +kerning first=89 second=93 amount=1 +kerning first=80 second=118 amount=1 +kerning first=107 second=100 amount=-1 +kerning first=114 second=34 amount=1 +kerning first=89 second=109 amount=-1 +kerning first=89 second=45 amount=-2 +kerning first=70 second=44 amount=-8 +kerning first=34 second=39 amount=-4 +kerning first=88 second=67 amount=-2 +kerning first=70 second=46 amount=-8 +kerning first=102 second=41 amount=1 +kerning first=89 second=117 amount=-1 +kerning first=89 second=111 amount=-4 +kerning first=89 second=115 amount=-4 +kerning first=114 second=102 amount=1 +kerning first=89 second=125 amount=1 +kerning first=89 second=121 amount=-1 +kerning first=114 second=108 amount=-1 +kerning first=47 second=47 amount=-8 +kerning first=65 second=63 amount=-2 +kerning first=75 second=67 amount=-4 +kerning first=87 second=100 amount=-1 +kerning first=111 second=104 amount=-1 +kerning first=111 second=107 amount=-1 +kerning first=75 second=109 amount=-1 +kerning first=87 second=114 amount=-1 +kerning first=111 second=120 amount=-1 +kerning first=69 second=99 amount=-1 +kerning first=65 second=84 amount=-6 +kerning first=39 second=97 amount=-2 +kerning first=121 second=46 amount=-4 +kerning first=89 second=85 amount=-3 +kerning first=75 second=79 amount=-4 +kerning first=107 second=99 amount=-1 +kerning first=102 second=100 amount=-1 +kerning first=102 second=103 amount=-1 +kerning first=75 second=110 amount=-1 +kerning first=39 second=110 amount=-1 +kerning first=69 second=84 amount=1 +kerning first=84 second=111 amount=-3 +kerning first=120 second=111 amount=-1 +kerning first=84 second=114 amount=-3 +kerning first=112 second=120 amount=-1 +kerning first=79 second=84 amount=-1 +kerning first=84 second=117 amount=-3 +kerning first=89 second=79 amount=-1 +kerning first=75 second=113 amount=-1 +kerning first=39 second=113 amount=-2 +kerning first=80 second=44 amount=-11 +kerning first=79 second=88 amount=-2 +kerning first=98 second=39 amount=-1 +kerning first=65 second=118 amount=-4 +kerning first=65 second=34 amount=-4 +kerning first=88 second=103 amount=-1 +kerning first=77 second=89 amount=-1 +kerning first=39 second=101 amount=-2 +kerning first=75 second=101 amount=-1 +kerning first=88 second=100 amount=-1 +kerning first=78 second=65 amount=-3 +kerning first=87 second=44 amount=-4 +kerning first=67 second=41 amount=-1 +kerning first=86 second=93 amount=1 +kerning first=84 second=83 amount=-1 +kerning first=102 second=113 amount=-1 +kerning first=34 second=111 amount=-2 +kerning first=70 second=111 amount=-1 +kerning first=86 second=99 amount=-2 +kerning first=84 second=86 amount=1 +kerning first=122 second=99 amount=-1 +kerning first=84 second=89 amount=1 +kerning first=70 second=114 amount=-1 +kerning first=86 second=74 amount=-8 +kerning first=89 second=38 amount=-1 +kerning first=87 second=97 amount=-1 +kerning first=76 second=86 amount=-9 +kerning first=40 second=86 amount=1 +kerning first=90 second=113 amount=-1 +kerning first=39 second=39 amount=-4 +kerning first=111 second=39 amount=-1 +kerning first=90 second=117 amount=-1 +kerning first=89 second=41 amount=1 +kerning first=65 second=121 amount=-4 +kerning first=89 second=100 amount=-2 +kerning first=89 second=42 amount=-2 +kerning first=76 second=117 amount=-2 +kerning first=69 second=111 amount=-1 +kerning first=46 second=39 amount=-6 +kerning first=118 second=39 amount=1 +kerning first=91 second=85 amount=-1 +kerning first=80 second=90 amount=-1 +kerning first=90 second=81 amount=-1 +kerning first=69 second=117 amount=-1 +kerning first=76 second=39 amount=-5 +kerning first=90 second=67 amount=-1 +kerning first=87 second=103 amount=-1 +kerning first=84 second=120 amount=-3 +kerning first=89 second=101 amount=-2 +kerning first=102 second=125 amount=1 +kerning first=76 second=85 amount=-2 +kerning first=79 second=65 amount=-3 +kerning first=65 second=71 amount=-3 +kerning first=79 second=44 amount=-4 +kerning first=97 second=39 amount=-2 +kerning first=90 second=101 amount=-1 +kerning first=65 second=87 amount=-5 +kerning first=79 second=46 amount=-4 +kerning first=87 second=99 amount=-1 +kerning first=34 second=101 amount=-2 +kerning first=40 second=89 amount=1 +kerning first=76 second=89 amount=-8 +kerning first=69 second=113 amount=-1 +kerning first=120 second=103 amount=-1 +kerning first=69 second=101 amount=-1 +kerning first=69 second=102 amount=-1 +kerning first=104 second=39 amount=-1 +kerning first=80 second=121 amount=1 +kerning first=86 second=46 amount=-8 +kerning first=65 second=81 amount=-3 +kerning first=86 second=44 amount=-8 +kerning first=120 second=99 amount=-1 +kerning first=98 second=120 amount=-1 +kerning first=39 second=115 amount=-3 +kerning first=121 second=39 amount=1 +kerning first=88 second=118 amount=-1 +kerning first=84 second=65 amount=-6 +kerning first=65 second=39 amount=-4 +kerning first=84 second=79 amount=-1 +kerning first=65 second=119 amount=-4 +kerning first=70 second=117 amount=-1 +kerning first=75 second=100 amount=-1 +kerning first=86 second=111 amount=-2 +kerning first=122 second=111 amount=-1 +kerning first=81 second=84 amount=-2 +kerning first=107 second=103 amount=-1 +kerning first=118 second=44 amount=-4 +kerning first=87 second=46 amount=-4 +kerning first=87 second=101 amount=-1 +kerning first=70 second=79 amount=-2 +kerning first=87 second=74 amount=-2 +kerning first=123 second=74 amount=-1 +kerning first=76 second=71 amount=-2 +kerning first=39 second=100 amount=-2 +kerning first=80 second=88 amount=-1 +kerning first=84 second=121 amount=-3 +kerning first=112 second=122 amount=-1 +kerning first=84 second=71 amount=-1 +kerning first=89 second=86 amount=1 +kerning first=84 second=113 amount=-3 +kerning first=120 second=113 amount=-1 +kerning first=89 second=44 amount=-7 +kerning first=84 second=99 amount=-3 +kerning first=34 second=113 amount=-2 +kerning first=80 second=46 amount=-11 +kerning first=86 second=117 amount=-1 +kerning first=110 second=34 amount=-1 +kerning first=80 second=74 amount=-7 +kerning first=120 second=101 amount=-1 +kerning first=73 second=88 amount=1 +kerning first=108 second=111 amount=-1 +kerning first=34 second=115 amount=-3 +kerning first=89 second=113 amount=-2 +kerning first=82 second=86 amount=-3 +kerning first=114 second=39 amount=1 +kerning first=34 second=109 amount=-1 +kerning first=84 second=101 amount=-3 +kerning first=70 second=121 amount=-1 +kerning first=123 second=85 amount=-1 +kerning first=122 second=103 amount=-1 +kerning first=86 second=97 amount=-2 +kerning first=82 second=89 amount=-4 +kerning first=66 second=84 amount=-1 +kerning first=84 second=97 amount=-4 +kerning first=86 second=103 amount=-2 +kerning first=70 second=113 amount=-1 +kerning first=84 second=87 amount=1 +kerning first=75 second=112 amount=-1 +kerning first=114 second=111 amount=-1 +kerning first=39 second=112 amount=-1 +kerning first=107 second=101 amount=-1 +kerning first=82 second=84 amount=-3 +kerning first=114 second=121 amount=1 +kerning first=34 second=99 amount=-2 +kerning first=70 second=81 amount=-2 +kerning first=111 second=122 amount=-1 +kerning first=84 second=67 amount=-1 +kerning first=111 second=108 amount=-1 +kerning first=89 second=84 amount=1 +kerning first=76 second=79 amount=-2 +kerning first=85 second=65 amount=-2 +kerning first=44 second=34 amount=-6 +kerning first=65 second=67 amount=-3 +kerning first=109 second=34 amount=-1 +kerning first=114 second=103 amount=-1 +kerning first=78 second=89 amount=-1 +kerning first=89 second=114 amount=-1 +kerning first=89 second=112 amount=-1 +kerning first=34 second=65 amount=-4 +kerning first=70 second=65 amount=-11 +kerning first=81 second=86 amount=-1 +kerning first=114 second=119 amount=1 +kerning first=89 second=102 amount=-1 +kerning first=84 second=45 amount=-8 +kerning first=86 second=125 amount=1 +kerning first=70 second=67 amount=-2 +kerning first=89 second=116 amount=-1 +kerning first=102 second=34 amount=1 +kerning first=114 second=99 amount=-1 +kerning first=67 second=84 amount=-1 +kerning first=114 second=113 amount=-1 +kerning first=89 second=122 amount=-1 +kerning first=89 second=118 amount=-1 +kerning first=70 second=71 amount=-2 +kerning first=114 second=107 amount=-1 +kerning first=89 second=120 amount=-1 diff --git a/src/web/static/fonts/bmfonts/RobotoSlab72White.png b/src/web/static/fonts/bmfonts/RobotoSlab72White.png new file mode 100644 index 0000000000000000000000000000000000000000..5aa1bf064c4d545993bd838050b81ccf624a5c64 GIT binary patch literal 54282 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 Date: Tue, 19 Mar 2019 13:54:26 +0000 Subject: [PATCH 243/687] Add new implementation of gaussian blur. Changed SharpenImage to use the new algorithm. --- src/core/lib/ImageManipulation.mjs | 252 +++++++++++++++++++++++++++ src/core/operations/BlurImage.mjs | 7 +- src/core/operations/SharpenImage.mjs | 5 +- 3 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 src/core/lib/ImageManipulation.mjs diff --git a/src/core/lib/ImageManipulation.mjs b/src/core/lib/ImageManipulation.mjs new file mode 100644 index 00000000..1f1b85ae --- /dev/null +++ b/src/core/lib/ImageManipulation.mjs @@ -0,0 +1,252 @@ +/** + * Image manipulation resources + * + * @author j433866 [j433866@gmail.com] + * @copyright Crown Copyright 2019 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError"; + +/** + * Gaussian blurs an image. + * + * @param {jimp} input + * @param {int} radius + * @param {boolean} fast + * @returns {jimp} + */ +export function gaussianBlur (input, radius) { + try { + // From http://blog.ivank.net/fastest-gaussian-blur.html + const boxes = boxesForGauss(radius, 3); + for (let i = 0; i < 3; i++) { + input = boxBlur(input, (boxes[i] - 1) / 2); + } + } catch (err) { + log.error(err); + throw new OperationError(`Error blurring image. (${err})`); + } + + return input; +} + +/** + * + * @param {int} radius + * @param {int} numBoxes + * @returns {Array} + */ +function boxesForGauss(radius, numBoxes) { + const idealWidth = Math.sqrt((12 * radius * radius / numBoxes) + 1); + + let wl = Math.floor(idealWidth); + + if (wl % 2 === 0) { + wl--; + } + + const wu = wl + 2; + + const mIdeal = (12 * radius * radius - numBoxes * wl * wl - 4 * numBoxes * wl - 3 * numBoxes) / (-4 * wl - 4); + const m = Math.round(mIdeal); + + const sizes = []; + for (let i = 0; i < numBoxes; i++) { + sizes.push(i < m ? wl : wu); + } + return sizes; +} + +/** + * Applies a box blur effect to the image + * + * @param {jimp} source + * @param {number} radius + * @returns {jimp} + */ +function boxBlur (source, radius) { + const width = source.bitmap.width; + const height = source.bitmap.height; + let output = source.clone(); + output = boxBlurH(source, output, width, height, radius); + source = boxBlurV(output, source, width, height, radius); + + return source; +} + +/** + * Applies the horizontal blur + * + * @param {jimp} source + * @param {jimp} output + * @param {number} width + * @param {number} height + * @param {number} radius + * @returns {jimp} + */ +function boxBlurH (source, output, width, height, radius) { + const iarr = 1 / (radius + radius + 1); + for (let i = 0; i < height; i++) { + let ti = 0, + li = ti, + ri = ti + radius; + const idx = source.getPixelIndex(ti, i); + const firstValRed = source.bitmap.data[idx], + firstValGreen = source.bitmap.data[idx + 1], + firstValBlue = source.bitmap.data[idx + 2], + firstValAlpha = source.bitmap.data[idx + 3]; + + const lastIdx = source.getPixelIndex(width - 1, i), + lastValRed = source.bitmap.data[lastIdx], + lastValGreen = source.bitmap.data[lastIdx + 1], + lastValBlue = source.bitmap.data[lastIdx + 2], + lastValAlpha = source.bitmap.data[lastIdx + 3]; + + let red = (radius + 1) * firstValRed; + let green = (radius + 1) * firstValGreen; + let blue = (radius + 1) * firstValBlue; + let alpha = (radius + 1) * firstValAlpha; + + for (let j = 0; j < radius; j++) { + const jIdx = source.getPixelIndex(ti + j, i); + red += source.bitmap.data[jIdx]; + green += source.bitmap.data[jIdx + 1]; + blue += source.bitmap.data[jIdx + 2]; + alpha += source.bitmap.data[jIdx + 3]; + } + + for (let j = 0; j <= radius; j++) { + const jIdx = source.getPixelIndex(ri++, i); + red += source.bitmap.data[jIdx] - firstValRed; + green += source.bitmap.data[jIdx + 1] - firstValGreen; + blue += source.bitmap.data[jIdx + 2] - firstValBlue; + alpha += source.bitmap.data[jIdx + 3] - firstValAlpha; + + const tiIdx = source.getPixelIndex(ti++, i); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + + for (let j = radius + 1; j < width - radius; j++) { + const riIdx = source.getPixelIndex(ri++, i); + const liIdx = source.getPixelIndex(li++, i); + red += source.bitmap.data[riIdx] - source.bitmap.data[liIdx]; + green += source.bitmap.data[riIdx + 1] - source.bitmap.data[liIdx + 1]; + blue += source.bitmap.data[riIdx + 2] - source.bitmap.data[liIdx + 2]; + alpha += source.bitmap.data[riIdx + 3] - source.bitmap.data[liIdx + 3]; + + const tiIdx = source.getPixelIndex(ti++, i); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + + for (let j = width - radius; j < width; j++) { + const liIdx = source.getPixelIndex(li++, i); + red += lastValRed - source.bitmap.data[liIdx]; + green += lastValGreen - source.bitmap.data[liIdx + 1]; + blue += lastValBlue - source.bitmap.data[liIdx + 2]; + alpha += lastValAlpha - source.bitmap.data[liIdx + 3]; + + const tiIdx = source.getPixelIndex(ti++, i); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + } + return output; +} + +/** + * Applies the vertical blur + * + * @param {jimp} source + * @param {jimp} output + * @param {int} width + * @param {int} height + * @param {int} radius + * @returns {jimp} + */ +function boxBlurV (source, output, width, height, radius) { + const iarr = 1 / (radius + radius + 1); + for (let i = 0; i < width; i++) { + let ti = 0, + li = ti, + ri = ti + radius; + + const idx = source.getPixelIndex(i, ti); + + const firstValRed = source.bitmap.data[idx], + firstValGreen = source.bitmap.data[idx + 1], + firstValBlue = source.bitmap.data[idx + 2], + firstValAlpha = source.bitmap.data[idx + 3]; + + const lastIdx = source.getPixelIndex(i, height - 1), + lastValRed = source.bitmap.data[lastIdx], + lastValGreen = source.bitmap.data[lastIdx + 1], + lastValBlue = source.bitmap.data[lastIdx + 2], + lastValAlpha = source.bitmap.data[lastIdx + 3]; + + let red = (radius + 1) * firstValRed; + let green = (radius + 1) * firstValGreen; + let blue = (radius + 1) * firstValBlue; + let alpha = (radius + 1) * firstValAlpha; + + for (let j = 0; j < radius; j++) { + const jIdx = source.getPixelIndex(i, ti + j); + red += source.bitmap.data[jIdx]; + green += source.bitmap.data[jIdx + 1]; + blue += source.bitmap.data[jIdx + 2]; + alpha += source.bitmap.data[jIdx + 3]; + } + + for (let j = 0; j <= radius; j++) { + const riIdx = source.getPixelIndex(i, ri++); + red += source.bitmap.data[riIdx] - firstValRed; + green += source.bitmap.data[riIdx + 1] - firstValGreen; + blue += source.bitmap.data[riIdx + 2] - firstValBlue; + alpha += source.bitmap.data[riIdx + 3] - firstValAlpha; + + const tiIdx = source.getPixelIndex(i, ti++); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + + for (let j = radius + 1; j < height - radius; j++) { + const riIdx = source.getPixelIndex(i, ri++); + const liIdx = source.getPixelIndex(i, li++); + red += source.bitmap.data[riIdx] - source.bitmap.data[liIdx]; + green += source.bitmap.data[riIdx + 1] - source.bitmap.data[liIdx + 1]; + blue += source.bitmap.data[riIdx + 2] - source.bitmap.data[liIdx + 2]; + alpha += source.bitmap.data[riIdx + 3] - source.bitmap.data[liIdx + 3]; + + const tiIdx = source.getPixelIndex(i, ti++); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + + for (let j = height - radius; j < height; j++) { + const liIdx = source.getPixelIndex(i, li++); + red += lastValRed - source.bitmap.data[liIdx]; + green += lastValGreen - source.bitmap.data[liIdx + 1]; + blue += lastValBlue - source.bitmap.data[liIdx + 2]; + alpha += lastValAlpha - source.bitmap.data[liIdx + 3]; + + const tiIdx = source.getPixelIndex(i, ti++); + output.bitmap.data[tiIdx] = Math.round(red * iarr); + output.bitmap.data[tiIdx + 1] = Math.round(green * iarr); + output.bitmap.data[tiIdx + 2] = Math.round(blue * iarr); + output.bitmap.data[tiIdx + 3] = Math.round(alpha * iarr); + } + } + return output; +} diff --git a/src/core/operations/BlurImage.mjs b/src/core/operations/BlurImage.mjs index e1a52710..e0b5c919 100644 --- a/src/core/operations/BlurImage.mjs +++ b/src/core/operations/BlurImage.mjs @@ -9,6 +9,7 @@ import OperationError from "../errors/OperationError"; import { isImage } from "../lib/FileType"; import { toBase64 } from "../lib/Base64"; import jimp from "jimp"; +import { gaussianBlur } from "../lib/ImageManipulation"; /** * Blur Image operation @@ -64,12 +65,14 @@ class BlurImage extends Operation { try { switch (blurType){ case "Fast": + if (ENVIRONMENT_IS_WORKER()) + self.sendStatusMessage("Fast blurring image..."); image.blur(blurAmount); break; case "Gaussian": if (ENVIRONMENT_IS_WORKER()) - self.sendStatusMessage("Gaussian blurring image. This may take a while..."); - image.gaussian(blurAmount); + self.sendStatusMessage("Gaussian blurring image..."); + image = gaussianBlur(image, blurAmount); break; } diff --git a/src/core/operations/SharpenImage.mjs b/src/core/operations/SharpenImage.mjs index db0e7bb7..3ef1912e 100644 --- a/src/core/operations/SharpenImage.mjs +++ b/src/core/operations/SharpenImage.mjs @@ -8,6 +8,7 @@ import Operation from "../Operation"; import OperationError from "../errors/OperationError"; import { isImage } from "../lib/FileType"; import { toBase64 } from "../lib/Base64"; +import { gaussianBlur } from "../lib/ImageManipulation"; import jimp from "jimp"; /** @@ -74,12 +75,12 @@ class SharpenImage extends Operation { try { if (ENVIRONMENT_IS_WORKER()) self.sendStatusMessage("Sharpening image... (Cloning image)"); - const blurImage = image.clone(); const blurMask = image.clone(); if (ENVIRONMENT_IS_WORKER()) self.sendStatusMessage("Sharpening image... (Blurring cloned image)"); - blurImage.gaussian(radius); + const blurImage = gaussianBlur(image.clone(), radius, 3); + if (ENVIRONMENT_IS_WORKER()) self.sendStatusMessage("Sharpening image... (Creating unsharp mask)"); From b312e179047d3f6bd374226eec975248e5833f41 Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Mar 2019 13:54:39 +0000 Subject: [PATCH 244/687] Change title to title case --- src/core/operations/ConvertImageFormat.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/operations/ConvertImageFormat.mjs b/src/core/operations/ConvertImageFormat.mjs index c8152908..5900fbbc 100644 --- a/src/core/operations/ConvertImageFormat.mjs +++ b/src/core/operations/ConvertImageFormat.mjs @@ -21,7 +21,7 @@ class ConvertImageFormat extends Operation { constructor() { super(); - this.name = "Convert image format"; + this.name = "Convert Image Format"; this.module = "Image"; this.description = "Converts an image between different formats. Supported formats:

  • Joint Photographic Experts Group (JPEG)
  • Portable Network Graphics (PNG)
  • Bitmap (BMP)
  • Tagged Image File Format (TIFF)

Note: GIF files are supported for input, but cannot be outputted."; this.infoURL = "https://wikipedia.org/wiki/Image_file_formats"; From d09ab4a153b9eaadd15005dff14065e0de975def Mon Sep 17 00:00:00 2001 From: j433866 Date: Tue, 19 Mar 2019 14:37:46 +0000 Subject: [PATCH 245/687] Add new solarized light and dark themes. Add more elements to be controlled by theme css: - Preloader spinner colours - Operation disable / breakpoint icons - Auto bake checkbox - Search highlight colour - Categories header colour --- src/web/html/index.html | 11 +- src/web/stylesheets/components/_operation.css | 8 +- src/web/stylesheets/index.css | 2 + src/web/stylesheets/layout/_controls.css | 6 + src/web/stylesheets/layout/_operations.css | 4 +- src/web/stylesheets/preloader.css | 10 +- src/web/stylesheets/themes/_classic.css | 15 ++ src/web/stylesheets/themes/_dark.css | 15 ++ src/web/stylesheets/themes/_geocities.css | 15 ++ src/web/stylesheets/themes/_solarizedDark.css | 143 +++++++++++++++++ .../stylesheets/themes/_solarizedDarkOld.css | 127 +++++++++++++++ .../stylesheets/themes/_solarizedLight.css | 145 ++++++++++++++++++ src/web/stylesheets/utils/_overrides.css | 7 +- 13 files changed, 494 insertions(+), 14 deletions(-) create mode 100755 src/web/stylesheets/themes/_solarizedDark.css create mode 100755 src/web/stylesheets/themes/_solarizedDarkOld.css create mode 100755 src/web/stylesheets/themes/_solarizedLight.css diff --git a/src/web/html/index.html b/src/web/html/index.html index 119e8ada..d7fbdd05 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -250,7 +250,7 @@
- +
Name:
@@ -436,6 +436,8 @@ + +
@@ -509,6 +511,13 @@ Attempt to detect encoded data automagically
+ +
+ +