pbm-js/src/pbm.js
Michael Smith 9dcbdd0327 Compatibility improvements and minor fixes
* Account for padding byte when chunk length is not a multiple of 2
* Use chunkLength to parse TINY and BODY chunk data
* Rename chunkID, lenChunk and formatID
* Deduplicate decompression code
2023-05-13 22:02:33 +02:00

249 lines
7.2 KiB
JavaScript

/*
MIT License
Copyright (c) 2023 Michael Smith <root@retrospace.be>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
import BinaryStream from "./binarystream.js";
class PBM {
constructor(arrayBuffer) {
this.binaryStream = new BinaryStream(arrayBuffer);
// 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;
// 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
}
}
}
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 cyclingRange = {
rate: this.binaryStream.readInt16BE(),
flags: this.binaryStream.readInt16BE(),
low: this.binaryStream.readUint8(),
hight: this.binaryStream.readUint8(),
};
this.cyclingRanges.push(cyclingRange);
}
// 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) {
let 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) {
let result = [];
while (this.binaryStream.index < endOfChunkIndex) {
const byte = this.binaryStream.readByte();
result.push(byte);
}
return result;
}
}
export default PBM;