Skip to content

Instantly share code, notes, and snippets.

@Alhadis
Created June 10, 2026 09:11
Show Gist options
  • Select an option

  • Save Alhadis/2a8bf8f37277bdfb338e8ddea20c2614 to your computer and use it in GitHub Desktop.

Select an option

Save Alhadis/2a8bf8f37277bdfb338e8ddea20c2614 to your computer and use it in GitHub Desktop.
Binary file format parsers
#!/usr/bin/env node
/**
* @fileoverview Program to convert Adobe Colour Book files to JSON.
* @todo Compare our implementation with the ACB file spec documented by Adobe
* @see {@link https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577411_pgfId-1066780}
* @see {@link https://ates.dev/pages/acb-spec/}
*/
import {readFileSync} from "fs";
/**
* Read the contents of an Adobe Colour Book (ACB) file.
* @param {Uint8Array} bytes
* @return {Object}
*/
function parseACB(bytes){
let offset = 0;
const dv = new DataView(bytes.buffer);
const isEOL = () => offset >= bytes.byteLength;
const getByte = () => getBytes(1)[0];
const getBytes = count => bytes.slice(offset, offset += count);
const getChars = count => String.fromCodePoint(...getBytes(count));
const getShort = () => {
const int = dv.getUint16(offset);
offset += 2;
return int;
};
const getLong = () => {
const int = dv.getUint32(offset);
offset += 4;
return int;
};
const getString = () => {
let result = "";
const length = getLong();
for(let i = 0; i < length; ++i)
result += String.fromCodePoint(getShort());
return result.replaceAll("^C", "©").replaceAll("^R", "®");
};
if("8BCB" !== getChars(4))
throw new TypeError("Invalid header; file is not an Adobe Colour Book");
const version = getShort();
if(1 !== version)
throw new TypeError(`Invalid colour book version: ${version}`);
const id = getShort().toString(16).toUpperCase().padStart(4, "0");
const title = getString();
const prefix = getString();
const suffix = getString();
const about = getString();
const count = getShort();
const limit = getShort();
const sample = getShort();
const space = {
__proto__: null,
0: "RGB",
1: "HSB",
2: "CMYK",
3: "PANTONE",
4: "FOCOLTONE",
5: "TRUMATCH",
6: "Toyo",
7: "Lab", // CIELAB D50
8: "Greyscale",
10: "HKS",
}[getShort()] || "Unknown";
const colours = [];
for(let i = 0; i < count; ++i){
const components = [];
const colour = {
name: getString(),
code: getChars(6),
[space.toLowerCase()]: components,
};
colours.push(colour);
switch(space){
case "RGB":
components.push(...getBytes(3));
break;
case "CMYK":
components.push(...getBytes(4).map(n => (255 - n) / 2.55 + 0.5));
break;
case "Lab":
components.push(
getByte() / 2.55 + 0.5,
getByte() - 128,
getByte() - 128,
);
break;
default:
throw new TypeError(`Unsupported colour space: ${space}`);
}
}
const typeFlag = (!isEOL() && {
__proto__: null,
spflspot: "Spot colours",
spflproc: "Process colours",
}[getChars(8)]) || null;
return {
version,
bookID: id,
bookTitle: title,
bookType: typeFlag,
colourNamePrefix: prefix,
colourNameSuffix: suffix,
bookDescription: about,
colourCount: count,
pageSize: limit,
pageSelectorOffset: sample,
colourSpace: space,
colours,
};
}
function loadACBFile(file){
const bytes = Uint8Array.from(readFileSync(file));
return {file, ...parseACB(bytes)};
}
for(const file of process.argv.slice(2)){
const acb = loadACBFile(file);
process.stdout.write(JSON.stringify(acb, null, "\t") + "\n");
}
import {readFileSync} from "fs";
/**
* Client data exported from SoulseekQt.
* @see https://github.com/nicotine-plus/nicotine-plus/issues/1685#issuecomment-988321580
* @class
*/
export default class SoulseekClientData{
/**
* List of table names whose data is a number encoded as an ASCII string.
* @example [52, 53, 48] => 450
* @property {String[]} NUMERIC_TABLES
* @static
*/
static NUMERIC_TABLES = `
bitrate
length_seconds
private_message_time
search_time
shared_file
shared_file_size
sizer_height
sizer_size
sizer_tree_column
sizer_width
sizer_x
sizer_y
`.trim().split(/\s+/g);
/**
* List of table names whose data is encoded as an ASCII string.
* @example [65, 66, 67] => "ABC"
* @property {String[]} STRING_TABLES
* @static
*/
static STRING_TABLES = `
color_setting
color_setting_value
event_sound
excluded_download_extension
folder_name
global
global_value
private_message_direction
private_message_text
search_record
shared_file_folder
shared_file_name
shared_folder
sizer_splitter
sizer_tree
sizer_window
user
wish_list_item
`.trim().split(/\s+/g);
/**
* Combined list of all recognised table names.
* @property {String[]} ALL_TABLES
* @static
*/
static ALL_TABLES = this.STRING_TABLES.concat(this.NUMERIC_TABLES);
#dv = null;
#bytes = null;
tables = null;
mappings = null;
offset = 0;
/**
* Parse a loaded SCD file.
* @param {Uint8Array|ArrayBuffer} bytes
* @constructor
*/
constructor(bytes){
if(bytes instanceof (Uint8Array.__proto__))
bytes = bytes.buffer;
if(!(bytes instanceof ArrayBuffer))
throw new TypeError("Argument is not a TypedArray or ArrayBuffer");
this.#dv = new DataView(bytes);
this.#bytes = new Uint8Array(bytes);
// Read tables section
let count = this.#readInt();
this.tables = {__proto__: null};
for(let i = 0; i < count; ++i){
const table = this.#readTable();
const {name} = table;
table.index = i;
this.tables[name] = table;
if(new.target.ALL_TABLES.includes(name)){
const flatMap = {__proto__: null};
const isNum = new.target.NUMERIC_TABLES.includes(name);
for(const {key, data} of table){
let value = String.fromCharCode(...data);
if(isNum) value = value.includes(".")
? parseFloat(value)
: parseInt(value, 10);
flatMap[key] = value;
}
this.tables[name] = flatMap;
}
}
// Read mappings section
count = this.#readInt();
this.mappings = {__proto__: null};
for(let i = 0; i < count; ++i)
this.#readMapping();
// Shouldn't happen, but make it known when it does
const junk = this.#dv.buffer.byteLength - this.offset;
if(junk > 0)
console.error(`Ignoring ${junk} bytes of trailing garbage`);
}
/**
* Read an unsigned, 4-byte, little-endian integer from the byte-stream.
* @return {Number}
* @private
*/
#readInt(){
const int = this.#dv.getUint32(this.offset, true);
this.offset += 4;
return int;
}
/**
* Read an arbitrary number of bytes from the byte-stream.
* @param {Number} [count=1]
* @throws {RangeError} Argument must be greater than zero
* @return {Uint8Array}
* @private
*/
#readBytes(count = 1){
if(!isFinite(count) || count < 1)
throw new RangeError("Invalid byte-count: " + count);
return this.#bytes.slice(this.offset, this.offset += count);
}
/**
* Read an entry from the SCD file's mappings table.
* @return {void}
* @private
*/
#readMapping(){
const key1 = this.#readInt();
const key2 = this.#readInt();
(this.mappings[key1] ??= []).push(key2);
}
/**
* Read an entry from the SCD file's tables list.
* @return {Table[]}
* @private
*/
#readTable(){
const nameSize = this.#readInt();
const name = String.fromCharCode(...this.#readBytes(nameSize));
const tableSize = this.#readInt();
const table = new Array(tableSize);
table.name = name;
for(let i = 0; i < tableSize; ++i){
const key = this.#readInt();
const size = this.#readInt();
const data = this.#readBytes(size);
/**
* @typedef Table
* @property {Number} key
* @property {Number} size
* @property {Uint8Array} data
*/
table[i] = {key, size, data};
}
return table;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment