crdtuxpPHXS.js

// Copyright (c) 2024–present Rorohiko Ltd. All rights reserved.
// SPDX-License-Identifier: Elastic-2.0
// https://github.com/zwettemaan/CRDT_UXP

/**
 * Creative Developer Tools (CRDT) is a growing suite of tools aimed at script developers<br>
 * and plug-in developers for the Adobe Creative Cloud eco-system.<br>
 * <br>
 * This module provides functions that are specific to Adobe Photoshop.<br>
 * <br>
 * Started (2026) with a per-document config-layer API - a dedicated layer
 * group used as an INI-style settings store inside the PSD itself, since
 * Photoshop (unlike InDesign) has no pasteboard and most of its own
 * preferences are global rather than per-document. See PXConfig
 * (https://github.com/zwettemaan/PXConfig) for the first consumer.<br>
 * <br>
 * Intended to grow into a general collection of Photoshop-specific
 * UXP/UXPScript helpers over time, the way crdtuxpIDSN.js did for InDesign -
 * not just the config-layer API.
 *
 * @module crdtuxpPHXS
 * @namespace crdtuxpPHXS
 */

if (! module.exports) {
    module.exports = {};
}
let crdtuxpPHXS = module.exports;

let crdtuxp = getCRDTUXP();

const REGEXP_TRIM                      = /^\s*(\S?.*?)\s*$/;
const REGEXP_TRIM_REPLACE              = "$1";
const REGEXP_DESPACE                   = /\s+/g;
const REGEXP_DESPACE_REPLACE           = "";
const REGEXP_ALPHA_ONLY                = /[^-a-zA-Z0-9_$]+/g;
const REGEXP_ALPHA_ONLY_REPLACE        = "";
const REGEXP_SECTION_NAME_ONLY         = /[^-a-zA-Z0-9_$:]+/g;
const REGEXP_SECTION_NAME_ONLY_REPLACE = "";
const REGEXP_HEX_COLOR                 = /^#?([0-9a-fA-F]{6})$/;

const STATE_IDLE                       = 0;
const STATE_SEEN_OPEN_SQUARE_BRACKET   = 1;
const STATE_SEEN_NON_WHITE             = 2;
const STATE_SEEN_EQUAL                 = 4;
const STATE_ERROR                      = 5;
const STATE_SEEN_CLOSE_SQUARE_BRACKET  = 6;
const STATE_IN_COMMENT                 = 7;

function getCRDTUXP() {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            retVal = global.crdtuxp;
            if (retVal) {
                break;
            }

            if (typeof require != "function") {
                break;
            }

            retVal = require("./crdtuxp.js");
            if (retVal) {
                global.crdtuxp = retVal;
            }
        }
        catch (err) {
        }
    }
    while (false);

    return retVal;
}

/**
 * Get the Photoshop UXP `photoshop` module's `app`/`constants` objects.
 * Centralises the `require("photoshop")` call so every function below goes
 * through the same spot.
 *
 * @function getPhotoshop
 * @memberOf crdtuxpPHXS
 *
 * @returns {object} `{ app, constants }`, or `undefined` if not running
 *   inside Photoshop's UXP host (e.g. required from a non-Photoshop context).
 */

function getPhotoshop() {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (typeof require != "function") {
                break;
            }

            let photoshop = require("photoshop");
            if (! photoshop || ! photoshop.app || ! photoshop.constants) {
                break;
            }

            retVal = photoshop;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.getPhotoshop = getPhotoshop;

/**
 * Run a callback inside Photoshop's required `executeAsModal` wrapper -
 * needed for any document-mutating call (layer create/move/visibility/
 * selection, etc.), which Photoshop's UXP API rejects outside of one.
 * Centralised here so every PXConfig-style script uses the same wrapper
 * instead of each reimplementing it.
 *
 * @function runModal
 * @memberOf crdtuxpPHXS
 *
 * @param {Function} callback - function to run inside the modal scope; may
 *   be `async` and/or return a Promise
 * @param {string} commandName - label shown in Photoshop's Undo history
 * @returns {Promise} resolves with `callback`'s return value, rejects if
 *   `callback` throws or this isn't running inside Photoshop
 */

function runModal(callback, commandName) {
// coderstate: promisor
    let retVal = Promise.resolve(undefined);

    do {
        try {
            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            retVal = photoshop.core.executeAsModal(
                callback,
                { commandName: commandName || "PXConfig" }
            );
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.runModal = runModal;

/**
 * Find the first top-level layer group in a document whose name contains
 * `nameSubstring`.
 *
 * @function findLayerGroupByNameSubstring
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document` (e.g. `app.activeDocument`)
 * @param {string} nameSubstring - substring to look for in top-level group names
 * @returns {Layer} the matching layer group, or `undefined` if not found
 */

function findLayerGroupByNameSubstring(doc, nameSubstring) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! doc || ! nameSubstring) {
                crdtuxp.logError(arguments, "need doc and nameSubstring");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            let layers = doc.layers;
            if (! layers) {
                break;
            }

            for (let idx = 0; idx < layers.length; idx++) {
                let layer = layers[idx];
                if (! layer) {
                    continue;
                }

                let isGroup = layer.kind == photoshop.constants.LayerKind.GROUP;
                if (! isGroup) {
                    continue;
                }

                if (layer.name && layer.name.indexOf(nameSubstring) != -1) {
                    retVal = layer;
                    break;
                }
            }
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.findLayerGroupByNameSubstring = findLayerGroupByNameSubstring;

/**
 * Collect all text layers directly inside a layer group, sorted
 * left-to-right by the left edge of their bounds.
 *
 * @function collectTextLayersSortedByX
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} group - a Photoshop UXP layer group
 * @returns {Layer[]} text layers, sorted by `bounds.left` ascending (empty
 *   array if `group` has no text layers or is falsy)
 */

function collectTextLayersSortedByX(group) {
// coderstate: function
    let retVal = [];

    do {
        try {
            if (! group || ! group.layers) {
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            let textLayers = [];
            let layers = group.layers;
            for (let idx = 0; idx < layers.length; idx++) {
                let layer = layers[idx];
                if (layer && layer.kind == photoshop.constants.LayerKind.TEXT) {
                    textLayers.push(layer);
                }
            }

            textLayers.sort(
                function compareByLeft(layerA, layerB) {
                    let leftA = (layerA.bounds && layerA.bounds.left) || 0;
                    let leftB = (layerB.bounds && layerB.bounds.left) || 0;
                    return leftA - leftB;
                }
            );

            retVal = textLayers;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.collectTextLayersSortedByX = collectTextLayersSortedByX;

/**
 * Concatenate the text contents of a list of text layers, one per line,
 * in the order given (see `collectTextLayersSortedByX` for left-to-right
 * column ordering).
 *
 * @function concatenateTextLayerContents
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer[]} textLayers - array of Photoshop UXP text layers
 * @returns {string} contents joined with `"\n"` (empty string if
 *   `textLayers` is empty or falsy)
 */

function concatenateTextLayerContents(textLayers) {
// coderstate: function
    let retVal = "";

    do {
        try {
            if (! textLayers || ! textLayers.length) {
                break;
            }

            let parts = [];
            for (let idx = 0; idx < textLayers.length; idx++) {
                let layer = textLayers[idx];
                let contents = (layer && layer.textItem && layer.textItem.contents) || "";
                parts.push(contents);
            }

            retVal = parts.join("\n");
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.concatenateTextLayerContents = concatenateTextLayerContents;

/**
 * Find a config layer group in a document, read its text layers and parse
 * them as INI data, in one call. Equivalent to:
 * `readINI(concatenateTextLayerContents(collectTextLayersSortedByX(findLayerGroupByNameSubstring(doc, groupNameMarker))))`
 *
 * @function readConfigFromGroup
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @param {string} groupNameMarker - substring identifying the config layer
 *   group (e.g. `"[photoshop-config]"`)
 * @returns {object} the parsed INI object, or `undefined` if no matching
 *   group was found or it had no readable INI content
 */

function readConfigFromGroup(doc, groupNameMarker) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            let group = findLayerGroupByNameSubstring(doc, groupNameMarker);
            if (! group) {
                break;
            }

            let textLayers = collectTextLayersSortedByX(group);
            let text = concatenateTextLayerContents(textLayers);

            retVal = readINI(text);
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.readConfigFromGroup = readConfigFromGroup;

/**
 * Find a config layer group, read it as INI data, and hand that data to a
 * caller-supplied callback - the "apply" part is always specific to
 * whatever product built the config layer (which sections/keys it
 * supports, what each one means), so that logic lives in the calling
 * script, not here. This function only owns the generic, reusable part:
 * find the group, extract the data, dispatch it.
 *
 * @function applyConfigFromGroup
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @param {string} groupNameMarker - substring identifying the config layer
 *   group (e.g. `"[photoshop-config]"`)
 * @param {Function} callback - `function(configData, group, doc)`, called
 *   once the group is found and its content parsed; `configData` is
 *   whatever `readINI` returns (may be `undefined` if the group had no
 *   readable INI content - callbacks should handle that). May be `async`
 *   and/or return a Promise; whatever it returns/resolves to is returned
 *   by `applyConfigFromGroup` in turn. Mutating the document from inside
 *   the callback still needs `crdtuxpPHXS.runModal`, same as any other
 *   document edit.
 * @returns the callback's return value, or `undefined` if no matching
 *   group was found (callback is never called in that case) or the
 *   arguments are invalid
 */

function applyConfigFromGroup(doc, groupNameMarker, callback) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! doc || ! groupNameMarker || "function" != typeof callback) {
                crdtuxp.logError(arguments, "need doc, groupNameMarker and callback");
                break;
            }

            let group = findLayerGroupByNameSubstring(doc, groupNameMarker);
            if (! group) {
                break;
            }

            let configData = readConfigFromGroup(doc, groupNameMarker);

            retVal = callback(configData, group, doc);
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.applyConfigFromGroup = applyConfigFromGroup;

/**
 * Show or hide a layer.
 *
 * @function setLayerVisible
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} layer - a Photoshop UXP layer (or layer group)
 * @param {boolean} visible - true to show, false to hide
 * @returns {boolean} true on success
 */

function setLayerVisible(layer, visible) {
// coderstate: function
    let retVal = false;

    do {
        try {
            if (! layer) {
                crdtuxp.logError(arguments, "need layer");
                break;
            }

            layer.visible = !! visible;

            retVal = true;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.setLayerVisible = setLayerVisible;

/**
 * Set the document's selected layers.
 *
 * @function selectLayers
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @param {Layer[]} layers - layers to select; pass an empty array (or
 *   `undefined`) to deselect everything
 * @returns {boolean} true on success
 */

function selectLayers(doc, layers) {
// coderstate: function
    let retVal = false;

    do {
        try {
            if (! doc) {
                crdtuxp.logError(arguments, "need doc");
                break;
            }

            doc.activeLayers = layers || [];

            retVal = true;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.selectLayers = selectLayers;

/**
 * Deselect all layers in a document. Shorthand for `selectLayers(doc, [])`.
 *
 * @function deselectAllLayers
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @returns {boolean} true on success
 */

function deselectAllLayers(doc) {
// coderstate: function
    return selectLayers(doc, []);
}
crdtuxpPHXS.deselectAllLayers = deselectAllLayers;

/**
 * Move a layer to the top of its own stack (within its parent group, or
 * within the document if it's a top-level layer).
 *
 * @function moveLayerToBeginning
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} layer - a Photoshop UXP layer (or layer group); must have
 *   a `.parent` (a top-level layer's parent is the document itself)
 * @returns {boolean} true on success
 */

function moveLayerToBeginning(layer) {
// coderstate: function
    let retVal = false;

    do {
        try {
            if (! layer || ! layer.parent) {
                crdtuxp.logError(arguments, "need layer with a parent");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            layer.move(layer.parent, photoshop.constants.ElementPlacement.PLACEATBEGINNING);

            retVal = true;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.moveLayerToBeginning = moveLayerToBeginning;

/**
 * Move a layer to the bottom of its own stack (within its parent group, or
 * within the document if it's a top-level layer).
 *
 * @function moveLayerToEnd
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} layer - a Photoshop UXP layer (or layer group); must have
 *   a `.parent` (a top-level layer's parent is the document itself)
 * @returns {boolean} true on success
 */

function moveLayerToEnd(layer) {
// coderstate: function
    let retVal = false;

    do {
        try {
            if (! layer || ! layer.parent) {
                crdtuxp.logError(arguments, "need layer with a parent");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            layer.move(layer.parent, photoshop.constants.ElementPlacement.PLACEATEND);

            retVal = true;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.moveLayerToEnd = moveLayerToEnd;

/**
 * Expand or collapse a layer group's twirl-down in the Layers panel.
 *
 * NOT YET IMPLEMENTED - the Layers-panel expand/collapse state has no
 * documented direct UXP DOM property (same gap exists in classic
 * ExtendScript), and likely needs `batchPlay` - not yet researched/
 * verified. Exists as a named stub so callers (ShowConfig/HideConfig) have
 * one place to call and one place to fix later, instead of each
 * reimplementing/skipping this independently. Note: selecting a layer
 * inside a collapsed group auto-expands it in the UI as a side effect, so
 * a caller that only needs the group's content visible/selectable (e.g.
 * ShowConfig selecting its first text layer via `selectLayers`) may not
 * need this at all.
 *
 * @function setLayerGroupExpanded
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} group - a Photoshop UXP layer group
 * @param {boolean} expanded - true to expand, false to collapse
 * @returns {boolean} always `false` currently
 */

function setLayerGroupExpanded(group, expanded) {
// coderstate: function
    crdtuxp.logError(arguments, "setLayerGroupExpanded: not implemented - needs batchPlay research");
    return false;
}
crdtuxpPHXS.setLayerGroupExpanded = setLayerGroupExpanded;

/**
 * Delete all text layers directly inside a group, optionally preserving
 * one whose name contains `preserveNameSubstring` (e.g. a border/frame
 * layer that isn't part of the text content). Must be called from inside
 * `crdtuxpPHXS.runModal`.
 *
 * @function deleteTextLayersInGroup
 * @memberOf crdtuxpPHXS
 *
 * @param {Layer} group - a Photoshop UXP layer group
 * @param {string} [preserveNameSubstring] - substring identifying a layer
 *   to leave alone (e.g. `"[config-border]"`)
 * @returns {boolean} true on success
 */

function deleteTextLayersInGroup(group, preserveNameSubstring) {
// coderstate: function
    let retVal = false;

    do {
        try {
            if (! group || ! group.layers) {
                crdtuxp.logError(arguments, "need group");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            // Walk backwards - deleting while iterating forward skips
            // entries as the live collection shifts under us.
            let layers = group.layers;
            for (let idx = layers.length - 1; idx >= 0; idx--) {
                let layer = layers[idx];
                if (! layer || layer.kind != photoshop.constants.LayerKind.TEXT) {
                    continue;
                }

                if (preserveNameSubstring && layer.name && layer.name.indexOf(preserveNameSubstring) != -1) {
                    continue;
                }

                layer.delete();
            }

            retVal = true;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.deleteTextLayersInGroup = deleteTextLayersInGroup;

/**
 * Create a new, empty top-level layer group.
 *
 * Not independently verified against a real Photoshop install (no local
 * precedent to crib from when this was written), but `Document.createLayerGroup`
 * is Adobe's documented, commonly-shown UXP API for this - higher
 * confidence than `createTextLayer`'s `textItem` styling guesses below.
 * Must be called from inside `crdtuxpPHXS.runModal`.
 *
 * @function createLayerGroup
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @param {string} name - group name
 * @returns {Layer} the new layer group, or `undefined` on failure
 */

function createLayerGroup(doc, name) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! doc) {
                crdtuxp.logError(arguments, "need doc");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            retVal = doc.createLayerGroup({ name: name || "" });
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.createLayerGroup = createLayerGroup;

/**
 * Create a text layer inside a group, with position and basic style.
 *
 * UNVERIFIED AGAINST A REAL PHOTOSHOP INSTALL - the overall shape
 * (`document.createLayer` with `LayerKind.TEXT`, then set `.textItem`
 * properties) matches Adobe's documented UXP Photoshop API, but the exact
 * `textItem` property names used for font/size/color have not been
 * confirmed live. Treat this as a first draft to verify/fix in one place
 * (both NewConfig and CleanConfig call this), not a proven implementation.
 * Must be called from inside `crdtuxpPHXS.runModal`.
 *
 * @function createTextLayer
 * @memberOf crdtuxpPHXS
 *
 * @param {Document} doc - a Photoshop UXP `Document`
 * @param {Layer} parentGroup - group to create the layer inside
 * @param {string} name - layer name
 * @param {string} contents - text contents
 * @param {object} [options] - `{ left, top, fontName, fontSizePt, color: {r,g,b} }`,
 *   all optional
 * @returns {Layer} the new text layer, or `undefined` on failure
 */

function createTextLayer(doc, parentGroup, name, contents, options) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! doc || ! parentGroup) {
                crdtuxp.logError(arguments, "need doc and parentGroup");
                break;
            }

            let photoshop = getPhotoshop();
            if (! photoshop) {
                crdtuxp.logError(arguments, "not running inside Photoshop");
                break;
            }

            let opts = options || {};

            let layer = doc.createLayer(
                photoshop.constants.LayerKind.TEXT,
                { name: name || "" }
            );
            if (! layer) {
                crdtuxp.logError(arguments, "createLayer returned nothing");
                break;
            }

            layer.move(parentGroup, photoshop.constants.ElementPlacement.PLACEINSIDE);

            layer.textItem.contents = contents || "";

            if (opts.fontName) {
                layer.textItem.font = opts.fontName;
            }
            if (opts.fontSizePt) {
                layer.textItem.size = opts.fontSizePt;
            }
            if (opts.color) {
                let solidColor = new photoshop.SolidColor();
                solidColor.rgb.red   = opts.color.r;
                solidColor.rgb.green = opts.color.g;
                solidColor.rgb.blue  = opts.color.b;
                layer.textItem.characterStyle.color = solidColor;
            }

            layer.textItem.position = [opts.left || 0, opts.top || 0];

            retVal = layer;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.createTextLayer = createTextLayer;

/**
 * Reformat raw text as a tidy, consistent set of INI lines, WITHOUT parsing
 * it into an object - unlike `readINI`, comments and blank lines are
 * preserved so this is safe to use on content a human has annotated. Per
 * line:<br>
 * <br>
 * - Blank lines are kept as-is.<br>
 * - Lines starting with `;` or `#` (comments) are kept as-is.<br>
 * - `[section]` headers are normalised the same way `readINI` normalises
 *   section names (lowercased, de-spaced, stripped to `[-a-zA-Z0-9_$:]`).<br>
 * - `key = value` lines have their spacing normalised to `key = value`
 *   (the key itself is trimmed but NOT otherwise normalised/case-folded,
 *   unlike `readINI` - this is a display reformat, not a re-parse).<br>
 * - Any other line is discarded.<br>
 *
 * @function reformatINILines
 * @memberOf crdtuxpPHXS
 *
 * @param {string} text - raw text to reformat
 * @returns {string[]} the reformatted lines (empty array if `text` is
 *   empty/not a string)
 */

function reformatINILines(text) {
// coderstate: function
    let retVal = [];

    do {
        try {
            if (! text || "string" != typeof text) {
                break;
            }

            let rawLines = text.split(/\r\n|\r|\n/);
            let lines = [];

            for (let idx = 0; idx < rawLines.length; idx++) {
                let line = rawLines[idx].replace(REGEXP_TRIM, REGEXP_TRIM_REPLACE);

                if (! line) {
                    lines.push("");
                    continue;
                }

                let firstChar = line.charAt(0);
                if (firstChar == ";" || firstChar == "#") {
                    lines.push(line);
                    continue;
                }

                if (firstChar == "[" && line.charAt(line.length - 1) == "]") {
                    let sectionName = line.substring(1, line.length - 1);
                    sectionName = sectionName.toLowerCase().replace(REGEXP_DESPACE, REGEXP_DESPACE_REPLACE);
                    sectionName = sectionName.replace(REGEXP_SECTION_NAME_ONLY, REGEXP_SECTION_NAME_ONLY_REPLACE);
                    if (sectionName) {
                        lines.push("[" + sectionName + "]");
                    }
                    continue;
                }

                let equalIdx = line.indexOf("=");
                if (equalIdx > 0) {
                    let key = line.substring(0, equalIdx).replace(REGEXP_TRIM, REGEXP_TRIM_REPLACE);
                    let value = line.substring(equalIdx + 1).replace(REGEXP_TRIM, REGEXP_TRIM_REPLACE);
                    if (key) {
                        lines.push(key + " = " + value);
                    }
                    continue;
                }

                // Anything else is silently discarded (same rule readINI
                // applies implicitly by never storing it).
            }

            retVal = lines;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.reformatINILines = reformatINILines;

/**
 * Split a list of lines into column-sized chunks, each chunk joined back
 * into a single string with `"\n"` - the inverse of
 * `concatenateTextLayerContents`/`collectTextLayersSortedByX`.
 *
 * @function splitLinesIntoColumns
 * @memberOf crdtuxpPHXS
 *
 * @param {string[]} lines - lines to split
 * @param {number} linesPerColumn - max lines per column (must be > 0)
 * @returns {string[]} one string per column, left-to-right order
 */

function splitLinesIntoColumns(lines, linesPerColumn) {
// coderstate: function
    let retVal = [];

    do {
        try {
            if (! lines || ! lines.length || ! linesPerColumn || linesPerColumn <= 0) {
                break;
            }

            let columns = [];
            for (let idx = 0; idx < lines.length; idx += linesPerColumn) {
                columns.push(lines.slice(idx, idx + linesPerColumn).join("\n"));
            }

            retVal = columns;
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.splitLinesIntoColumns = splitLinesIntoColumns;

/**
 * Interpret a string as a boolean, the way an INI value would be: `y`, `n`,
 * `yes`, `no`, `true`, `false`, `t`, `f`, `0`, `1` (case-insensitive, only
 * the first character is actually examined, matching CRDT_ES's
 * `crdtes.getBooleanFromINI`).
 *
 * @function getBooleanFromINI
 * @memberOf crdtuxpPHXS
 *
 * @param {string} value - ini value
 * @returns {boolean} value
 */

function getBooleanFromINI(value) {
// coderstate: function
    let retVal = false;

    if (value) {
        let trimmed = (value + "").replace(REGEXP_TRIM, REGEXP_TRIM_REPLACE);
        let firstChar = trimmed.charAt(0).toLowerCase();
        let firstValue = parseInt(firstChar, 10);
        retVal = firstChar == "y" || firstChar == "t" || (! isNaN(firstValue) && firstValue != 0);
    }

    return retVal;
}
crdtuxpPHXS.getBooleanFromINI = getBooleanFromINI;

/**
 * Parse INI-formatted text into a nested object, one property per section,
 * one property per key within each section. Direct port of CRDT_ES's
 * `crdtes.readINI` (character-by-character state machine) - keep both in
 * sync if either changes. Behaviour:<br>
 * <br>
 * - `[section-name]` starts a section; everything until the next
 *   `[section]` line belongs to it.<br>
 * - `key = value` sets an attribute (spaces around `=` optional).<br>
 * - Section/attribute names are lowercased, de-spaced, and stripped down to
 *   `[-a-zA-Z0-9_$]` (section names additionally keep `:`).<br>
 * - The original, un-normalised section name is kept on the section object
 *   as `__rawSectionName`.<br>
 * - A value wrapped in matching straight or curly quotes (`"..."`, `'...'`)
 *   has the quotes stripped; internal spaces are preserved.<br>
 * - Duplicate section or attribute names are suffixed `_2`, `_3`, etc.<br>
 * - A line starting with `#` is a comment. Any other line that isn't a
 *   `[section]` or `key = value` line (including one starting with `;`) is
 *   silently ignored - it never reaches a `=`, so nothing is stored.<br>
 *
 * @function readINI
 * @memberOf crdtuxpPHXS
 *
 * @param {string} text - raw text, which might or might not contain some
 *   INI-formatted data mixed with normal text
 * @returns {object} the parsed INI data, or `undefined` if `text` is empty/
 *   not a string, or contains no recognisable `[section]`
 */

function readINI(text) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! text || "string" != typeof text) {
                break;
            }

            let scanText = text + "\r";
            let state = STATE_IDLE;
            let attr;
            let value;
            let attrSpaceCount;
            let rawSectionName = "";
            let sectionName = "";
            let section;
            let attrCounters = {};
            let sectionCounters = {};

            for (let idx = 0; state != STATE_ERROR && idx < scanText.length; idx++) {
                let c = scanText.charAt(idx);
                switch (state) {
                    default:
                        crdtuxp.logError(arguments, "unexpected state");
                        state = STATE_ERROR;
                        break;
                    case STATE_IDLE:
                        if (c == '[') {
                            state = STATE_SEEN_OPEN_SQUARE_BRACKET;
                            rawSectionName = "";
                        }
                        else if (c == '#') {
                            state = STATE_IN_COMMENT;
                        }
                        else if (c > ' ') {
                            attr = c;
                            attrSpaceCount = 0;
                            state = STATE_SEEN_NON_WHITE;
                        }
                        break;
                    case STATE_IN_COMMENT:
                    case STATE_SEEN_CLOSE_SQUARE_BRACKET:
                        if (c == '\r' || c == '\n') {
                            state = STATE_IDLE;
                        }
                        break;
                    case STATE_SEEN_OPEN_SQUARE_BRACKET:
                        if (c == ']') {
                            state = STATE_SEEN_CLOSE_SQUARE_BRACKET;
                            sectionName = rawSectionName.toLowerCase();
                            sectionName = sectionName.replace(REGEXP_DESPACE, REGEXP_DESPACE_REPLACE);
                            sectionName = sectionName.replace(REGEXP_SECTION_NAME_ONLY, REGEXP_SECTION_NAME_ONLY_REPLACE);
                            if (sectionName) {

                                if (! retVal) {
                                    retVal = {};
                                }

                                let sectionSuffix = "";
                                let sectionCounter = 1;
                                if (sectionName in sectionCounters) {
                                    sectionCounter = sectionCounters[sectionName];
                                    sectionCounter++;
                                    sectionSuffix = "_" + sectionCounter;
                                }
                                sectionCounters[sectionName] = sectionCounter;
                                sectionName += sectionSuffix;
                                retVal[sectionName] = {};
                                section = retVal[sectionName];
                                section.__rawSectionName = rawSectionName;
                                attrCounters = {};
                            }
                        }
                        else {
                            rawSectionName += c;
                        }
                        break;
                    case STATE_SEEN_NON_WHITE:
                        if (c == "=") {
                            value = "";
                            state = STATE_SEEN_EQUAL;
                        }
                        else if (c == '\r' || c == '\n') {
                            state = STATE_IDLE;
                        }
                        else if (c != " ") {
                            while (attrSpaceCount > 0) {
                                attr += " ";
                                attrSpaceCount--;
                            }
                            attr += c;
                        }
                        else {
                            attrSpaceCount++;
                        }
                        break;
                    case STATE_SEEN_EQUAL:
                        if (c != '\r' && c != '\n') {
                            value += c;
                        }
                        else {
                            value = value.replace(REGEXP_TRIM, REGEXP_TRIM_REPLACE);
                            if (value.length >= 2) {
                                let firstChar = value.charAt(0);
                                let lastChar = value.charAt(value.length - 1);
                                if (
                                    (firstChar == "\"" || firstChar == "“" || firstChar == "”")
                                &&
                                    (lastChar == "\"" || lastChar == "“" || lastChar == "”")
                                ) {
                                    value = value.substring(1, value.length - 1);
                                }
                                else if (
                                    (firstChar == "'" || firstChar == "‘" || firstChar == "’")
                                &&
                                    (lastChar == "'" || lastChar == "‘" || lastChar == "’")
                                ) {
                                    value = value.substring(1, value.length - 1);
                                }
                            }

                            if (section) {
                                attr = attr.replace(REGEXP_DESPACE, REGEXP_DESPACE_REPLACE).toLowerCase();
                                attr = attr.replace(REGEXP_ALPHA_ONLY, REGEXP_ALPHA_ONLY_REPLACE);
                                if (attr) {

                                    let attrSuffix = "";
                                    let attrCounter = 1;
                                    if (attr in attrCounters) {
                                        attrCounter = attrCounters[attr];
                                        attrCounter++;
                                        attrSuffix = "_" + attrCounter;
                                    }
                                    attrCounters[attr] = attrCounter;
                                    attr += attrSuffix;

                                    section[attr] = value;
                                }
                            }

                            state = STATE_IDLE;
                        }
                        break;
                }
            }
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.readINI = readINI;

/**
 * Parse a `#RRGGBB` or `RRGGBB` hex color string (leading `#` optional).
 *
 * @function parseHexColor
 * @memberOf crdtuxpPHXS
 *
 * @param {string} hexString - e.g. `"#AACCFF"` or `"AACCFF"`
 * @returns {object} `{ r, g, b }` as integers 0-255, or `undefined` if
 *   `hexString` is not a valid 6-digit hex color
 */

function parseHexColor(hexString) {
// coderstate: function
    let retVal = undefined;

    do {
        try {
            if (! hexString || "string" != typeof hexString) {
                break;
            }

            let match = hexString.match(REGEXP_HEX_COLOR);
            if (! match) {
                break;
            }

            let hex = match[1];
            retVal = {
                r: parseInt(hex.substring(0, 2), 16),
                g: parseInt(hex.substring(2, 4), 16),
                b: parseInt(hex.substring(4, 6), 16)
            };
        }
        catch (err) {
            crdtuxp.logError(arguments, "throws " + err);
        }
    }
    while (false);

    return retVal;
}
crdtuxpPHXS.parseHexColor = parseHexColor;