Last active
August 24, 2025 16:23
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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