InDesign Scripting Forum Roundup #9
January 27, 2016 | Snippets | en
Here is the new season of the InDesign Scripting Forum Roundup series! In this new episode we shall probe various counter-intuitive concepts and behaviors of the Scripting DOM …and attempt to deliver appropriate solutions!
1/ Text and Typography
Text Ranges vs. Text Units
Reporting Overrides within Nested Styles
On Getting and Setting a Kerning Value
Selectively Removing Conditional Text
2/ Graphics and Geometry
Increasing/Reducing Tints by Defined Steps
Drawing Ellipse Arcs in InDesign
What is the Origin for the Absolute Rotation Angle?
Abbreviating Measurement Units
3/ Miscellaneous
Prompting a Message Box during a Limited Time
Sorting InDesign Color Swatches
Using an IdleTask to Create Responsive PageItems
How to Disable a Custom Menu Item
1/ Text and Typography
Text Ranges vs. Text Units
While playing with range of insertion points Andreas Jansson noted that InDesign commands may return either “simple array” or “array of arrays” with no obvious reason.
What should be highlighted here is that a text range is a special beast compared to regular Collection.itemByRange(...)
outputs.
Here is the point: myText.insertionPoints.itemByRange(0, myIndex)
provides a collective InsertionPoint
(this would be the same with characters, words, or any text collection) but this text range instantly coerces into a single Text
unit as soon as we access a property.
Compare:
/*1*/ myDoc.stories.itemByRange(0, -1).pointSize; // => Array of point sizes /*2*/ myText.insertionPoints.everyItem().pointSize; // => Array of point sizes /*3*/ myText.insertionPoints.itemByRange(0, -1).pointSize; // => Single point size! (even in heterogeneous context)
So, text ranges are specially treated as Text
units, which leads to ask why myRange.findGrep()
returns an array of arrays. In my opinion, the DOM is not consistent on this point.
As Peter Kahrel showed in the related forum thread, the workaround is easy. We just need to explicitly convert the range into the Text
unit it actually refers to, using either myRange.getElements()[0]
or myRange.texts[0]
.
• Original discussion: forums.adobe.com/message/7854957#7854957
Reporting Overrides within Nested Styles
Working with nested styles and overrides issues is not the easiest task when you get into the development of InDesign scripts.
Basically you have to deal with both paragraph and character attributes. Even if you only focus on character overrides, keep in mind that paragraph styles also contain character attributes, so a full report of what happens to some portion of text has to include that paragraph style level. Here is a recap of important cascading effects affecting character attributes:
1. Paragraph style level (incl. inheritance mechanism, cf basedOn property).
2. Manually edited paragraph attributes (i.e. “paragraph overrides,” which may include nested style overrides!)
3. Nested styles (i.e. stack of automated character styles dictated by the current paragraph attributes.)
4. Manually applied—not [None]
—character style (incl. inheritance mechanism, cf. basedOn
property.)
5. Manually edited character attributes (those that override the current character style.)
Given a piece of Text
, its textStyleRanges
collection allows to identify discontiguities at levels 4 and 5, that is, manually applied changes regarding either character style or attributes. (Note however that a character style might be 'empty' relative to its inherited attributes, meaning that no actual change is done in terms of visible properties, although this still is reported as a range break.)
Now, about nested styles (level 3), see them as a stack of pointers that involve existing character styles in an automated way (from the paragraph level.) Technically nested styles are distributed in three collections: NestedStyles
, NestedLineStyles
, and NestedGrepStyles
. Given a paragraph (or a paragraph style) P, you need to inspect both P.nestedStyles
, P.nestedLineStyles
, and P.nestedGrepStyles
to have a complete view of all nested styles defined in P, which does not mean that the underlying character styles are all actually applied.
Note. — As nested styles are managed as paragraph attributes, you can see them in fact from any text portion inside a paragraph, but do not inspect them from a larger portion, e.g. a Story
, because you will then lose specific data.
Since changes resulting from nested styles do not appear as text style ranges, they do not count as overrides, so the crucial question is to identify pieces of text that undergo the effects of some nested style(s). Here we have a useful property, myText.appliedNestedStyles
, which returns an array of all character styles that actually act on myText
as a result of nested/nestedLine/nestedGrep-style mechanism. But this property is reliable only if myText
is a 'uniform' range regarding the nested styles applied. If you select a larger portion, results won't be relevant.
To determine nested styled texts that also undergo manual overrides (level 5), you need to extract from the overridden text style ranges the uniform parts which have a non-empty appliedNestedStyles
array. Problem is, overridden ranges are not easy to identify because the styleOverridden
property does not make any distinction between paragraph-level and character-level overrides! That's the deepest issue.
So, no simple solution exists on this side. We can get the style ranges and select those meeting the condition styleOverridden==true
, but I don't know how to easily exclude the 'good boys' from there... (A deep attribute compare routine could be required for a perfect solution.)
The draft code I suggest is just an approximation, assuming that paragraph styles are properly applied and do not create misleading overrides:
// assuming some text is selected var story = app.selection[0].parentStory; var ranges = story.textStyleRanges, nr = ranges.length, r, t, a, i, iMin, iMax, cur, s, res = []; // Loop in the style ranges // --- for( r=0 ; r < nr ; ++r ) { t = ranges[r]; if( !t.styleOverridden || !t.appliedNestedStyles.length ) continue; t = t.characters; if( !t.length ) continue; // Inspect this range (regarding nested styles.) // --- a = t.everyItem().appliedNestedStyles; iMax = a.length-1; cur = a[iMin=0].join('|'); for( i=1 ; i <= iMax ; ++i ) { s = a[i].join('|'); if( s == cur ) continue; cur && res.push(t.itemByRange(iMin,i-1).getElements()[0]); iMin = i; cur = s; } cur && res.push(t.itemByRange(iMin,iMax).getElements()[0]); } // Show the results as contents. // --- i = res.length; while( i-- ){ res[i]=res[i].contents } alert( "The nested-styled text overrides should be in this array:\r\r\r" + res.join('\r--------------\r') );
• Original discussion: forums.adobe.com/message/7963472#7963472
On Getting and Setting a Kerning Value
From the Scripting DOM standpoint kerning regards insertion points between characters, so you will usually get in trouble when targeting either the first or the last insertion points of a Story
. When the kerningMethod
is automated (as 'Optical'
or 'Metrical'
) kerning values have a default amount which you can read using myInsertionPoint.kerningValue
, but this command fails if the insertion point index is 0
(zero) or the last index in the story. You then get a “property is not applicable in the current state” error.
Anyway you can override kerning values. Peter Kahrel has shown how to do this quickly:
// Overriding the kerning value everywhere // --- myText.insertionPoints.everyItem().kerningValue = 50;
which can even be abbreviated as follows:
// All underlying insertion points are targeted as well // --- myText.kerningValue = 50;
Interestingly, such command allows to apply the overriding value to both the first and last insertion point (although this has no visual effect). And then myText.insertionPoints[0].kerningValue
does not lead to a runtime error and actually returns the value (50
).
• Original discussion: forums.adobe.com/message/7998706#7998706
Selectively Removing Conditional Text
sreekarthik wrote: “My text has two conditions (red and green). I need to remove the red conditional text from the selected text. How to do this?”
Suggested code:
(function removeConditionalText(/*Text*/tx,/*enum*/uiColor,/*bool*/STRICT) // ------------------------------------- // Remove any text within tx having a uiColor condition applied. // (If STRICT is true, do not affect text undergoing multiple conds.) { var ranges = tx.textStyleRanges, a = ranges.everyItem().getElements(), conds = ranges.everyItem().appliedConditions, i = a.length, t, c; while( i-- ) { t = conds[i]; if( STRICT && t.length != 1 ) continue; while( (c=t.pop()) && !(c=c.indicatorColor==uiColor) ); if( c ) a[i].remove(); } })(app.selection[0],UIColors.RED);
• Original discussion: forums.adobe.com/message/8029754#8029754
2/ Graphics and Geometry
Increasing/Reducing Tints by Constant Steps
The function stepTint below provides a simple way of changing the tint of the selected object by a constant step. This snippet can be used as a base for incrementing/decrementing scripted tasks, in conjonction with custom shortcut keys.
Suggested code:
function stepTint(/*int*/dt, a,TXT,PROP,LIM,ALT,MM,o,t,v) { if( !dt || !(a=app.properties.selection) ) return; TXT = +(app.strokeFillProxySettings.target==StrokeFillTargetOptions.FORMATTING_AFFECTS_TEXT); PROP = app.strokeFillProxySettings.active.toString().toLowerCase() + 'Tint'; LIM = 100 * (0 < dt); ALT = TXT ? (100-LIM) : 100; MM = Math[ LIM ? 'min' : 'max' ]; while( o=a.pop() ) { TXT && (o=o.texts[0].textStyleRanges.everyItem()); o = o.getElements(); while( t=o.pop() ) { ~(v=t[PROP]) || (v=ALT); t[PROP] = MM(LIM,v+=dt); } } } // API // --- const STEP = 5; // positive integer function incTint(){ stepTint(+STEP); } function decTint(){ stepTint(-STEP); } // TEST // --- incTint();
• Original discussion: forums.adobe.com/message/3080481#3080481
Drawing Ellipse Arcs in InDesign
What Trevor wanted to implement was a generic function for elliptic arcs, based on a height, a width, and both a starting and an ending angle.
Basic idea: do not work with an ellipse when you can just rescale a circle through a transformation matrix. Indeed, all can be done by just scaling data from basic circle path points, although this involves managing circles (and arcs) in terms of cubic Bézier curves.
Note. — As we already know a Bézier curve cannot perfectly match an exact circle but there are decent approximations. A good read on this issue is: Approximate a circle with cubic Bézier curves. See also: Drawing Sine Waves in InDesign.
Hence, first task is to implement circle arcs. My main reference here was Circular Arcs and Circles, which provides clear explanations on computing the right Bézier weight for a specific arc in [0, π/2]
. Then, all we need to do is to create as many control points as needed to cover arcs over 90 degree. Let's divide the entire arc in equal sub-arcs so that we just have to deal with a single Bézier weight calculation and then apply a rotation to the successive control points.
At this stage we can use the following code:
$.hasOwnProperty('Arc')||(function(H/*OST*/,S/*ELF*/,I/*NNER*/) { H[S] = S; I.F_ROTATOR = function F(/*num[2][]&*/a) // ------------------------------------- // A rotation utility. { if( !a ) { F.FACTOR = 1e3; F.COS = 1; F.SIN = 0; F.alphaDeg = 0; F.rotate || (F.rotate = function(dAlphaDeg) { var a = ((F.alphaDeg += dAlphaDeg)*Math.PI)/180; F.COS = Math.cos(a); F.SIN = Math.sin(a); }); return F; } var cos = F.COS, sin = F.SIN, factor = F.FACTOR, x, y, i = a.length; while( i-- ) { x = a[i][0]; y = a[i][1]; a[i][0] = (x*cos + y*sin)/factor; a[i][1] = (y*cos - x*sin)/factor; } }; I.F_TANGENT_FACTOR = function(/*]0...[*/radius,/*]0,90]*/angle,/*bool=false*/ANGLE_IN_RADIANS) // ------------------------------------- // Get the perfect Bezier weight for that angle. { var a = ANGLE_IN_RADIANS ? angle : ((angle * Math.PI) / 180), x = radius * Math.cos(a), y = radius * Math.sin(a); return ( 4 * (Math.sqrt(radius*radius - 2*x) - (x-1)) ) / ( 3*y ); } I.F_ARC_PATH = function(/*]0...[*/radius,/*]0,360]*/arcDeg,/*[0,360[*/rotDeg) // ------------------------------------- // Generate the entire path. { var FX = I.F_ROTATOR(), // --- n = 2 + (arcDeg>90) + (arcDeg>180) + (arcDeg>210), r = radius*FX.FACTOR, // --- alphaDeg = arcDeg / n, kAlpha = r * I.F_TANGENT_FACTOR(r, alphaDeg/2), // --- path, i, z, t; FX.rotate(rotDeg); for( path=[], i=z=0 ; i <= n ; ++i ) { t = path[z++] = [ null, [r,0], null ]; t[0] = i > 0 ? [r, kAlpha] : t[1].concat(); t[2] = i < n ? [r, -kAlpha] : t[1].concat(); FX(t); FX.rotate(alphaDeg); } return path; }; // ------------------------------------- // API // ------------------------------------- S.buildPath = I.F_ARC_PATH; })($,{toString:function(){return 'Arc'}},{}); // --- // TEST -- assuming the ruler space is OK, in pt, etc. // --- var path = $.Arc.buildPath(/*radius*/100,/*arc*/270,/*rotation*/90); app.activeWindow.activeSpread.rectangles.add({ strokeColor:'Black', fillColor:'None', strokeWeight:'3pt' }).paths[0].properties = { entirePath: path, pathType: +PathType.OPEN_PATH, };
Now the question is to find the right parameters (radius, rotation and arc angles) in order to fit a final ellipse arc, with respect to some scaling factor, as shown below.
Here we will need a little bit of math. Take R = W / 2
and k = W / H
(where W and H respectively represent the width and the height of the ellipse.) k
is the y-scaling factor that sends the ellipse to the circle (and inversely 1/k
is the y-scaling factor we shall apply to the circular arc.)
Assuming the parameters provide the start and end angles relative to the ellipse, the problem is to determine the corresponding angles in the circular area (that is, before stretching the circle).
Given an angle α
relative to the ellipse, what is the corresponding angle α'
relative to the circle? Let's solve this first in the usual trigonometric form:
Now we can write an adapter to convert ellipse parameters into circle parameters and then re-use our previous Arc
routine.
// =================================================== // Assuming the $.Arc function is included (see above) // =================================================== // Client parameters // --- var W = 400, H = 144, S = 57, // start angle, in degree, origin +90, reversed E = 212; // start angle, in degree, origin +90, reversed // Adapter // --- const DEG2RAD = Math.PI/180, RAD2DEG = 180/Math.PI; var radius = W/2, k = W/H, convertEllipseToCircleAngle = function(a) { return 180*(0>Math.cos(a*=DEG2RAD)) + RAD2DEG*Math.atan(k*Math.tan(a)); }, // --- start = convertEllipseToCircleAngle(90-E), end = convertEllipseToCircleAngle(90-S), arc = end-start; while( arc <= 0 ) arc+=360; // Build the circular arc using $.Arc (see previous post.) // --- var path = $.Arc.buildPath(radius,arc,start); var arc = app.activeWindow.activeSpread.rectangles.add({ strokeColor:'Black', fillColor:'None', strokeWeight:'2pt' }); arc.paths[0].properties = { entirePath: path, pathType: +PathType.OPEN_PATH, }; // Y-rescale by 1/k (relative to the space origin.) // [This should be done at the coordinate level for better performance!] // --- arc.resize( CoordinateSpaces.spreadCoordinates, [[0,0],AnchorPoint.centerAnchor], // this refers to the ruler origin ResizeMethods.MULTIPLYING_CURRENT_DIMENSIONS_BY, [1,1/k] );
Here is how the script behaves for decreasing values of H
:
• Original discussion: forums.adobe.com/message/8055223#8055223
What is the Origin for the Absolute Rotation Angle?
ajaatshatru wrote: “As you can see, the image has been rotated while the container is as it is. Now the absoluteRotationAngle
property gives the angle of rotation with respect to the container. Hence if I wish to rotate the points accordingly, I need to translate them the origin, apply the rotation and then translate back. My question is, what are the coordinates of the origin and how to get it?”
The property absoluteRotationAngle
—which by the way is totally misnamed—reflects as a degree angle the rotation attributes of the object transformation matrix—that is, relative to the parent coordinate space. The transformation matrix in itself does not specify any origin, since coordinates are computed by just applying that matrix, which contains translation attributes. However, you can specify a “temporary origin” when you are applying a transformation (using e.g. the transform()
method.) In that case it is possible to provide a location (any location) and to apply a rotation relative to that point. The resulting transformation matrix will then reflect the resulting state of the object (rotation, translation, etc.), but the temporary origin used during the rotation is not recorded at all.
Anyway, if you don't deal with explicit transform methods and just want to (re)set the absoluteRotationAngle
property, you have to consider the current transform reference point as the implicit origin of that transformation. For example, if the transform reference point is currently the center anchor in the GUI, then the center point of the inner bounding box (in your screenshot, the center point of the brown rectangle) will behave as the origin of the rotation. Indeed,
1. Changing only the rotation attributes of a transformation matrix has no effect on the translation attributes.
2. The center point of the inner bounding box is known to be invariant as long as no translation occurs. By contrast, if the current transform reference point is, say, the top-left anchor, then the rotation will occur relative to that origin; and so on.
• Original discussion: forums.adobe.com/message/8119598#8119598
• See also: Coordinate Spaces and Transformations in InDesign
Abbreviating Measurement Units
A cool way of abbreviating units that come from the MeasurementUnits
enumeration is to use the UnitValue
interface:
// Given a Measurement Unit: var unit = app.activeDocument.viewPreferences .horizontalMeasurementUnits.toString(); // Its abbreviation: var abbr = UnitValue(1+unit).type; alert( unit + " => " + abbr );
But this trick only works if the DOM unit is supported by the UnitValue
class. This is not the case for every unit available in InDesign, so if abbr contains "?" you'll need to manage an exception.
Here is a possible approach:
function abbrUnit(/*MeasurementUnitName*/mu, r) { // First method. // --- r = UnitValue(1+mu).type; if( '?' != r ) return r; // Fallback. // --- r = +MeasurementUnits[mu]; return String.fromCharCode(0xFF&(r>>>16), 0xFF&(r>>>8)) .toLowerCase(); }
Note. — At first sight the “Fallback” part looks like an insane hack! It relies on the fact that MeasurementUnits
enum values provide bytes that encode in ASCII some letters of the unit string. For further detail on this, read the entry “How Do Enumerator Instances Mimick Numbers” in ISFR #8.
• Original discussion: forums.adobe.com/message/8350971#8350971
3/ Miscellaneous
Prompting a Message Box during a Limited Time
As explained by Gerald Singelmann since a ScriptUI dialog
can interact with user events one can hardly combine it with custom timing events. So if you need to display a message box that automatically closes after 10 seconds and ignore user interactions, best is to use a non-modal palette
object. This way the script execution is not interrupted and you can have a loop based timer (Gerald provides a sample code in the thread.)
However, if you really need a modal solution, that is, a dialog
-based message box which automatically closes after some delay, you can trigger a timer within the activate event handler. Which leads to the following snippet:
function modalPrompt(/*str*/msg, /*uint*/msec, w,u) { (w=new Window('dialog')).add('statictext',u,msg); w.onActivate = function(){ $.sleep(msec); this.close(); }; w.show(); } // Test // --- modalPrompt("That Should Work.",10000);
• Original discussion: forums.adobe.com/message/7959735#7959735
Sorting InDesign Color Swatches
MPrewit challenged us of sorting InDesign color swatches with respect to some custom parameters detailed in his original post.
My answer is a 548 line script which I won't duplicate here to avoid burdening this page, so give a look at the original link.
• Original discussion: forums.adobe.com/message/8186080#8186080
Using an IdleTask to Create Responsive PageItems
We sometimes need that some page item interact or update accordingly depending on selection changes. For example, a TextFrame
object may have a twin than wants to be updated when the user is changing the color or the location of the original component.
Under some circumstances InDesign is supposed to trigger a afterSelectionAttributeChanged
event, but it usuallly doesn't work as expected. My workaround is to use an idleTask
instead:
Sample code:
#targetengine follow var doc = app.documents.add(), master, slave; master = doc.pages[0].textFrames.add({ geometricBounds:[20,20,100,100], fillColor: "Cyan", contents: "MASTER" }); slave = doc.pages[0].textFrames.add({ geometricBounds:[120,20,200,100], fillColor: "Cyan", fillTint:40, contents: "SLAVE" }); function follow() { if( !master || !master.isValid ) return; if( !slave || !slave.isValid ) return; var pm = master.properties, ps = slave.properties, t; if( ps.fillColor !== (t=pm.fillColor) ) { slave.fillColor = t } if( ps.contents !== (t=pm.contents) ) { slave.contents = t } // etc. if( !follow.CACHE ) { follow.CACHE = pm.geometricBounds.concat(); return; } if( follow.CACHE.toSource() != (t=pm.geometricBounds).toSource() ) { var dx = t[1]-follow.CACHE[1]; var dy = t[0]-follow.CACHE[0]; slave.move(undefined,[dx,dy]); // etc. follow.CACHE.length = 0; [].push.apply(follow.CACHE,t); } } (function(tasks,name,rate,callback) { var t = tasks.itemByName(name); if( t.isValid ) { t.eventListeners.everyItem().remove(); t.remove(); } tasks.add({name:name, sleep:rate}) .addEventListener(IdleEvent.ON_IDLE, callback, false); })(app.idleTasks,'followTask',15,follow);
Additional notes. — The way the DOM hierarchy reports event types may be confusing. For example, the beforePlace
and afterPlace
event types are statically registered at the PageItem
level (and shared by the corresponding subclasses) which probably indicates that those kinds of objects can trigger those types of events. Here by “triggering” I mean that any PageItem
can be the target of a (before/after)Place event—keeping in mind that one can still attach the listener to any parent in the hierarchy, e.g. a document or app.
Using the same reasoning for afterAttributeChanged
, we can notice that this event type is statically registered at the Window
level, not at the Document
level. From what we can conclude that afterAttributeChanged
is mainly triggered by Window
objects (incl. LayoutWindow
and StoryWindow
) and then is not connected to attribute changing at the page item or document levels. Interestingly this event also appears under the MediaItem
, Link
and ToolBox
classes.
As for afterSelectionChanged
and afterSelectionAttributeChanged
, we only find them at Window
and Application
levels, so here again they do not target page items, although one may still run a scan on app.properties.selection
when such event occurs. But I remain very dubious about the actual sensitivity and scope of those events. Maybe they are just flags for some GUI changing states, not including every “selection change” that is relevant to us.
Apart from that I have to mention that the IdleTask
approach is highly resource-consuming and shouldn't be the right answer in a perfect world. Anyway it works in a very responsive and funny way.
• Original discussion: forums.adobe.com/message/8259858#8259858
How to Disable a Custom Menu Item
K159 noticed that after adding a custom menu using ExtendScript he cannot directly set to false
his MyMenuItem.enabled
property, since this is a read-only member. So, what if one needs to temporarily disable a scripted menu item?
The trick is, only the ScriptMenuAction.enabled
property is rewriteable. This prevents the scripting layer from interfering with regular InDesign menus.
So, given your custom MenuItem
object (myMenuItem), its associatedMenuAction
should refer to a ScriptMenuAction
which can be disabled this way:
// In order to disable a custom menu item, // disable its associated menu action. myMenuItem.associatedMenuAction.enabled = false;
This results in graying the menu item.
• Original discussion: forums.adobe.com/message/8302298#8302298
GitHub page: github.com/indiscripts
Twitter stream: twitter.com/indiscripts
YouTube: youtube.com/user/IndiscriptsTV
Comments
Thank-you! The section about Character, and Paragraph overrides, is especially useful for me - almost serendipitous to what I'm doing now.
Hi,
Sorry about to bring this subject to this post, but I was wondering i would exist updated version of the SmartCellMerge.
Thanks.
Hi Edu,
No update of SmartCellMerge was planned… until you post your request ;-)
Now I'm considering removing dust from that old code.
@+
Marc