Skip to content

Instantly share code, notes, and snippets.

@elmimmo
Last active August 24, 2025 16:23
Show Gist options
  • Save elmimmo/562161a2c8fb1f3ee28c3c1f518117e0 to your computer and use it in GitHub Desktop.
Save elmimmo/562161a2c8fb1f3ee28c3c1f518117e0 to your computer and use it in GitHub Desktop.
InDesign startup script that blocks InDesign's native Ungroup command on groups with custom Object Export Options applied and allows to review them before confirming.
//DESCRIPTION:Alerts when ungrouping if there are custom Object Export Options
/* ============================================================================
Blocks InDesign's native Ungroup command whenever the user has selected
one or more groups that have custom Object Export Options applied
(Alt/Actual Text, Decorative flag, or explicit PDF Tag).
The script presents an interactive dialog allowing the user to:
• Review each offending group and its custom export data.
• Restore default export options for the current group.
• Step through previous/next groups, or cancel the operation.
• Ungroup all originally‑selected items and lose those custom options.
All UI strings are localizable via the `STR` object below.
by Jorge Hernández Valiñani _
===========================================================================*/
#target "InDesign"
#targetengine "AlertUngroup"
(function () {
// ---------------------------------------------------------------------------
// Localizable UI strings
// ---------------------------------------------------------------------------
var STR = {
dialogTitle : "Ungroup",
intro1 : "Some groups have custom Object Export Options.",
intro2 : "If you ungroup them, those options will be lost.",
intro1_singular : "This group has the following custom Object Export Options.",
intro2_singular : "If you ungroup it, those options will be lost.",
btnPrev : "◂ Previous",
btnNext : "Next ▸",
btnRestoreGroup : "Restore default options",
btnCancel : "Cancel",
btnUngroup : "Ungroup",
groupCounter : function(n, total){ return "Group " + n + " of " + total; },
pdfTagPrefix : "PDF Tag: ",
altTextPrefix : "Alt Text: ",
actualTextPrefix : "Actual Text: ",
quotationOpen : "“",
quotationClose : "”",
altSourceCustom : "Alt Text Source: Custom",
actualSourceCustom : "Actual Text Source: Custom",
altSourceDecorativeImage : "Alt Text Source: Decorative (no text)",
actualSourceXmp : "Actual Text Source: From XMP",
noCustomExportOptions : "No custom Object Export Options.",
inactivePrefix : "[Inactive] ",
undo : "Ungroup"
};
// --- Locate the Ungroup menu action ---
var UNGROUP_ACTION_ID = getUngroupActionID();
if (!UNGROUP_ACTION_ID) {
// if it fails, bail out silently rather than blocking the user
return;
}
// Register listener: remove all previous listeners and add the new one
try {
var ma = app.menuActions.itemByID(UNGROUP_ACTION_ID);
// Remove any previous handler (old or new) by matching its source
var els = ma.eventListeners;
for (var i = els.length - 1; i >= 0; i--) {
try {
var h = els[i].handler;
// Remove any previous handler (old or new) by matching its source
if (h && ("" + h).indexOf("preUngroupHandler") > -1) {
els[i].remove();
}
} catch (_) {}
}
ma.addEventListener('beforeInvoke', preUngroupHandler, false);
} catch (_) {}
// --- Main handler ---
var inHandler = false; // Avoid re‑entry when this script triggers the event
function preUngroupHandler(ev) {
// Avoid re‑entry when this script triggers the event
if (inHandler) return;
try {
var flagged = getSelectedGroupsWithCustomExportOptions();
if (!flagged.length) {
// Nothing to watch: let InDesign continue
return;
}
// --- Save original selection and show review UI ---
var originalSelection = app.selection.slice(0); // 1. keep selection
// 2. select only the first matching group
try { if (flagged.length) app.select(flagged[0]); } catch (_) {}
// 3. interactive dialog: returns 'Ungroup' or 'Cancel'
var action = reviewCustomExportOptionsDialog(flagged, originalSelection);
// Always block the native action
try { ev.stopPropagation(); } catch (_) {}
try { ev.preventDefault(); } catch (_) {}
if (action !== 'ungroup') {
// Cancel: restore original selection
try { app.select(originalSelection); } catch (_) {}
return;
}
// Before ungrouping, restore the original selection
try { app.select(originalSelection); } catch (_) {}
// Ungroup all items that were selected when Ungroup was invoked
inHandler = true;
manualUngroup(originalSelection);
} catch (e2) {
// Silences unexpected errors
} finally {
inHandler = false;
}
}
// Ungroup selected groups and rebuild selection to mimic native behavior
function manualUngroup(items) {
var sel = items || app.selection;
if (!sel || !sel.length) return;
// Separate groups and non‑groups to rebuild selection later
var groups = [];
var newSelect = [];
for (var i = 0; i < sel.length; i++) {
var it = sel[i];
if (it && it.constructor && it.constructor.name === "Group") {
groups.push(it); // to ungroup
} else {
newSelect.push(it); // stay selected
}
}
// Perform ungrouping in a single undo step
app.doScript(function () {
for (var j = 0; j < groups.length; j++) {
try {
// Save a reference to children BEFORE ungrouping,
// so they're still valid after the operation
var children = groups[j].pageItems.everyItem().getElements();
// Ungroup
groups[j].ungroup();
// Add their children to the resulting selection
for (var k = 0; k < children.length; k++) {
newSelect.push(children[k]);
}
} catch (_) {}
}
}, ScriptLanguage.javascript, undefined, UndoModes.ENTIRE_SCRIPT, STR.undo);
// After doScript finishes, apply the resulting selection
try {
if (newSelect.length) {
app.select(newSelect);
} else {
// If nothing valid remains, clear selection
app.select(NothingEnum.NOTHING);
}
} catch (_) {}
}
// --- Collect selected groups that contain custom metadata ---
function getSelectedGroupsWithCustomExportOptions() {
var out = [];
var sel = app.selection;
if (!sel || !sel.length) return out;
for (var i = 0; i < sel.length; i++) {
var it = sel[i];
if (it && it.constructor && it.constructor.name === "Group") {
if (hasCustomExportOptions(it)) out.push(it);
}
}
return out;
}
// --- Does the group have any custom export option? ---
function hasCustomExportOptions(pi) {
var ox;
try { ox = pi.objectExportOptions; } catch (_) {}
if (!ox) return false;
// Alt/Actual Source = Custom
try { if (ox.altTextSourceType == SourceType.SOURCE_CUSTOM) return true; } catch (_) {}
try { if (ox.actualTextSourceType == SourceType.SOURCE_CUSTOM) return true; } catch (_) {}
// Custom Alt Text contents
if (stringNotEmpty(ox.customAltText)) return true;
// Custom Actual Text contents
if (stringNotEmpty(ox.customActualText)) return true;
// Alt Text marked as “Decorative (no alt text)”
try {
if (ox.altTextSourceType == SourceType.SOURCE_DECORATIVE_IMAGE) return true;
} catch (_) {}
// Custom PDF tag (Artifact, etc.) — assumes From Structure is default
try {
if (ox.applyTagType && ox.applyTagType != TagType.TAG_FROM_STRUCTURE) return true;
} catch (_) {}
return false;
}
// --- Convert a TagType value into a readable name ----------------------
function tagLabel(code){
// 1) try localized key generated from enum
for (var k in TagType){
if (TagType[k] === code){
return k.replace(/^TAG_/, "").replace(/_/g," ")
.toLowerCase().replace(/\b\w/g, function(c){ return c.toUpperCase(); });
}
}
return code;
}
// --- Convert a SourceType value into a localizable label --------------
function sourceLabel(code, isAlt){
var enumKey = null;
for (var k in SourceType){
if (SourceType[k] === code){
enumKey = k; break;
}
}
if (!enumKey) return null;
var camel = enumKey.replace(/^SOURCE_/, "") // CUSTOM / DECORATIVE_IMAGE
.toLowerCase()
.replace(/(^|_)(\w)/g, function(_, p1, c){
return c.toUpperCase();
}); // Custom / DecorativeImage
var strKey = (isAlt ? "altSource" : "actualSource") + camel; // e.g. altSourceCustom
if (STR[strKey]) return STR[strKey];
var prefix = isAlt ? "Alt Source: " : "Actual Source: ";
return prefix + camel.replace(/([A-Z])/g," $1").trim();
}
// --- Build a readable summary of custom properties ---
function getCustomExportOptionsSummary(grp) {
var ox;
try { ox = grp.objectExportOptions; } catch (_) {}
if (!ox) return STR.noCustomExportOptions;
var out = [];
try {
var altLbl = sourceLabel(ox.altTextSourceType, true);
if (altLbl) out.push(altLbl);
} catch (_) { }
try {
var actLbl = sourceLabel(ox.actualTextSourceType, false);
if (actLbl) out.push(actLbl);
} catch (_) { }
// Custom Alt Text
if (stringNotEmpty(ox.customAltText)) {
var line = STR.altTextPrefix + STR.quotationOpen + ox.customAltText + STR.quotationClose;
if (ox.altTextSourceType !== SourceType.SOURCE_CUSTOM) {
line = STR.inactivePrefix + line; // mark as inactive
}
out.push(line);
}
// Custom Actual Text
if (stringNotEmpty(ox.customActualText)) {
var lineA = STR.actualTextPrefix + STR.quotationOpen + ox.customActualText + STR.quotationClose;
if (ox.actualTextSourceType !== SourceType.SOURCE_CUSTOM) {
lineA = STR.inactivePrefix + lineA; // mark as inactive
}
out.push(lineA);
}
try {
if (ox.applyTagType && ox.applyTagType != TagType.TAG_FROM_STRUCTURE) {
out.push(STR.pdfTagPrefix + tagLabel(Number(ox.applyTagType)));
}
} catch (_) { }
return out.length ? "• " + out.join("\n• ").replace(/\n+$/, '') : STR.noCustomExportOptions;
}
// --- Generic fallback (if [None] style does not exist) ---
function getNoneExportOptionsDefaults() {
var os = null;
try { os = app.objectStyles.itemByName("[Ninguno]"); } catch (_) {}
if (!os || !os.isValid) {
try { os = app.objectStyles.itemByName("[None]"); } catch (_) {}
}
if (os && os.isValid) {
var d = os.objectExportOptions;
return {
customAltText: d.customAltText,
customActualText: d.customActualText,
altTextSourceType: d.altTextSourceType,
actualTextSourceType: d.actualTextSourceType,
applyTagType: d.applyTagType
};
}
// Generic fallback (if [None] style does not exist)
return {
customAltText: "",
customActualText: "",
altTextSourceType: (SourceType.SOURCE_FROM_XMP || SourceType.SOURCE_XMP || SourceType.SOURCE_NONE),
actualTextSourceType: (SourceType.SOURCE_FROM_XMP || SourceType.SOURCE_XMP || SourceType.SOURCE_NONE),
applyTagType: TagType.TAG_FROM_STRUCTURE
};
}
// --- Reset custom Object Export Options to default values ---
function resetCustomExportOptions(grp) {
var ox;
try { ox = grp.objectExportOptions; } catch (_) {}
if (!ox) return;
var def = getNoneExportOptionsDefaults(); // use values from [None] style
try {
ox.customAltText = def.customAltText;
ox.customActualText = def.customActualText;
ox.altTextSourceType = def.altTextSourceType;
ox.actualTextSourceType = def.actualTextSourceType;
ox.applyTagType = def.applyTagType;
} catch (_) {}
}
// --- Interactive review dialog ---
function reviewCustomExportOptionsDialog(flagged, originalSel) {
var idx = 0;
var total = flagged.length;
var isPlural = (total > 1);
var dlg = new Window("dialog", STR.dialogTitle,
undefined, {closeButton:false});
dlg.orientation = "column";
dlg.alignChildren = ["left","top"];
dlg.spacing = 10;
dlg.margins = 16;
// Intro text
var intro = dlg.add("group");
intro.orientation = "column";
intro.alignChildren = ["left","center"];
intro.spacing = 2;
intro.add("statictext", undefined,
isPlural ? STR.intro1 : STR.intro1_singular,
{softWrap:false, scrolling:false, truncate:"none"});
intro.add("statictext", undefined,
isPlural ? STR.intro2 : STR.intro2_singular,
{softWrap:false, scrolling:false, truncate:"none"});
if (isPlural) {
try {
// Top divider
var div1 = dlg.add("panel");
div1.alignment = "fill";
// Row: counter + navigation buttons
var nav = dlg.add("group");
nav.orientation = "row";
nav.alignChildren = ["left","fill"];
var counter = nav.add("statictext", undefined, STR.groupCounter(1, total));
counter.alignment = ["left","center"];
var prevBtn = nav.add("button", undefined, STR.btnPrev);
var nextBtn = nav.add("button", undefined, STR.btnNext);
// Set initial default button
if (total > 1) {
nextBtn.active = true;
} else {
prevBtn.active = true;
}
} catch (e) {
$.writeln("❌ Error in the navigation UI: " + e);
}
}
// List of custom Object Export Options
var listBlock = dlg.add("group");
listBlock.orientation = "column";
listBlock.alignChildren = ["left","top"];
var info = listBlock.add("edittext", undefined,
getCustomExportOptionsSummary(flagged[0]),
{multiline:true, readonly:true, scrolling:true});
info.alignment = ["fill","top"];
info.preferredSize.width = 400;
info.preferredSize.height = 75;
if (isPlural) {
try {
// Restore default options button for actual group
var resetBtn = listBlock.add("button", undefined, STR.btnRestoreGroup);
resetBtn.enabled = hasCustomExportOptions(flagged[idx]);
// Bottom divider
var div2 = dlg.add("panel"); // ===== divisor 2 =====
div2.alignment = "fill";
} catch (e) {
$.writeln("❌ Error in the block isPlural: " + e);
}
}
// Final action buttons
var bottom = dlg.add("group");
bottom.orientation = "row";
bottom.alignChildren = ["center","top"];
bottom.alignment = ["right","top"];
bottom.spacing = 10;
var cancelBtn = bottom.add("button", undefined, STR.btnCancel, {name:"cancel"});
var ungroupBtn = bottom.add("button", undefined, STR.btnUngroup,{name:"ok"});
// -------------------- interaction logic -------------------------
function refresh() {
counter.text = STR.groupCounter(idx+1, total);
info.text = getCustomExportOptionsSummary(flagged[idx]);
resetBtn.enabled = hasCustomExportOptions(flagged[idx]);
prevBtn.enabled = (idx > 0);
nextBtn.enabled = (idx < total-1);
// Set default (active) button for Space/Enter
if (nextBtn.enabled) {
nextBtn.active = true;
} else if (prevBtn.enabled) {
prevBtn.active = true;
}
}
if (isPlural) {
prevBtn.onClick = function (){
if (idx > 0){
idx--;
try { app.select(flagged[idx]); } catch(_){ }
refresh();
}
};
nextBtn.onClick = function (){
if (idx < total-1){
idx++;
try { app.select(flagged[idx]); } catch(_){ }
refresh();
}
};
resetBtn.onClick = function (){
resetCustomExportOptions(flagged[idx]);
refresh();
};
}
try { refresh(); } catch (_) {} // ensure button states are correct before showing, but don't block dialog if refresh fails
var res = dlg.show();
return (res === 1) ? "ungroup" : "cancel";
}
function stringNotEmpty(v) {
return !!(v && ("" + v).replace(/\s+/g, "").length);
}
// --- Locate Ungroup action ID in any locale ---
function getUngroupActionID() {
var ma;
// order: ID > various known strings
try { ma = app.menuActions.itemByName("$ID/Ungroup"); if (ma.isValid) return ma.id; } catch (_) {}
try { ma = app.menuActions.itemByName("Ungroup"); if (ma.isValid) return ma.id; } catch (_) {}
try { ma = app.menuActions.itemByName("Desagrupar"); if (ma.isValid) return ma.id; } catch (_) {}
// Fallback to known ID (CS5 doc) — usually stable, but not guaranteed
try { ma = app.menuActions.itemByID(118845); if (ma.isValid) return ma.id; } catch (_) {}
return null;
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment