thebespokepixel/palette2oco

View on GitHub
src/classes/reader.js

Summary

Maintainability
A
3 hrs
Test Coverage
/*
 * Open Color Converter for Sip, JSON and ASE palettes
 * ──────────────────────────────────────────────────────────────
 * ©2021 Mark Griffiths @ The Bespoke Pixel (MIT licensed)
 */
/* eslint node/prefer-global/process: [error] */

import {relative as relativePath} from 'node:path'
import fs from 'node:fs'

import _ from 'lodash'
import {promisify} from 'es6-promisify'
import {OCOValueEX, fromPrecise, fromBytes} from '@thebespokepixel/oco-colorvalue-ex'
import oco from 'opencolor'
import ase from 'ase-util'
import {console} from '../index.js'

const loader = promisify(fs.readFile)

const supportedTypes = [
    'oco',
    'json',
    'sippalette',
    'ase',
]

const fileFilter = new RegExp(`.(${supportedTypes.join('|')})$`)
const fileMatch = new RegExp(`(.*/)*(.+?).(${supportedTypes.join('|')})$`)

function createIdentity(rootPath) {
    return function (path) {
        const address = path
            .replace(rootPath, '')
            .match(fileMatch)
        return {
            source: path,
            name: address[2],
            path: address[1].replace(/^\//, '').replace(/\//g, '.'),
            type: address[3],
        }
    }
}

function testPaletteJSONColor(datum, min, max) {
    return {
        name: typeof datum.name === 'string' && datum.name,
        red: (datum.red >= min && datum.red <= max) && datum.red,
        green: (datum.green >= min && datum.green <= max) && datum.green,
        blue: (datum.blue >= min && datum.blue <= max) && datum.blue,
        alpha: (datum.alpha >= min && datum.alpha <= max) && datum.alpha,
    }
}

function isPaletteJSON(datum) {
    const tests = {
        palette: {
            name: (typeof datum.name === 'string') && datum.name,
            colors: (Array.isArray(datum.colors)) && datum.colors,
        },
        rgba: testPaletteJSONColor(datum, 0, 1),
        rgbaInteger: testPaletteJSONColor(datum, 0, 255),
    }
    return {
        isPalette: _.isEqual(datum, tests.palette),
        isRGBA: _.isEqual(datum, tests.rgba),
        isIntegerRGBA: _.isEqual(datum, tests.rgbaInteger),
    }
}

async function loadOCO(identity) {
    const ocoSource = await loader(identity.source, 'utf8')
    return oco.parse(ocoSource)
}

async function loadJSON(identity) {
    const json = await loader(identity.source, 'utf8')
    const palette = JSON.parse(json)

    if (isPaletteJSON(palette).isPalette) {
        console.debug(`JSON Palette: ${palette.name}`)
        return new oco.Entry(
            identity.name,
            [OCOValueEX.generateOCO(
                palette.name,
                palette.colors.map(color => {
                    const paletteColor = isPaletteJSON(color)
                    switch (true) {
                        case paletteColor.isRGBA:
                            console.debug(`JSON Color (RGBA): ${color.name}`)
                            return fromPrecise(color)
                        case paletteColor.isIntegerRGBA:
                            console.debug(`JSON Color (Integer RGBA): ${color.name}`)
                            return fromBytes(color)
                        default:
                            throw new Error(`${color.name}.json is not a valid JSON color object`)
                    }
                }),
            )],
        )
    }

    throw new Error(`${identity.name}.json is not a valid palette`)
}

async function loadASE(identity) {
    function scan(node) {
        return node.map(datum => {
            switch (datum.type) {
                case 'color':
                    switch (datum.color.model) {
                        case 'Gray':
                        case 'RGB':
                            console.debug(`ASE Color (RGB/Grayscale): ${datum.name}`)
                            return new OCOValueEX(datum.color.hex, datum.name)
                        case 'CMYK':
                            console.debug(`ASE Color (CMYK): ${datum.name}`)
                            return new OCOValueEX({
                                cyan: datum.color.c,
                                magenta: datum.color.m,
                                yellow: datum.color.y,
                                black: datum.color.k,
                            }, datum.name)
                        case 'LAB':
                            console.debug(`ASE Color (Lab): ${datum.name}`)
                            return new OCOValueEX({
                                L: datum.color.lightness,
                                a: datum.color.a,
                                b: datum.color.b,
                            }, datum.name)
                        default:
                            throw new Error(`${datum.color.model} is not a valid ASE color model`)
                    }

                case 'group':
                    console.debug(`ASE Group: ${datum.name}`)
                    return OCOValueEX.generateOCO(
                        datum.name,
                        scan(datum.entries),
                    )

                default:
                    throw new Error(`${datum.type} is not a valid ASE data type`)
            }
        })
    }

    const aseSource = await loader(identity.source)
    const palette = await ase.read(aseSource)

    if (Array.isArray(palette)) {
        return palette.length === 1 ? new oco.Entry(
            identity.name,
            scan(palette),
        ) : OCOValueEX.generateOCO(
            identity.name,
            scan(palette),
        )
    }

    throw new Error(`${identity.name}.ase is not a valid palette`)
}

function selectLoaderByIndentity(type) {
    switch (type) {
        case 'sippalette':
            return loadJSON
        case 'json':
            return loadJSON
        case 'ase':
            return loadASE
        case 'oco':
            return loadOCO
        default:
            throw new Error(`${type} is not recognised`)
    }
}

export default class Reader {
    constructor(source_) {
        this.sourcePath = source_
        this.tree = new oco.Entry()
    }

    pick(key_) {
        return key_ ? this.tree.get(key_) : this.tree.root()
    }

    /**
     * Transform the internal color palette to a range of additional formats.
     * @private
     * @param  {string[]} formats - An array of color formats to add to the palette.
     * @return {Reader} The Reader instance.
     */
    transform(formats) {
        this.tree.traverseTree('Color', color_ => {
            const original = color_.get(0).identifiedValue.getOriginalInput()
            color_.children = []

            for (const [index_, format] of formats.entries()) {
                const newFormat = new OCOValueEX(original, color_.name)
                newFormat._format = format

                color_.addChild(new oco.ColorValue(
                    format,
                    newFormat.toString(format),
                    newFormat,
                ), true, index_)
            }
        })
        return this
    }

    /**
     * Load palette data from an array of paths.
     * @private
     * @param  {string[]} pathArray The array of paths to load.
     * @return {Promise} A Promise that resolves with this instance when loaded.
     */
    async load(pathArray) {
        await Promise.all(pathArray
            .filter(file => file.match(fileFilter))
            .map(file => createIdentity(this.sourcePath)(file))
            .map(async identity => {
                const entry = await selectLoaderByIndentity(identity.type)(identity)
                entry.addMetadata({
                    'import/file/source': relativePath(process.cwd(), identity.source),
                    'import/file/type': identity.type,
                })
                this.tree.set(`${identity.path}${identity.name}`, entry)
            }),
        )
        return this
    }

    render(path) {
        return oco.render(this.pick(path))
    }
}