Compare commits
11 Commits
6f779d0c04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb96f405b7 | ||
|
|
e28b88aedf | ||
|
|
f937499e6f | ||
|
|
bd68eb6940 | ||
|
|
24875d989b | ||
|
|
8375261eb1 | ||
|
|
f7a90d5433 | ||
|
|
fc3469ec48 | ||
|
|
9f4a734558 | ||
|
|
c623db79a3 | ||
|
|
85f426aee7 |
19
.eslintrc.cjs
Normal file
19
.eslintrc.cjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2021: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
extends: ["airbnb-base", "prettier"],
|
||||||
|
overrides: [],
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"import/extensions": [2, "always"],
|
||||||
|
"no-plusplus": ["error", { allowForLoopAfterthoughts: true }],
|
||||||
|
"no-param-reassign": ["error", { props: false }],
|
||||||
|
"no-bitwise": ["error", { allow: ["<<", "&"] }],
|
||||||
|
},
|
||||||
|
};
|
||||||
12
README.md
12
README.md
@@ -1,6 +1,6 @@
|
|||||||
# pbm-js
|
# pbm-js
|
||||||
|
|
||||||
JavaScript library for IFF PBM files.
|
JavaScript library for parsing IFF PBM files.
|
||||||
|
|
||||||
This is the format used by the PC version of Deluxe Paint II and is different from the Amiga version.
|
This is the format used by the PC version of Deluxe Paint II and is different from the Amiga version.
|
||||||
|
|
||||||
@@ -10,6 +10,8 @@ This is the format used by the PC version of Deluxe Paint II and is different fr
|
|||||||
- 100% plain JavaScript. No dependencies.
|
- 100% plain JavaScript. No dependencies.
|
||||||
- Compatible with all [Mark J. Ferrari](https://www.markferrari.com/about/)'s artwork I could find.
|
- Compatible with all [Mark J. Ferrari](https://www.markferrari.com/about/)'s artwork I could find.
|
||||||
|
|
||||||
|
Try it out at [https://michaelshmitty.github.io/pbm-js/](https://michaelshmitty.github.io/pbm-js/). You will need to supply your own IFF PBM files. All processing is done in your browser, no data is sent to any server.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### In the browser
|
### In the browser
|
||||||
@@ -17,14 +19,14 @@ This is the format used by the PC version of Deluxe Paint II and is different fr
|
|||||||
_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._
|
_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
|
```javascript
|
||||||
import PBM from "./src/pbm.js";
|
import parsePBM from "./src/pbm.js";
|
||||||
|
|
||||||
fetch("/assets/TEST.LBM")
|
fetch("/assets/TEST.LBM")
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
return response.arrayBuffer();
|
return response.arrayBuffer();
|
||||||
})
|
})
|
||||||
.then((buffer) => {
|
.then((buffer) => {
|
||||||
const image = new PBM(buffer);
|
const image = parsePBM(buffer);
|
||||||
console.log(image);
|
console.log(image);
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -34,10 +36,10 @@ fetch("/assets/TEST.LBM")
|
|||||||
```javascript
|
```javascript
|
||||||
import * as fs from "fs";
|
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 data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
||||||
const image = new PBM(data.buffer);
|
const image = parsePBM(data.buffer);
|
||||||
console.log(image);
|
console.log(image);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
176
main.js
176
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 thumbnailCanvas = document.getElementById("thumbnail-canvas");
|
||||||
const thumbnailContext = thumbnailCanvas.getContext("2d");
|
const thumbnailContext = thumbnailCanvas.getContext("2d");
|
||||||
@@ -33,94 +33,13 @@ const imageContext = imageCanvas.getContext("2d");
|
|||||||
const paletteCanvas = document.getElementById("palette-canvas");
|
const paletteCanvas = document.getElementById("palette-canvas");
|
||||||
const paletteContext = paletteCanvas.getContext("2d");
|
const paletteContext = paletteCanvas.getContext("2d");
|
||||||
|
|
||||||
const inputElement = document.getElementById("imagefile");
|
|
||||||
inputElement.addEventListener("change", handleFile, false);
|
|
||||||
|
|
||||||
document.getElementById("paletteLeft").addEventListener("click", paletteLeft);
|
|
||||||
document.getElementById("paletteRight").addEventListener("click", paletteRight);
|
|
||||||
|
|
||||||
document.getElementById("cycleColors").addEventListener("click", () => {
|
|
||||||
if (running) {
|
|
||||||
running = false;
|
|
||||||
} else {
|
|
||||||
running = true;
|
|
||||||
animate();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const palettePageLabelEl = document.getElementById("palettePageLabel");
|
|
||||||
const cyclingSpeedLabel = document.getElementById("cyclingSpeedLabel");
|
|
||||||
cyclingSpeedLabel.innerText = cyclingSpeedSlider.value;
|
|
||||||
|
|
||||||
let currentPalettePage = 0;
|
let currentPalettePage = 0;
|
||||||
let image = null;
|
let image = null;
|
||||||
let running = false;
|
let running = false;
|
||||||
|
|
||||||
let cycleSpeed = 15.0;
|
let cycleSpeed = 15.0;
|
||||||
|
|
||||||
document
|
// Drawing
|
||||||
.getElementById("cyclingSpeedSlider")
|
|
||||||
.addEventListener("input", (evt) => {
|
|
||||||
cycleSpeed = evt.target.value;
|
|
||||||
cyclingSpeedLabel.innerText = cycleSpeed;
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch("/assets/TEST.LBM")
|
|
||||||
.then((response) => {
|
|
||||||
return response.arrayBuffer();
|
|
||||||
})
|
|
||||||
.then((buffer) => {
|
|
||||||
image = loadImage(buffer);
|
|
||||||
drawPalette(image.palette, currentPalettePage, paletteContext);
|
|
||||||
drawImage(image.thumbnail, thumbnailContext);
|
|
||||||
drawImage(image, imageContext);
|
|
||||||
console.log(image.cyclingRanges);
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleFile() {
|
|
||||||
const imageFile = this.files[0];
|
|
||||||
const reader = new FileReader();
|
|
||||||
|
|
||||||
reader.onload = (evt) => {
|
|
||||||
image = loadImage(evt.target.result);
|
|
||||||
drawPalette(image.palette, currentPalettePage, paletteContext);
|
|
||||||
drawImage(image.thumbnail, thumbnailContext);
|
|
||||||
drawImage(image, imageContext);
|
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsArrayBuffer(imageFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(buffer) {
|
|
||||||
image = new PBM(buffer);
|
|
||||||
thumbnailCanvas.width = image.thumbnail.width;
|
|
||||||
thumbnailCanvas.height = image.thumbnail.height;
|
|
||||||
imageCanvas.width = image.width;
|
|
||||||
imageCanvas.height = image.height;
|
|
||||||
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
function paletteLeft(evt) {
|
|
||||||
if (currentPalettePage === 0) {
|
|
||||||
currentPalettePage = 3;
|
|
||||||
} else {
|
|
||||||
currentPalettePage--;
|
|
||||||
}
|
|
||||||
palettePageLabelEl.innerText = currentPalettePage + 1;
|
|
||||||
drawPalette();
|
|
||||||
}
|
|
||||||
|
|
||||||
function paletteRight(evt) {
|
|
||||||
if (currentPalettePage === 3) {
|
|
||||||
currentPalettePage = 0;
|
|
||||||
} else {
|
|
||||||
currentPalettePage++;
|
|
||||||
}
|
|
||||||
palettePageLabelEl.innerText = currentPalettePage + 1;
|
|
||||||
drawPalette();
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawPalette() {
|
function drawPalette() {
|
||||||
const colorSize = 20; // in pixels
|
const colorSize = 20; // in pixels
|
||||||
const width = 4 * colorSize; // 4 columns
|
const width = 4 * colorSize; // 4 columns
|
||||||
@@ -143,19 +62,21 @@ function drawPalette() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawImage(image, ctx) {
|
function drawImage(anImage, ctx) {
|
||||||
ctx.clearRect(0, 0, image.width, image.height);
|
if (!anImage) return;
|
||||||
let pixels = ctx.createImageData(image.width, image.height);
|
|
||||||
|
|
||||||
for (let x = 0; x < image.width; x++) {
|
ctx.clearRect(0, 0, anImage.width, anImage.height);
|
||||||
for (let y = 0; y < image.height; y++) {
|
const pixels = ctx.createImageData(anImage.width, anImage.height);
|
||||||
const index = y * image.width + x;
|
|
||||||
const paletteIndex = image.pixelData[index];
|
for (let x = 0; x < anImage.width; x++) {
|
||||||
|
for (let y = 0; y < anImage.height; y++) {
|
||||||
|
const index = y * anImage.width + x;
|
||||||
|
const paletteIndex = anImage.pixelData[index];
|
||||||
const pixelIndex = index * 4;
|
const pixelIndex = index * 4;
|
||||||
|
|
||||||
const r = image.palette[paletteIndex][0];
|
const r = anImage.palette[paletteIndex][0];
|
||||||
const g = image.palette[paletteIndex][1];
|
const g = anImage.palette[paletteIndex][1];
|
||||||
const b = image.palette[paletteIndex][2];
|
const b = anImage.palette[paletteIndex][2];
|
||||||
|
|
||||||
pixels.data[pixelIndex] = r;
|
pixels.data[pixelIndex] = r;
|
||||||
pixels.data[pixelIndex + 1] = g;
|
pixels.data[pixelIndex + 1] = g;
|
||||||
@@ -167,6 +88,59 @@ function drawImage(image, ctx) {
|
|||||||
ctx.putImageData(pixels, 0, 0);
|
ctx.putImageData(pixels, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image loading
|
||||||
|
function loadImage(buffer) {
|
||||||
|
image = parsePBM(buffer);
|
||||||
|
thumbnailCanvas.width = image.thumbnail?.width || 80;
|
||||||
|
thumbnailCanvas.height = image.thumbnail?.height || 60;
|
||||||
|
imageCanvas.width = image.width;
|
||||||
|
imageCanvas.height = image.height;
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("imagefile").addEventListener(
|
||||||
|
"change",
|
||||||
|
(e) => {
|
||||||
|
const imageFile = e.target.files[0];
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (evt) => {
|
||||||
|
image = loadImage(evt.target.result);
|
||||||
|
drawPalette(image.palette, currentPalettePage, paletteContext);
|
||||||
|
drawImage(image.thumbnail, thumbnailContext);
|
||||||
|
drawImage(image, imageContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(imageFile);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Palette navigation
|
||||||
|
document.getElementById("paletteLeft").addEventListener("click", () => {
|
||||||
|
if (currentPalettePage === 0) {
|
||||||
|
currentPalettePage = 3;
|
||||||
|
} else {
|
||||||
|
currentPalettePage -= 1;
|
||||||
|
}
|
||||||
|
document.getElementById("palettePageLabel").innerText =
|
||||||
|
currentPalettePage + 1;
|
||||||
|
drawPalette();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("paletteRight").addEventListener("click", () => {
|
||||||
|
if (currentPalettePage === 3) {
|
||||||
|
currentPalettePage = 0;
|
||||||
|
} else {
|
||||||
|
currentPalettePage += 1;
|
||||||
|
}
|
||||||
|
document.getElementById("palettePageLabel").innerText =
|
||||||
|
currentPalettePage + 1;
|
||||||
|
drawPalette();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Color cycling
|
||||||
function cycleColors(now) {
|
function cycleColors(now) {
|
||||||
image.cyclingRanges.forEach((range) => {
|
image.cyclingRanges.forEach((range) => {
|
||||||
if (range.active) {
|
if (range.active) {
|
||||||
@@ -195,3 +169,19 @@ function animate(now) {
|
|||||||
|
|
||||||
if (running) requestAnimationFrame(animate);
|
if (running) requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.getElementById("cyclingSpeedSlider")
|
||||||
|
.addEventListener("input", (evt) => {
|
||||||
|
cycleSpeed = evt.target.value;
|
||||||
|
document.getElementById("cyclingSpeedLabel").innerText = cycleSpeed;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("cycleColors").addEventListener("click", () => {
|
||||||
|
if (running) {
|
||||||
|
running = false;
|
||||||
|
} else {
|
||||||
|
running = true;
|
||||||
|
animate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
1772
package-lock.json
generated
1772
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,10 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-c8": "^0.31.0",
|
"@vitest/coverage-c8": "^0.31.0",
|
||||||
|
"eslint": "^8.41.0",
|
||||||
|
"eslint-config-airbnb-base": "^15.0.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-import": "^2.27.5",
|
||||||
"vitest": "^0.31.0"
|
"vitest": "^0.31.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,17 @@ class BinaryStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readByte() {
|
readByte() {
|
||||||
const byte = this.dataView.getUint8(this.index);
|
return this.readUint8();
|
||||||
|
}
|
||||||
|
|
||||||
this.index++;
|
readBytes(length) {
|
||||||
return byte;
|
const bytes = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
bytes.push(this.readUint8());
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
readInt16BE() {
|
readInt16BE() {
|
||||||
|
|||||||
402
src/pbm.js
402
src/pbm.js
@@ -26,240 +26,208 @@
|
|||||||
|
|
||||||
import BinaryStream from "./binarystream.js";
|
import BinaryStream from "./binarystream.js";
|
||||||
|
|
||||||
class PBM {
|
function decompress(binaryStream, length) {
|
||||||
constructor(arrayBuffer) {
|
const result = [];
|
||||||
this.binaryStream = new BinaryStream(arrayBuffer);
|
const endOfChunkIndex = binaryStream.index + length;
|
||||||
|
|
||||||
// Image properties taken from BMHD chunk
|
while (binaryStream.index < endOfChunkIndex) {
|
||||||
this.width = null;
|
const byte = binaryStream.readByte();
|
||||||
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;
|
|
||||||
|
|
||||||
// Palette information taken from CMAP chunk
|
if (byte > 128) {
|
||||||
this.palette = [];
|
const nextByte = binaryStream.readByte();
|
||||||
|
for (let i = 0; i < 257 - byte; i++) {
|
||||||
// Color cycling information taken from CRNG chunk
|
result.push(nextByte);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
} else if (byte < 128) {
|
||||||
}
|
for (let i = 0; i < byte + 1; i++) {
|
||||||
|
result.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++) {
|
|
||||||
let 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: rate,
|
|
||||||
active: (flags & activeBitMask) !== 0,
|
|
||||||
direction: (flags & directionBitMask) !== 0 ? "reverse" : "forward",
|
|
||||||
low: low,
|
|
||||||
high: 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 {
|
} else {
|
||||||
this.thumbnail.pixelData = this.readUncompressed(endOfChunkIndex);
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse Image data chunk
|
return result;
|
||||||
parseBODY(chunkLength) {
|
}
|
||||||
const endOfChunkIndex = this.binaryStream.index + chunkLength;
|
|
||||||
|
|
||||||
// Decompress pixel data if necessary
|
// Parse Bitmap Header chunk
|
||||||
if (this.compression === 1) {
|
function parseBMHD(binaryStream, image) {
|
||||||
this.pixelData = this.decompress(endOfChunkIndex);
|
image.width = binaryStream.readUint16BE();
|
||||||
} else {
|
image.height = binaryStream.readUint16BE();
|
||||||
this.pixelData = this.readUncompressed(endOfChunkIndex);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Palette chunk
|
||||||
|
function parseCMAP(binaryStream, numPlanes) {
|
||||||
|
const palette = [];
|
||||||
|
const numColors = 2 ** 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(binaryStream.readByte());
|
||||||
}
|
}
|
||||||
|
palette.push(rgb);
|
||||||
}
|
}
|
||||||
|
|
||||||
decompress(endOfChunkIndex) {
|
return palette;
|
||||||
let result = [];
|
}
|
||||||
|
|
||||||
while (this.binaryStream.index < endOfChunkIndex) {
|
// Parse Color range chunk
|
||||||
const byte = this.binaryStream.readByte();
|
function parseCRNG(binaryStream) {
|
||||||
|
binaryStream.jump(2); // 2 bytes padding according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range
|
||||||
|
|
||||||
if (byte > 128) {
|
const rate = binaryStream.readInt16BE();
|
||||||
const nextByte = this.binaryStream.readByte();
|
const flags = binaryStream.readInt16BE();
|
||||||
for (let i = 0; i < 257 - byte; i++) {
|
const low = binaryStream.readUint8();
|
||||||
result.push(nextByte);
|
const high = binaryStream.readUint8();
|
||||||
}
|
|
||||||
} else if (byte < 128) {
|
// Parse flags according to https://en.wikipedia.org/wiki/ILBM#CRNG:_Colour_range
|
||||||
for (let i = 0; i < byte + 1; i++) {
|
// If bit 0 is 1, the color should cycle, otherwise this color register range is inactive
|
||||||
result.push(this.binaryStream.readByte());
|
// and should have no effect.
|
||||||
}
|
//
|
||||||
} else {
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Thumbnail chunk
|
||||||
|
function parseTINY(binaryStream, compression, chunkLength) {
|
||||||
|
const thumbnail = {};
|
||||||
|
|
||||||
|
thumbnail.width = binaryStream.readUint16BE();
|
||||||
|
thumbnail.height = binaryStream.readUint16BE();
|
||||||
|
thumbnail.size = thumbnail.width * thumbnail.height;
|
||||||
|
|
||||||
|
if (compression === 1) {
|
||||||
|
thumbnail.pixelData = decompress(binaryStream, chunkLength - 4);
|
||||||
|
} else {
|
||||||
|
thumbnail.pixelData = binaryStream.readBytes(chunkLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Image data chunk
|
||||||
|
function parseBODY(binaryStream, compression, chunkLength) {
|
||||||
|
if (compression === 1) {
|
||||||
|
return decompress(binaryStream, chunkLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return binaryStream.readBytes(chunkLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse FORM chunk
|
||||||
|
function parseFORM(binaryStream, image) {
|
||||||
|
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;
|
break;
|
||||||
}
|
case "CMAP":
|
||||||
|
image.palette = parseCMAP(binaryStream, image.numPlanes);
|
||||||
|
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":
|
||||||
|
image.thumbnail = parseTINY(
|
||||||
|
binaryStream,
|
||||||
|
image.compression,
|
||||||
|
chunkLength
|
||||||
|
);
|
||||||
|
|
||||||
|
// FIXME(m): Remove need for reference to image palette in thumbnail data
|
||||||
|
image.thumbnail.palette = image.palette;
|
||||||
|
break;
|
||||||
|
case "BODY":
|
||||||
|
image.pixelData = parseBODY(
|
||||||
|
binaryStream,
|
||||||
|
image.compression,
|
||||||
|
chunkLength
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported chunkId: ${chunkId} at byte ${binaryStream.index}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
// Skip chunk padding byte when chunkLength is not a multiple of 2
|
||||||
}
|
if (chunkLength % 2 === 1) binaryStream.jump(1);
|
||||||
|
|
||||||
// TODO(m): Read a range of bytes straight into an array?
|
|
||||||
// Use arrayBuffers throughout instead?
|
|
||||||
readUncompressed(endOfChunkIndex) {
|
|
||||||
let result = [];
|
|
||||||
|
|
||||||
while (this.binaryStream.index < endOfChunkIndex) {
|
|
||||||
const byte = this.binaryStream.readByte();
|
|
||||||
result.push(byte);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PBM;
|
export default function parsePBM(arrayBuffer) {
|
||||||
|
const binaryStream = new BinaryStream(arrayBuffer);
|
||||||
|
const image = {
|
||||||
|
cyclingRanges: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
parseFORM(binaryStream, image);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RangeError) {
|
||||||
|
throw new Error(`Failed to parse file.`);
|
||||||
|
} else {
|
||||||
|
throw error; // re-throw the error unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|||||||
BIN
tests/fixtures/ASH.LBM
vendored
BIN
tests/fixtures/ASH.LBM
vendored
Binary file not shown.
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-new */
|
||||||
/*
|
/*
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
@@ -26,48 +27,45 @@
|
|||||||
|
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
import PBM from "../src/pbm.js";
|
import parsePBM from "../src/pbm.js";
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
|
||||||
test("Successfully parse a PBM file", () => {
|
test("Successfully parse a PBM file", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
new PBM(data.buffer);
|
parsePBM(data.buffer);
|
||||||
}).not.toThrowError();
|
}).not.toThrowError();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Fail to parse a PBM file with an invalid chunk id", () => {
|
test("Fail to parse a PBM file with an invalid chunk id", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_ID.LBM");
|
const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_ID.LBM");
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
new PBM(data.buffer);
|
parsePBM(data.buffer);
|
||||||
}).toThrowError(/^Invalid chunkId: "FARM" at byte 12. Expected "FORM".$/);
|
}).toThrowError(/^Invalid chunkId: "FARM" at byte 12. Expected "FORM".$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Fail to parse a PBM file with an invalid chunk length", () => {
|
test("Fail to parse a PBM file with an invalid chunk length", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_LENGTH.LBM");
|
const data = fs.readFileSync("./tests/fixtures/INVALID_CHUNK_LENGTH.LBM");
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
new PBM(data.buffer);
|
parsePBM(data.buffer);
|
||||||
}).toThrowError(/^Invalid chunk length: 7070 bytes. Expected 7012 bytes.$/);
|
}).toThrowError(/^Invalid chunk length: 7070 bytes. Expected 7012 bytes.$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Fail to parse an IFF file that is not a PBM file", () => {
|
test("Fail to parse an IFF file that is not a PBM file", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/SEASCAPE.LBM");
|
const data = fs.readFileSync("./tests/fixtures/SEASCAPE.LBM");
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
new PBM(data.buffer);
|
parsePBM(data.buffer);
|
||||||
}).toThrowError(/^Invalid formatId: "ILBM". Expected "PBM ".$/);
|
}).toThrowError(/^Invalid formatId: "ILBM". Expected "PBM ".$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Parse a PBM bitmap header", () => {
|
test("Parse a PBM bitmap header", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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.width).toStrictEqual(640);
|
||||||
expect(image.height).toStrictEqual(480);
|
expect(image.height).toStrictEqual(480);
|
||||||
@@ -85,26 +83,23 @@ test("Parse a PBM bitmap header", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Parse PBM palette information", () => {
|
test("Parse PBM palette information", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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.length).toStrictEqual(256);
|
||||||
expect(image.palette[10]).toStrictEqual([87, 255, 87]);
|
expect(image.palette[10]).toStrictEqual([87, 255, 87]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Parse PBM color cycling information", () => {
|
test("Parse PBM color cycling information", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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);
|
expect(image.cyclingRanges.length).toStrictEqual(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Parse PBM thumbnail", () => {
|
test("Parse PBM thumbnail", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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.width).toStrictEqual(80);
|
||||||
expect(image.thumbnail.height).toStrictEqual(60);
|
expect(image.thumbnail.height).toStrictEqual(60);
|
||||||
@@ -112,9 +107,8 @@ test("Parse PBM thumbnail", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Decode PBM thumbnail pixel data", () => {
|
test("Decode PBM thumbnail pixel data", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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);
|
expect(image.thumbnail.pixelData.length).toStrictEqual(4800);
|
||||||
// FIXME(m): Verify these values are correct in the test image thumbnail:
|
// FIXME(m): Verify these values are correct in the test image thumbnail:
|
||||||
@@ -123,9 +117,8 @@ test("Decode PBM thumbnail pixel data", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Decode PBM image pixel data", () => {
|
test("Decode PBM image pixel data", () => {
|
||||||
const fs = require("fs");
|
|
||||||
const data = fs.readFileSync("./tests/fixtures/VALID.LBM");
|
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);
|
expect(image.pixelData.length).toStrictEqual(307_200);
|
||||||
// FIXME(m): Verify these values are correct in the test image:
|
// FIXME(m): Verify these values are correct in the test image:
|
||||||
|
|||||||
Reference in New Issue
Block a user