From f937499e6f8e606706da5c762a7ce71993fd13f5 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Wed, 31 May 2023 20:49:44 +0200 Subject: [PATCH] Remove unnecessary wrapping of functionality in a class --- README.md | 8 +- main.js | 4 +- src/pbm.js | 434 ++++++++++++++++++++++------------------------ tests/pbm.test.js | 22 +-- 4 files changed, 220 insertions(+), 248 deletions(-) diff --git a/README.md b/README.md index 8cbe6b3..c66da4e 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,14 @@ Try it out at [https://michaelshmitty.github.io/pbm-js/](https://michaelshmitty. _Also see `index.html` and `main.js` for a more elaborate example that renders the image and palette data to an html5 canvas and supports color cycling._ ```javascript -import PBM from "./src/pbm.js"; +import parsePBM from "./src/pbm.js"; fetch("/assets/TEST.LBM") .then((response) => { return response.arrayBuffer(); }) .then((buffer) => { - const image = new PBM(buffer); + const image = parsePBM(buffer); console.log(image); }); ``` @@ -36,10 +36,10 @@ fetch("/assets/TEST.LBM") ```javascript import * as fs from "fs"; -import PBM from "./src/pbm.js"; +import parsePBM from "./src/pbm.js"; const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); -const image = new PBM(data.buffer); +const image = parsePBM(data.buffer); console.log(image); ``` diff --git a/main.js b/main.js index e1e3ed2..65df66d 100644 --- a/main.js +++ b/main.js @@ -24,7 +24,7 @@ */ -import PBM from "./src/pbm.js"; +import parsePBM from "./src/pbm.js"; const thumbnailCanvas = document.getElementById("thumbnail-canvas"); const thumbnailContext = thumbnailCanvas.getContext("2d"); @@ -88,7 +88,7 @@ function drawImage(anImage, ctx) { // Image loading function loadImage(buffer) { - image = new PBM(buffer); + image = parsePBM(buffer); thumbnailCanvas.width = image.thumbnail.width; thumbnailCanvas.height = image.thumbnail.height; imageCanvas.width = image.width; diff --git a/src/pbm.js b/src/pbm.js index bbcb8a2..7bfb7a3 100644 --- a/src/pbm.js +++ b/src/pbm.js @@ -26,240 +26,212 @@ import BinaryStream from "./binarystream.js"; -class PBM { - constructor(arrayBuffer) { - this.binaryStream = new BinaryStream(arrayBuffer); +// Parse Bitmap Header chunk +function parseBMHD(binaryStream, image) { + image.width = binaryStream.readUint16BE(); + image.height = binaryStream.readUint16BE(); + image.size = image.width * image.height; + image.xOrigin = binaryStream.readInt16BE(); + image.yOrigin = binaryStream.readInt16BE(); + image.numPlanes = binaryStream.readUint8(); + image.mask = binaryStream.readUint8(); + image.compression = binaryStream.readUint8(); + binaryStream.readUint8(); // Ignore pad1 field left "for future compatibility" + image.transClr = binaryStream.readUint16BE(); + image.xAspect = binaryStream.readUint8(); + image.yAspect = binaryStream.readUint8(); + image.pageWidth = binaryStream.readInt16BE(); + image.pageHeight = binaryStream.readInt16BE(); +} - // Image properties taken from BMHD chunk - this.width = null; - this.height = null; - this.size = null; - this.xOrigin = null; - this.yOrigin = null; - this.numPlanes = null; - this.mask = null; - this.compression = null; - this.transClr = null; - this.xAspect = null; - this.yAspect = null; - this.pageWidth = null; - this.pageHeight = null; +// Parse Palette chunk +function parseCMAP(binaryStream, image) { + const numColors = 2 ** image.numPlanes; + image.palette = []; - // Palette information taken from CMAP chunk - this.palette = []; - - // Color cycling information taken from CRNG chunk - this.cyclingRanges = []; - - // Thumbnail information taken from TINY chunk - this.thumbnail = { - width: null, - height: null, - size: null, - palette: this.palette, - pixelData: [], - }; - - // Uncompressed pixel data referencing palette colors - this.pixelData = []; - - try { - this.parseFORM(); - } catch (error) { - if (error instanceof RangeError) { - throw new Error(`Failed to parse file.`); - } else { - throw error; // re-throw the error unchanged - } + // TODO(m): Read 3 bytes at a time? + for (let i = 0; i < numColors; i++) { + const rgb = []; + for (let j = 0; j < 3; j++) { + rgb.push(binaryStream.readByte()); } - } - - parseFORM() { - // Parse "FORM" chunk - let chunkId = this.binaryStream.readString(4); - let chunkLength = this.binaryStream.readUint32BE(); - const formatId = this.binaryStream.readString(4); - - // Validate chunk according to notes on https://en.wikipedia.org/wiki/ILBM - if (chunkId !== "FORM") { - throw new Error( - `Invalid chunkId: "${chunkId}" at byte ${this.binaryStream.index}. Expected "FORM".` - ); - } - - if (chunkLength !== this.binaryStream.length - 8) { - throw new Error( - `Invalid chunk length: ${chunkLength} bytes. Expected ${ - this.binaryStream.length - 8 - } bytes.` - ); - } - - if (formatId !== "PBM ") { - throw new Error(`Invalid formatId: "${formatId}". Expected "PBM ".`); - } - - // Parse all other chunks - while (!this.binaryStream.EOF()) { - chunkId = this.binaryStream.readString(4); - chunkLength = this.binaryStream.readUint32BE(); - - switch (chunkId) { - case "BMHD": - this.parseBMHD(); - break; - case "CMAP": - this.parseCMAP(); - break; - case "DPPS": - // NOTE(m): Ignore unknown DPPS chunk of size 110 bytes - this.binaryStream.jump(110); - break; - case "CRNG": - this.parseCRNG(); - break; - case "TINY": - this.parseTINY(chunkLength); - break; - case "BODY": - this.parseBODY(chunkLength); - break; - default: - throw new Error( - `Unsupported chunkId: ${chunkId} at byte ${this.binaryStream.index}` - ); - } - - // Skip chunk padding byte when chunkLength is not a multiple of 2 - if (chunkLength % 2 === 1) this.binaryStream.jump(1); - } - } - - // Parse Bitmap Header chunk - parseBMHD() { - this.width = this.binaryStream.readUint16BE(); - this.height = this.binaryStream.readUint16BE(); - this.size = this.width * this.height; - this.xOrigin = this.binaryStream.readInt16BE(); - this.yOrigin = this.binaryStream.readInt16BE(); - this.numPlanes = this.binaryStream.readUint8(); - this.mask = this.binaryStream.readUint8(); - this.compression = this.binaryStream.readUint8(); - this.binaryStream.readUint8(); // Ignore pad1 field left "for future compatibility" - this.transClr = this.binaryStream.readUint16BE(); - this.xAspect = this.binaryStream.readUint8(); - this.yAspect = this.binaryStream.readUint8(); - this.pageWidth = this.binaryStream.readInt16BE(); - this.pageHeight = this.binaryStream.readInt16BE(); - } - - // Parse Palette chunk - parseCMAP() { - const numColors = 2 ** this.numPlanes; - - // TODO(m): Read 3 bytes at a time? - for (let i = 0; i < numColors; i++) { - const rgb = []; - for (let j = 0; j < 3; j++) { - rgb.push(this.binaryStream.readByte()); - } - this.palette.push(rgb); - } - } - - // Parse Color range chunk - parseCRNG() { - this.binaryStream.jump(2); // 2 bytes padding according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range - - const rate = this.binaryStream.readInt16BE(); - const flags = this.binaryStream.readInt16BE(); - const low = this.binaryStream.readUint8(); - const high = this.binaryStream.readUint8(); - - // Parse flags according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range - // If bit 0 is 1, the color should cycle, otherwise this color register range is inactive - // and should have no effect. - // - // If bit 1 is 0, the colors cycle upwards (forward), i.e. each color moves into the next - // index position in the palette and the uppermost color in the range moves down to the - // lowest position. - // If bit 1 is 1, the colors cycle in the opposite direction (reverse). - // Only those colors between the low and high entries in the palette should cycle. - const activeBitMask = 1 << 0; - const directionBitMask = 1 << 1; - - this.cyclingRanges.push({ - rate, - active: (flags & activeBitMask) !== 0, - direction: (flags & directionBitMask) !== 0 ? "reverse" : "forward", - low, - high, - }); - } - - // Parse Thumbnail chunk - parseTINY(chunkLength) { - const endOfChunkIndex = this.binaryStream.index + chunkLength; - - this.thumbnail.width = this.binaryStream.readUint16BE(); - this.thumbnail.height = this.binaryStream.readUint16BE(); - this.thumbnail.size = this.thumbnail.width * this.thumbnail.height; - - // Decompress pixel data if necessary - if (this.compression === 1) { - this.thumbnail.pixelData = this.decompress(endOfChunkIndex); - } else { - this.thumbnail.pixelData = this.readUncompressed(endOfChunkIndex); - } - } - - // Parse Image data chunk - parseBODY(chunkLength) { - const endOfChunkIndex = this.binaryStream.index + chunkLength; - - // Decompress pixel data if necessary - if (this.compression === 1) { - this.pixelData = this.decompress(endOfChunkIndex); - } else { - this.pixelData = this.readUncompressed(endOfChunkIndex); - } - } - - decompress(endOfChunkIndex) { - const result = []; - - while (this.binaryStream.index < endOfChunkIndex) { - const byte = this.binaryStream.readByte(); - - if (byte > 128) { - const nextByte = this.binaryStream.readByte(); - for (let i = 0; i < 257 - byte; i++) { - result.push(nextByte); - } - } else if (byte < 128) { - for (let i = 0; i < byte + 1; i++) { - result.push(this.binaryStream.readByte()); - } - } else { - break; - } - } - - return result; - } - - // TODO(m): Read a range of bytes straight into an array? - // Use arrayBuffers throughout instead? - readUncompressed(endOfChunkIndex) { - const result = []; - - while (this.binaryStream.index < endOfChunkIndex) { - const byte = this.binaryStream.readByte(); - result.push(byte); - } - - return result; + image.palette.push(rgb); } } -export default PBM; +// Parse Color range chunk +function parseCRNG(binaryStream) { + binaryStream.jump(2); // 2 bytes padding according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range + + const rate = binaryStream.readInt16BE(); + const flags = binaryStream.readInt16BE(); + const low = binaryStream.readUint8(); + const high = binaryStream.readUint8(); + + // Parse flags according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range + // If bit 0 is 1, the color should cycle, otherwise this color register range is inactive + // and should have no effect. + // + // If bit 1 is 0, the colors cycle upwards (forward), i.e. each color moves into the next + // index position in the palette and the uppermost color in the range moves down to the + // lowest position. + // If bit 1 is 1, the colors cycle in the opposite direction (reverse). + // Only those colors between the low and high entries in the palette should cycle. + const activeBitMask = 1 << 0; + const directionBitMask = 1 << 1; + + return { + rate, + active: (flags & activeBitMask) !== 0, + direction: (flags & directionBitMask) !== 0 ? "reverse" : "forward", + low, + high, + }; +} + +function decompress(binaryStream, endOfChunkIndex) { + const result = []; + + while (binaryStream.index < endOfChunkIndex) { + const byte = binaryStream.readByte(); + + if (byte > 128) { + const nextByte = binaryStream.readByte(); + for (let i = 0; i < 257 - byte; i++) { + result.push(nextByte); + } + } else if (byte < 128) { + for (let i = 0; i < byte + 1; i++) { + result.push(binaryStream.readByte()); + } + } else { + break; + } + } + + return result; +} + +// TODO(m): Read a range of bytes straight into an array? +// Use arrayBuffers throughout instead? +function readUncompressed(binaryStream, endOfChunkIndex) { + const result = []; + + while (binaryStream.index < endOfChunkIndex) { + const byte = binaryStream.readByte(); + result.push(byte); + } + + return result; +} + +// Parse Thumbnail chunk +function parseTINY(binaryStream, image, chunkLength) { + image.thumbnail = { + // FIXME(m): Remove need for reference to image palette in thumbnail data + palette: image.palette, + }; + const endOfChunkIndex = binaryStream.index + chunkLength; + + image.thumbnail.width = binaryStream.readUint16BE(); + image.thumbnail.height = binaryStream.readUint16BE(); + image.thumbnail.size = image.thumbnail.width * image.thumbnail.height; + + if (image.compression === 1) { + image.thumbnail.pixelData = decompress(binaryStream, endOfChunkIndex); + } else { + image.thumbnail.pixelData = readUncompressed(binaryStream, endOfChunkIndex); + } +} + +// Parse Image data chunk +function parseBODY(binaryStream, image, chunkLength) { + const endOfChunkIndex = binaryStream.index + chunkLength; + + if (image.compression === 1) { + image.pixelData = decompress(binaryStream, endOfChunkIndex); + } else { + image.pixelData = readUncompressed(binaryStream, endOfChunkIndex); + } +} + +// Parse FORM chunk +function parseFORM(binaryStream) { + const image = { + cyclingRanges: [], + }; + + let chunkId = binaryStream.readString(4); + let chunkLength = binaryStream.readUint32BE(); + const formatId = binaryStream.readString(4); + + // Validate chunk according to notes on https://en.wikipedia.org/wiki/ILBM + if (chunkId !== "FORM") { + throw new Error( + `Invalid chunkId: "${chunkId}" at byte ${binaryStream.index}. Expected "FORM".` + ); + } + + if (chunkLength !== binaryStream.length - 8) { + throw new Error( + `Invalid chunk length: ${chunkLength} bytes. Expected ${ + binaryStream.length - 8 + } bytes.` + ); + } + + if (formatId !== "PBM ") { + throw new Error(`Invalid formatId: "${formatId}". Expected "PBM ".`); + } + + // Parse all other chunks + while (!binaryStream.EOF()) { + chunkId = binaryStream.readString(4); + chunkLength = binaryStream.readUint32BE(); + + switch (chunkId) { + case "BMHD": + parseBMHD(binaryStream, image); + break; + case "CMAP": + parseCMAP(binaryStream, image); + break; + case "DPPS": + // NOTE(m): Ignore unknown DPPS chunk of size 110 bytes + binaryStream.jump(110); + break; + case "CRNG": + image.cyclingRanges.push(parseCRNG(binaryStream)); + break; + case "TINY": + parseTINY(binaryStream, image, chunkLength); + break; + case "BODY": + parseBODY(binaryStream, image, chunkLength); + break; + default: + throw new Error( + `Unsupported chunkId: ${chunkId} at byte ${binaryStream.index}` + ); + } + + // Skip chunk padding byte when chunkLength is not a multiple of 2 + if (chunkLength % 2 === 1) binaryStream.jump(1); + } + + return image; +} + +export default function parsePBM(arrayBuffer) { + const binaryStream = new BinaryStream(arrayBuffer); + try { + const image = parseFORM(binaryStream); + return image; + } catch (error) { + if (error instanceof RangeError) { + throw new Error(`Failed to parse file.`); + } else { + throw error; // re-throw the error unchanged + } + } +} diff --git a/tests/pbm.test.js b/tests/pbm.test.js index d03d86a..b3905ca 100644 --- a/tests/pbm.test.js +++ b/tests/pbm.test.js @@ -27,7 +27,7 @@ import { expect, test } from "vitest"; -import PBM from "../src/pbm.js"; +import parsePBM from "../src/pbm.js"; const fs = require("fs"); @@ -35,7 +35,7 @@ test("Successfully parse a PBM file", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); expect(() => { - new PBM(data.buffer); + parsePBM(data.buffer); }).not.toThrowError(); }); @@ -43,7 +43,7 @@ test("Fail to parse a PBM file with an invalid chunk id", () => { const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_ID.LBM"); expect(() => { - new PBM(data.buffer); + parsePBM(data.buffer); }).toThrowError(/^Invalid chunkId: "FARM" at byte 12. Expected "FORM".$/); }); @@ -51,7 +51,7 @@ test("Fail to parse a PBM file with an invalid chunk length", () => { const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_LENGTH.LBM"); expect(() => { - new PBM(data.buffer); + parsePBM(data.buffer); }).toThrowError(/^Invalid chunk length: 7070 bytes. Expected 7012 bytes.$/); }); @@ -59,13 +59,13 @@ test("Fail to parse an IFF file that is not a PBM file", () => { const data = fs.readFileSync("./tests/fixtures/SEASCAPE.LBM"); expect(() => { - new PBM(data.buffer); + parsePBM(data.buffer); }).toThrowError(/^Invalid formatId: "ILBM". Expected "PBM ".$/); }); test("Parse a PBM bitmap header", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.width).toStrictEqual(640); expect(image.height).toStrictEqual(480); @@ -84,7 +84,7 @@ test("Parse a PBM bitmap header", () => { test("Parse PBM palette information", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.palette.length).toStrictEqual(256); expect(image.palette[10]).toStrictEqual([87, 255, 87]); @@ -92,14 +92,14 @@ test("Parse PBM palette information", () => { test("Parse PBM color cycling information", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.cyclingRanges.length).toStrictEqual(16); }); test("Parse PBM thumbnail", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.thumbnail.width).toStrictEqual(80); expect(image.thumbnail.height).toStrictEqual(60); @@ -108,7 +108,7 @@ test("Parse PBM thumbnail", () => { test("Decode PBM thumbnail pixel data", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.thumbnail.pixelData.length).toStrictEqual(4800); // FIXME(m): Verify these values are correct in the test image thumbnail: @@ -118,7 +118,7 @@ test("Decode PBM thumbnail pixel data", () => { test("Decode PBM image pixel data", () => { const data = fs.readFileSync("./tests/fixtures/VALID.LBM"); - const image = new PBM(data.buffer); + const image = parsePBM(data.buffer); expect(image.pixelData.length).toStrictEqual(307_200); // FIXME(m): Verify these values are correct in the test image: