Created
June 10, 2026 09:11
-
-
Save Alhadis/2a8bf8f37277bdfb338e8ddea20c2614 to your computer and use it in GitHub Desktop.
Binary file format parsers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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"); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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