colors.js

const hummus = require('hummus');
const fs = require('fs');

this.knownColors = {  // knownColors.colorspace.colorName = value
    rgb: {
        red    : 'ff0000',
        green  : '00ff00',
        blue   : '0000ff',
    },
    cmyk: {
        cyan   : 'ff000000',
        magenta: '00ff0000',
        yellow : '0000ff00',
        black  : '000000ff'
    },
    gray: {
        white  : 'ff',
        black  : '00'
    },
    separation : {              // meant for printing, so using cmyk values initially
        cyan   : 'ff000000',
        magenta: '00ff0000',
        yellow : '0000ff00',
        black  : '000000ff',
        nans   : '%,35,6,0'   // a great PDF collaborator!
    }
};

/**
 * Associate color values to names
 *
 * The colorspace parameter is optional. When it is missing, the colorspace
 * is automatically determined by the given color value. Note that the special
 * PDF color space called 'separation' may also be used. The color value is then
 * treated as the alternative color when the named 'separation' color is unavailable.
 *
 * If the 'name' parameter is '!load', the second parameter is the name of a JSON
 * formatted file containing a formatted list of defined colors associated with the
 * color spaces rgb, cmyk, gray, or separation (think PANTONE color definitions).
 * This file will be merged with existing set of known colors. The color values
 * must be specified as hex values.
 *
 * For example,
 *   {
 *      'rgb':  {'purple':'ff00ff', 'red':'#ff0000'},
 *      'cmyk': {'cyan':'ff000000', 'magenta':'%0,100,0,0'},
 *      'gray': {'grey':'#33'}
 *   }
 *
 * @name chroma
 * @function
 * @memberof Recipe
 * @param {string} name - the name to be associated to given color value, or '!load'
 * @param {string|number[]} value - the color value (HexColor, DecimalColor, or PercentColor), or name of '!load' file
 * @param {string} colorspace - one of the followning: 'rgb', 'cmyk', 'gray', 'separation';
 */
exports.chroma = function chroma(name, value, colorspace='') {
    if (name) {
        if (name === '!load') {
            let newColors = JSON.parse(fs.readFileSync(value));
            // Add new colors to existing colorspaces
            for (let cs in newColors) {
                if (this.knownColors[cs]) {
                    Object.assign(this.knownColors[cs], newColors[cs]);
                } else {
                    throw new Error(`Unrecognized colorspace: ${cs}`);
                }
            }
        } else {
            if (Array.isArray(value)) {
                value = arrayToHex(value);
            } else if (value.startsWith('%')) {
                value = percentToHex(value.replace('%', ''));
            } else {
                value = value.replace('#', '');
            }

            // Only deal with valid hex codes from the
            // device colorspaces gray, rgb, and cmyk.
            if (![2,6,8].includes(value.toString().length)) {
                throw new Error('Color value has incorrect size for gray, rgb, or cmyk colorspaces');
            }

            // Determine colorspace by length of given input
            // value when colorspace not provided in call.
            if (colorspace === '') {
                const colorSpaces = {2:'gray', 6:'rgb', 8:'cmyk'};
                colorspace = colorSpaces[`${value.length}`];
            } else if (!['rgb','cmyk','gray','separation'].includes(colorspace)) {
                throw new Error(`Unknown colorspace: ${colorspace}.`);
            }

            if (colorspace) {
                this.knownColors[colorspace][name] = value;
            }
        }
    }

    return this;
};

function createColorSpaces(self, colorName, color) {
    const deviceCS =  {1:'DeviceGray', 3:'DeviceRGB', 4:'DeviceCMYK'};
    const altCS = deviceCS[`${color.length}`];
    this.colorSpaces = this.colorSpaces || {};
    this.colorSpaces[altCS] = this.colorSpaces[altCS] || {};
    let colorSpaceID = this.colorSpaces[altCS][colorName];

    if (!colorSpaceID) {
        const transformFunction = tintTransform(self, color);
        self.pauseContext();
        const objCxt = self.writer.getObjectsContext();
        colorSpaceID = objCxt.startNewIndirectObject();
        objCxt
            .startArray()
            .writeName('Separation')
            .writeName(colorName)
            .writeName(altCS)
            .writeIndirectObjectReference(transformFunction)
            .endArray(hummus.eTokenSeparatorEndLine)
            .endIndirectObject();
        self.resumeContext();
        this.colorSpaces[altCS][colorName] = colorSpaceID;
    }

    return colorSpaceID;
}

function tintTransform(self, color) {
    const rangeCount = color.length;
    self.pauseContext();
    const objCxt = self.writer.getObjectsContext();
    const tintFuncID = objCxt.startNewIndirectObject();
    const dict = objCxt.startDictionary();
    dict
        .writeKey('FunctionType')
        .writeNumberValue(2)
        .writeKey('Domain');
    objCxt
        .startArray()
        .writeNumber(0.0)
        .writeNumber(1.0)
        .endArray();
    dict.writeKey('Range');
    objCxt.startArray();
    for (let index = 0; index < rangeCount; index++) {
        objCxt.writeNumber(0.0).writeNumber(1.0);
    }
    objCxt.endArray();
    dict.writeKey('N');
    dict.writeNumberValue(1);
    dict.writeKey('C0');
    objCxt.startArray();
    for (let index = 0; index < rangeCount; index++) {
        objCxt.writeNumber(0.0);
    }
    objCxt.endArray();

    dict.writeKey('C1');
    objCxt.startArray();
    for (let index = 0; index < rangeCount; index++) {
        objCxt.writeNumber(color[index]);
    }
    objCxt.endArray();
    objCxt.endDictionary(dict);
    objCxt.endIndirectObject();
    self.resumeContext();

    return tintFuncID;
}

exports._createExtGStates = function _createExtGStates(value) {
    this.extGStates = this.extGStates || {};
    if (this.extGStates[value]) {
        return this.extGStates[value];
    }

    const write = (key, value) => {
        this.pauseContext();
        const objCxt = this.writer.getObjectsContext();
        const gsId = objCxt.startNewIndirectObject();
        const dict = objCxt.startDictionary();
        dict.writeKey('type');
        dict.writeNameValue('ExtGState');
        dict.writeKey(key);
        objCxt.writeNumber(value);
        objCxt.endLine();
        objCxt.endDictionary(dict);
        objCxt.endIndirectObject(); // new here [seh]
        this.resumeContext();
        return gsId;
    };
    this.extGStates[value] = {
        stroke: write('CA', value),
        fill:   write('ca', value)
    };
    return this.extGStates[value];
};

function _defaultColor(colorspace='rgb') {
    let defaultColor;
    switch (colorspace) {
        case 'cmyk':
            defaultColor = 'FF000000';
            break;
        case 'gray':
            defaultColor = '00';
            break;
        case 'rgb':
        default:
            defaultColor = '1777d1';
            break;
    }

    return defaultColor;
}

/**
 * Convert given color code int color model object
 *
 * ColorModel consists of: {
 *   color: number,
 *   colorspace: string {'rgb', 'cmyk', 'gray'},
 *   (colorspace == 'rgb')  r, g, b
 *   (colorspace == 'cmyk') c, m, y, k
 *   (colorspace == 'gray') gray
 * }
 *
 * where r,g,b,c,m,y,k,gray are all numbers between 0 and 1
 *
 * @param {string} code the color encoding as HexColor
 * @param {string} colorspace the name of the colorspace of given color code
 * @param {string} colorName the name to be associated with given color code
 * @returns {any} the color model
 */
function toColorModel(self, code, colorspace, colorName) {
    const cmodel = {};
    let color = hexToArray(code);

    cmodel.color = parseInt(code, 16);

    // The initial decider of color space is length of given 'code'.

    switch (color.length) {
        default:
            color = hexToArray(_defaultColor('rgb'));
            // purposely want to fall through to 'rgb' case below.
        case 3:
            cmodel.colorspace = 'rgb';
            cmodel.r = color[0];
            cmodel.g = color[1];
            cmodel.b = color[2];
            break;

        case 4:
            cmodel.colorspace = 'cmyk';
            cmodel.c = color[0];
            cmodel.m = color[1];
            cmodel.y = color[2];
            cmodel.k = color[3];
            break;

        case 1:
            cmodel.colorspace = 'gray';
            cmodel.gray = color[0];
            break;
    }

    // When creating a separation color space,
    // use the colorspace from above as the
    // alternative color transformation when
    // the named color is unavailable.
    if (colorspace === 'separation' && colorName !== '') {
        cmodel.colorspace = colorspace;
        cmodel.colorName = colorName;
        cmodel.colorspaceId = createColorSpaces(self, colorName, color);
    }

    return cmodel;
}

/**
 * Convert percentage string into hex string (x / 100 * 255)
 *
 * @param {string} code numbers separated by commas with values ranging between 0-100.
 * @returns {string} massaged hexadecimal string that can be used as input to hexToArray.
 */
function percentToHex(code) {
    return arrayToHex(code.split(',').map( x => Math.round(x * 2.55)) );
}

/**
 * Transform color code into numeric value or colorModel
 *
 * @param code color specification in form of HexColor (string, begins with '#'),
 *             DecimalColor (1, 3, or 4 element array with values between 0-255),
 *             PercentColor (string, begins with '%' followed by values separated
 *             by commas with values between 0-100)
 */
exports._transformColor = function _transformColor(code = '', opt = {}) {
    this.knownColors   = this.knownColors || {};
    let colorspace     = opt.colorspace || 'rgb';
    let wantColorModel = opt.wantColorModel || false;
    let colorName      = opt.colorName || '';
    let defaultColor   = _defaultColor(colorspace);
    let transformation;

    if (Array.isArray(code)) {
        code = arrayToHex(code);
    } else if (code.startsWith('#')){
        code = code.replace('#', '');
    } else if (code.startsWith('%')) {
        code = percentToHex(code.replace('%', ''));
    } else if (code !== '') {
        let color = this.knownColors[colorspace][code];  // assuming code is a color name
        if (!color) {
            color = '';
            code = defaultColor;
        } else {
            colorName = code;

            // The following handles known colors in hex form,
            // with or without initial '#' and percent form.
            if (color.startsWith('#')) {
                code = color.replace('#','');
            } else if (color.startsWith('%')) {
                code = percentToHex(color.replace('%', ''));
            } else {
                code = color;
            }
        }
    }

    // When colorspace is not explicitly given,
    // use size of value to determine colorspace.
    if (!opt.colorspace) {
        colorspace = {2:'gray', 6:'rgb', 8:'cmyk'}[`${code.length}`] || 'rgb';
        defaultColor = _defaultColor(colorspace);
    }

    // Suppply default color:
    //  when colorspace is given and given color code does not have appropriate length, or
    //  when colorspace is missing, verify allowable hex value sizes for rgb, cmyk, or gray.
    if ( ['rgb','cmyk','gray'].includes(colorspace) && code.length != defaultColor.length ||
         ! [2,6,8].includes(code.toString().length)) {
        code = defaultColor;
    }

    if (wantColorModel) {
        transformation = toColorModel(this, code, colorspace, colorName);
        if ( colorName && !this.knownColors[colorspace][colorName]) {
            this.chroma(colorName, code, colorspace);
        }
    } else {
        transformation = parseInt(`0x${code.toUpperCase()}`, 16);
    }

    return transformation;
};

function arrayToHex(color = []) {
    let code = '';
    color.forEach((item) => {
        let hex = item.toString(16);
        hex = (hex.length == 1) ? '0' + hex : hex;
        code += hex;
    });
    return code;
}

function hexToArray(hex) {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})?([a-f\d]{2})?([a-f\d]{2})?$/i.exec(hex);
    return result
        .reduce((array, item, index) => {
            if (index >= 1 && index <= (hex.length / 2)) {
                array.push(parseInt(item, 16) / 255);
            }
            return array;
        }, []);
}

exports._colorNumberToRGB = (bigint) => {
    if (!bigint) {
        return {
            r: 0,
            g: 0,
            b: 0
        };
    } else {
        return {
            r: (bigint >> 16) & 255,
            g: (bigint >>  8) & 255,
            b: bigint & 255
        };
    }
};