// 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 InDesign.
*
* @module crdtuxpIDSN
* @namespace crdtuxpIDSN
*/
if (! module.exports) {
module.exports = {};
}
let crdtuxpIDSN = module.exports;
let crdtuxp = getCRDTUXP();
const PLUGIN_PATH_PREFIX = "plugin:";
const BRIDGE_RUNNER_FILE_NAME = "crdtuxpIDSN_bridge_runner.idjs";
const BRIDGE_PAYLOAD_LABEL = "__CRDT_UXP_INDESIGN_UXPSCRIPT_BRIDGE_PAYLOAD__";
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;
}
function isAbsoluteNativePath(filePath) {
// coderstate: function
return /^(\/|[A-Za-z]:[\\/])/.test(String(filePath || ""));
}
function normalizeNativePath(filePath) {
// coderstate: function
let retVal = undefined;
do {
try {
if (filePath === undefined || filePath === null || filePath === "") {
break;
}
retVal = String(filePath);
if (retVal.indexOf(PLUGIN_PATH_PREFIX) != 0) {
break;
}
let nativePath = retVal.substring(PLUGIN_PATH_PREFIX.length);
if (! isAbsoluteNativePath(nativePath)) {
crdtuxp.logError(arguments, "Unsupported plugin path: " + retVal);
break;
}
retVal = nativePath;
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
retVal = undefined;
}
}
while (false);
return retVal;
}
function getBridgeContext() {
// coderstate: function
let retVal = undefined;
do {
try {
let uxpContext = crdtuxp.getUXPContext();
if (! uxpContext) {
crdtuxp.logError(arguments, "Could not determine UXP context.");
break;
}
if (
uxpContext.uxpVariant != crdtuxp.UXP_VARIANT_INDESIGN_UXP
&&
uxpContext.uxpVariant != crdtuxp.UXP_VARIANT_INDESIGN_UXPSCRIPT
) {
crdtuxp.logError(arguments, "UXPScript bridge is only available in desktop InDesign.");
break;
}
if (! uxpContext.indesign || ! uxpContext.app || typeof uxpContext.app.doScript != "function") {
crdtuxp.logError(arguments, "InDesign doScript() is unavailable.");
break;
}
retVal = {
crdtuxp: crdtuxp,
uxpContext: uxpContext
};
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
return retVal;
}
function sanitizeSourceForAsyncModeHeuristic(scriptText) {
// coderstate: function
let retVal = "";
do {
try {
let sourceText = String(scriptText || "");
let state = "code";
for (let index = 0; index < sourceText.length; index += 1) {
let ch = sourceText.charAt(index);
let nextCh = sourceText.charAt(index + 1);
if (state == "lineComment") {
retVal += ch == "\n" ? "\n" : " ";
if (ch == "\n") {
state = "code";
}
continue;
}
if (state == "blockComment") {
if (ch == "*" && nextCh == "/") {
retVal += " ";
index += 1;
state = "code";
continue;
}
retVal += ch == "\n" ? "\n" : " ";
continue;
}
if (
state == "singleQuote"
||
state == "doubleQuote"
||
state == "template"
) {
let quoteChar = state == "singleQuote" ? "'" : state == "doubleQuote" ? '"' : "`";
if (ch == "\\" && nextCh) {
retVal += " ";
index += 1;
continue;
}
if (ch == quoteChar) {
retVal += " ";
state = "code";
continue;
}
retVal += ch == "\n" ? "\n" : " ";
continue;
}
if (ch == "/" && nextCh == "/") {
retVal += " ";
index += 1;
state = "lineComment";
continue;
}
if (ch == "/" && nextCh == "*") {
retVal += " ";
index += 1;
state = "blockComment";
continue;
}
if (ch == "'") {
retVal += " ";
state = "singleQuote";
continue;
}
if (ch == '"') {
retVal += " ";
state = "doubleQuote";
continue;
}
if (ch == "`") {
retVal += " ";
state = "template";
continue;
}
retVal += ch;
}
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
retVal = "";
}
}
while (false);
return retVal;
}
function getAsyncModeHeuristicMatch(scriptText) {
// coderstate: function
let retVal = "";
do {
try {
let sourceText = sanitizeSourceForAsyncModeHeuristic(scriptText);
let asyncPatterns = [
/(^|[^\w$.])async\s+function\b/,
/(^|[^\w$.])async\s+[_$A-Za-z][_$A-Za-z0-9]*\s*=>/,
/(^|[^\w$.])async\s*\(/,
/(^|[^\w$.])async\s+(?:static\s+)?(?:get\s+|set\s+)?(?:\*\s*)?[#_$A-Za-z][#_$A-Za-z0-9]*\s*\(/
];
for (let index = 0; index < asyncPatterns.length; index += 1) {
let match = sourceText.match(asyncPatterns[index]);
if (! match) {
continue;
}
retVal = String(match[0]).replace(/^\s+/, "");
break;
}
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
retVal = "";
}
}
while (false);
return retVal;
}
/**
* Rough async-mode heuristic for a top-level InDesign UXPScript launcher.<br>
* <br>
* Comments and quoted strings are stripped before matching common async syntax.
* This is intentionally not a full JavaScript parser.
*
* @function wouldUXPScriptRunInAsyncMode
* @memberOf crdtuxpIDSN
* @param {string} scriptText - top-level launcher source text to inspect
* @returns {boolean} true when the launcher matches the rough async-mode heuristic
*/
function wouldUXPScriptRunInAsyncMode(scriptText) {
// coderstate: function
let retVal = false;
try {
retVal = getAsyncModeHeuristicMatch(scriptText) != "";
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
return retVal;
}
module.exports.wouldUXPScriptRunInAsyncMode = wouldUXPScriptRunInAsyncMode;
function validateSyncSafeSource(sourceText, options) {
// coderstate: function
let retVal = false;
do {
try {
if (sourceText === undefined || sourceText === null) {
if (options && options.requireSourceInspection) {
crdtuxp.logError(arguments, "Bridge source text is required when requireSourceInspection is true.");
break;
}
}
if (options && options.allowAsyncToken) {
retVal = true;
break;
}
let asyncHeuristicMatch = getAsyncModeHeuristicMatch(sourceText);
if (asyncHeuristicMatch) {
crdtuxp.logError(arguments, "Bridge source matches the rough async-mode heuristic (" + asyncHeuristicMatch + "). InDesign may switch the launch into slow redraw-heavy mode. This check can be bypassed with allowAsyncToken.");
break;
}
retVal = true;
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
return retVal;
}
function resolveUXPScriptFilePath(filePath, options) {
// coderstate: function
let retVal = undefined;
do {
try {
if (filePath === undefined || filePath === null || filePath === "") {
break;
}
retVal = normalizeNativePath(filePath);
if (isAbsoluteNativePath(retVal)) {
break;
}
retVal = String(filePath);
let bridgeContext = getBridgeContext();
let uxpContext = bridgeContext.uxpContext;
let basePath = options && options.basePath;
if (! basePath && crdtuxp.context) {
basePath =
crdtuxp.context.PATH_LAUNCHER_SCRIPT_PARENT
||
crdtuxp.context.FILE_PATH_PROJECT_FOLDER
||
crdtuxp.context.FILE_PATH_ROOT;
}
if (
basePath
&&
uxpContext.path
&&
typeof uxpContext.path.resolve == "function"
) {
retVal = decodeURI(uxpContext.path.resolve("file://" + String(basePath), retVal).pathname);
if (crdtuxp.IS_WINDOWS && retVal.substr(0,1) == "/") {
retVal = retVal.substr(1);
}
retVal = normalizeNativePath(retVal);
}
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
retVal = undefined;
}
}
while (false);
return retVal;
}
function getUXPScriptLanguage(uxpContext) {
// coderstate: function
let retVal = undefined;
do {
try {
if (
uxpContext
&&
uxpContext.indesign
&&
uxpContext.indesign.ScriptLanguage
&&
uxpContext.indesign.ScriptLanguage.UXPSCRIPT
) {
retVal = uxpContext.indesign.ScriptLanguage.UXPSCRIPT;
break;
}
if (
global.indesignAPI
&&
global.indesignAPI.ScriptLanguage
&&
global.indesignAPI.ScriptLanguage.UXPSCRIPT
) {
retVal = global.indesignAPI.ScriptLanguage.UXPSCRIPT;
break;
}
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
return retVal;
}
function resolveBridgeRunnerPath(bridgeContext) {
// coderstate: function
let retVal = undefined;
do {
try {
let uxpContext = bridgeContext && bridgeContext.uxpContext;
let crdtuxpFolderPath = undefined;
if (
crdtuxp
&&
crdtuxp.context
&&
crdtuxp.context.FILE_PATH_CRDT_UXP_FOLDER
) {
crdtuxpFolderPath = String(crdtuxp.context.FILE_PATH_CRDT_UXP_FOLDER);
}
if (! crdtuxpFolderPath && typeof __filename == "string" && __filename) {
if (! crdtuxp || ! crdtuxp.path || typeof crdtuxp.path.dirName != "function") {
crdtuxp.logError(arguments, "crdtuxp.path.dirName() is unavailable.");
break;
}
crdtuxpFolderPath = crdtuxp.path.dirName(__filename, {
addTrailingSeparator: true
});
}
if (! crdtuxpFolderPath && crdtuxp && typeof crdtuxp.getCurrentScriptPath == "function") {
let modulePath = crdtuxp.getCurrentScriptPath();
if (modulePath) {
if (! crdtuxp || ! crdtuxp.path || typeof crdtuxp.path.dirName != "function") {
crdtuxp.logError(arguments, "crdtuxp.path.dirName() is unavailable.");
break;
}
crdtuxpFolderPath = crdtuxp.path.dirName(modulePath, {
addTrailingSeparator: true
});
}
}
if (! crdtuxpFolderPath) {
crdtuxp.logError(arguments, "Could not determine the CRDT_UXP folder path.");
break;
}
if (uxpContext && uxpContext.path && typeof uxpContext.path.resolve == "function") {
retVal = decodeURI(uxpContext.path.resolve("file://" + crdtuxpFolderPath, BRIDGE_RUNNER_FILE_NAME).pathname);
if (crdtuxp.IS_WINDOWS && retVal.substr(0,1) == "/") {
retVal = retVal.substr(1);
}
retVal = normalizeNativePath(retVal);
break;
}
retVal = String(crdtuxpFolderPath) + BRIDGE_RUNNER_FILE_NAME;
retVal = normalizeNativePath(retVal);
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
retVal = undefined;
}
}
while (false);
return retVal;
}
function setBridgePayload(bridgeContext, filePath) {
// coderstate: procedure
do {
try {
let app = bridgeContext && bridgeContext.uxpContext && bridgeContext.uxpContext.app;
if (! app || typeof app.insertLabel != "function") {
crdtuxp.logError(arguments, "InDesign label API is unavailable.");
break;
}
if (! filePath) {
crdtuxp.logError(arguments, "Bridge payload filePath is unavailable.");
break;
}
app.insertLabel(BRIDGE_PAYLOAD_LABEL, String(filePath));
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
}
function executeBridgePayload(filePath) {
// coderstate: promisor
let retVal = Promise.resolve(undefined);
do {
try {
let bridgeContext = getBridgeContext();
let uxpContext = bridgeContext.uxpContext;
let uxpscriptLanguage = getUXPScriptLanguage(uxpContext);
let runnerPath = resolveBridgeRunnerPath(bridgeContext);
if (! uxpscriptLanguage) {
crdtuxp.logError(arguments, "InDesign UXPSCRIPT language is unavailable.");
break;
}
if (! runnerPath) {
crdtuxp.logError(arguments, "Could not resolve the bridge runner path.");
break;
}
if (! isAbsoluteNativePath(runnerPath)) {
crdtuxp.logError(arguments, "Bridge runner path is not absolute: " + runnerPath);
break;
}
setBridgePayload(bridgeContext, filePath);
retVal = Promise.resolve(uxpContext.app.doScript(runnerPath, uxpscriptLanguage));
}
catch (err) {
retVal = Promise.reject(err);
}
}
while (false);
return retVal;
}
function createTempBridgeScriptFile(scriptText) {
// coderstate: promisor
let retVal = Promise.resolve(undefined);
do {
try {
retVal = Promise.resolve(crdtuxp.getDir(crdtuxp.TMP_DIR)).then(
function handleTmpDirResolve(tmpDirPath) {
// coderstate: promisor
let tempFilePath = undefined;
do {
if (! tmpDirPath) {
crdtuxp.logError(arguments, "Could not resolve the temporary directory.");
break;
}
tempFilePath = String(tmpDirPath)
+ "crdtuxpIDSN_bridge_"
+ String(Date.now())
+ "_"
+ String(Math.floor(Math.random() * 1000000000))
+ ".idjs";
return Promise.resolve(crdtuxp.fileAppendString(tempFilePath, String(scriptText))).then(
function handleWriteResolve(writeSucceeded) {
// coderstate: function
do {
if (! writeSucceeded) {
crdtuxp.logError(arguments, "Could not write the temporary bridge payload file.");
}
}
while (false);
return tempFilePath;
}
);
}
while (false);
}
);
}
catch (err) {
retVal = Promise.reject(err);
}
}
while (false);
return retVal;
}
function cleanupTempBridgeScriptFile(filePath) {
// coderstate: procedure
do {
try {
if (! filePath || ! crdtuxp || typeof crdtuxp.fileDelete != "function") {
break;
}
Promise.resolve(crdtuxp.fileDelete(String(filePath))).then(
function handleDeleteResolve(deleteSucceeded) {
if (! deleteSucceeded) {
crdtuxp.logError(arguments, "Could not delete temporary bridge payload file " + filePath);
}
},
function handleDeleteReject(err) {
crdtuxp.logError(arguments, "Deleting temporary bridge payload file throws " + err);
}
);
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
}
/**
* Run a UXPScript source string through the InDesign bridge.<br>
* <br>
* This is meant for panel-side code that needs the final InDesign-facing work to run as
* a host-owned UXPScript launch instead of directly inside the panel runtime.<br>
* <br>
* The bridge launches a clean UXPScript runner file and hands it a payload file path.<br>
* For string input, the source is first written to a temporary <code>.idjs</code> file.<br>
* <br>
* By default, the bridge rejects source that matches a rough async-syntax heuristic after
* comments and quoted strings are stripped, because observed InDesign behavior suggests that
* the top-level launcher can switch into a slower, redraw-heavy mode when it contains real
* async syntax such as <code>async function dummy() {}</code>. This is intentionally not a
* full JavaScript parser.
*
* @function doUXPScript
* @memberOf crdtuxpIDSN
*
* @param {string} scriptText - UXPScript source to run
* @param {object=} options - <code>{<br>
* allowAsyncToken: false to bypass the rough async-mode source inspection<br>
* clearPending: accepted for backward compatibility; currently ignored<br>
* engineName: accepted for backward compatibility; currently ignored<br>
* taskName: accepted for backward compatibility; currently ignored<br>
* }</code>
* @returns {Promise<any>} result returned by InDesign <code>doScript()</code>
*/
function doUXPScript(scriptText, options) {
// coderstate: promisor
let retVal = Promise.resolve(undefined);
do {
try {
if (scriptText === undefined || scriptText === null) {
retVal = Promise.reject(new Error("scriptText is required."));
break;
}
validateSyncSafeSource(scriptText, options);
retVal = createTempBridgeScriptFile(String(scriptText)).then(
function handleTempBridgeScriptResolve(tempFilePath) {
return executeBridgePayload(tempFilePath).then(
function handleBridgeResolve(value) {
cleanupTempBridgeScriptFile(tempFilePath);
return value;
},
function handleBridgeReject(err) {
cleanupTempBridgeScriptFile(tempFilePath);
crdtuxp.logError(arguments, "Bridge execution throws " + err);
}
);
}
);
}
catch (err) {
retVal = Promise.reject(err);
}
}
while (false);
return retVal;
}
module.exports.doUXPScript = doUXPScript;
/**
* Run a UXPScript file through the InDesign bridge.<br>
* <br>
* The supplied file path is passed through directly; the bridge does not copy it to a
* temporary file.<br>
* <br>
* If you want fail-loud inspection for the top-level launcher text, pass that source in
* <code>options.sourceText</code> and set <code>options.requireSourceInspection = true</code>.
*
* @function doUXPScriptFile
* @memberOf crdtuxpIDSN
*
* @param {string} filePath - absolute path, or a path relative to <code>options.basePath</code>
* @param {object=} options - <code>{<br>
* basePath: base folder for relative file paths<br>
* sourceText: optional source text for sync-mode inspection<br>
* requireSourceInspection: reject if sourceText is not supplied<br>
* allowAsyncToken: false to reject inspected source containing the token async<br>
* clearPending: accepted for backward compatibility; currently ignored<br>
* engineName: accepted for backward compatibility; currently ignored<br>
* taskName: accepted for backward compatibility; currently ignored<br>
* }</code>
* @returns {Promise<any>} result returned by InDesign <code>doScript()</code>
*/
function doUXPScriptFile(filePath, options) {
// coderstate: promisor
let retVal = Promise.resolve(undefined);
do {
try {
let resolvedPath = resolveUXPScriptFilePath(filePath, options);
if (! resolvedPath) {
retVal = Promise.reject(new Error("filePath is required."));
break;
}
validateSyncSafeSource(options && options.sourceText, options);
retVal = executeBridgePayload(resolvedPath);
}
catch (err) {
retVal = Promise.reject(err);
}
}
while (false);
return retVal;
}
module.exports.doUXPScriptFile = doUXPScriptFile;
/**
* Convert an InDesign collection into a pure JavaScript array
*
* @function collectionToArray
* @memberOf crdtuxpIDSN
*
* @param {Collection} coll - an InDesign collection
* @returns array with the collection elements
*/
function collectionToArray(coll) {
// coderstate: function
let retVal = undefined;
do {
try {
if (! coll) {
break;
}
if (coll instanceof Array) {
retVal = coll.slice(0);
}
else {
retVal = coll.everyItem().getElements().slice(0);
}
}
catch (err) {
crdtuxp.logError(arguments, "throws " + err);
}
}
while (false);
return retVal;
}
module.exports.collectionToArray = collectionToArray;