diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index 53ca796d..b4cfdb73 100755 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -379,6 +379,7 @@ "Remove EXIF", "Extract EXIF", "Extract RGBA", + "Extract Quantisation Tables", "View Bit Plane", "Randomize Colour Palette", "Extract LSB" diff --git a/src/core/lib/QuantisationTable.mjs b/src/core/lib/QuantisationTable.mjs new file mode 100644 index 00000000..17b991bd --- /dev/null +++ b/src/core/lib/QuantisationTable.mjs @@ -0,0 +1,68 @@ +/** + * Parses a JPEG quantisation table. + * + * @param {Uint8Array} rawTable + * @param {boolean} doublePrec - true if 16-bit precision, false if 8-bit + * @returns {number[][]} + * + */ +export function parseQTable(rawTable, doublePrec = false) { + // The raw table is flattened in zig-zag order, similar to enumerating a countable set. + // This lookup table tells the index in the natural order for each element in the zig-zag order + const unZigZagIndex = [ + 0, 1, 8, 16, 9, 2, 3, 10, + 17, 24, 32, 25, 18, 11, 4, 5, + 12, 19, 26, 33, 40, 48, 41, 34, + 27, 20, 13, 6, 7, 14, 21, 28, + 35, 42, 49, 56, 57, 50, 43, 36, + 29, 22, 15, 23, 30, 37, 44, 51, + 58, 59, 52, 45, 38, 31, 39, 46, + 53, 60, 61, 54, 47, 55, 62, 63]; + const ret = [ + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 1, 1, 1, 1, 1, 1, 1]]; + let i = 0; + let elemCount = 0; + while (i < rawTable.length) { + const row = ~~(unZigZagIndex[elemCount] / 8); // floor + const col = unZigZagIndex[elemCount] % 8; + if (doublePrec) { + ret[row][col] = rawTable[i++] << 8 + rawTable[i++]; + } else { + ret[row][col] = rawTable[i++]; + } + elemCount++; + } + return ret; +} + +/** + * Finds the index of first appearance of a JPEG marker + * + * @param {Uint8Array} image + * @param {number} markerByte - doesn't include 0xFF + * @param {number} fromIndex - the index to start the search at + * @returns {number} + * + */ +export function indexOfMarker(image, markerByte, fromIndex = 0) { + let ptr = fromIndex; + let markerIndex = 0; + do { + // look for the byte that changes across markers first, as these are less common than 0xFF + markerIndex = image.indexOf(markerByte, ptr); + if (markerIndex === 0) return -1; // marker byte must follow 0xFF + if (image[markerIndex-1] === 0xFF) { + return markerIndex - 1; + } else { + ptr = markerIndex + 1; + } + } while (markerIndex !== -1); + return -1; +} diff --git a/src/core/operations/ExtractQuantisationTables.mjs b/src/core/operations/ExtractQuantisationTables.mjs new file mode 100644 index 00000000..f0972879 --- /dev/null +++ b/src/core/operations/ExtractQuantisationTables.mjs @@ -0,0 +1,98 @@ +/** + * @author cbeuw [cbeuw.andy@gmail.com] + * @copyright Crown Copyright 2020 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import OperationError from "../errors/OperationError.mjs"; +import {parseQTable, indexOfMarker} from "../lib/QuantisationTable.mjs"; + +/** + * Extract Quantisation Tables operation + */ +class ExtractQuantisationTables extends Operation { + + /** + * ExtractQuantisationTables constructor + */ + constructor() { + super(); + + this.name = "Extract Quantisation Tables"; + this.module = "Default"; + this.description = "Extracts quantisation tables embedded in a JPEG image."; + this.infoURL = "https://en.wikipedia.org/wiki/Quantization_(image_processing)"; + this.inputType = "ArrayBuffer"; + this.outputType = "string"; + this.args = []; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + const image = new Uint8Array(input); + // maximally 4 tables are allowed in a JPEG file + + let ptr = indexOfMarker(image, 0xD8); + if (ptr === -1) throw new OperationError("Malformed image file: Start of Image not found"); + + let ret = ""; + + do { + const dqtIndex = indexOfMarker(image, 0xDB, ptr); + if (dqtIndex === -1) { + break; + } + // -2 here because the length bytes include the two bytes used to record length + // -1 to exclude the byte used to record the table's precision and id + const tableLength = (image[dqtIndex+2] << 8) + image[dqtIndex+3] - 2 - 1; + // if the table is 16-bit precision + const doublePrecision = (image[dqtIndex+4] >> 4) === 1; + if (tableLength !== (doublePrecision? 128:64)) throw new OperationError(`Invalid table length ${tableLength} at table definition beginning at ${dqtIndex}`); + const tableId = image[dqtIndex+4] & 0x0F; + if (tableId >= 4) throw new OperationError(`Invalid table identifier ${tableId} at table definition beginning at ${dqtIndex}`); + const table = parseQTable(image.slice(dqtIndex + 5, dqtIndex + 5 + tableLength), doublePrecision); + + ret += `Quantisation table at position ${dqtIndex}. ID ${tableId}, ${(doublePrecision? "16":"8")}-bit precision:\n`; + ret += this.prettifyMatrix(table); + ret += "\n"; + + ptr = dqtIndex + 5 + tableLength + 1; + } while (ptr < image.length); + + return ret; + } + + /** + * @param {number[][]} mat + * @returns {string} + */ + prettifyMatrix(mat) { + const nRows = mat.length; + const nCols = mat[0].length; + let maxLen = 0; + for (let i = 0; i < nRows; i++) { + for (let j = 0; j < nCols; j++) { + const elemLen = mat[i][j].toString().length; + if (elemLen > maxLen) { + maxLen = elemLen; + } + } + } + + let ret = ""; + for (let i = 0; i < nRows; i++) { + for (let j = 0; j < nCols; j++) { + ret += mat[i][j].toString().padStart(maxLen) + " "; + } + ret += "\n"; + } + return ret; + } +} + +export default ExtractQuantisationTables;