Wednesday, October 26, 2005

 

Page Numbers are Squirrelly

The name of a page is a string and might include the section prefix, so subtracting 1 from it might fail.

It depends, I think, on three things:
  1. The state of the page numbering option in general preferences.
  2. The presence of a section prefix in the Section and Numbering Options panel.
  3. The state of the Include Prefix in Page Numbers option in the same panel.
Some scripts will clarify. This one sets the page numbering option and then displays an alert that cofirms what you did:
app.generalPreferences.pageNumbering = [PageNumberingOptions.section,
  PageNumberingOptions.absolute][0];
alert(decodePageNumbering(app.generalPreferences.pageNumbering,true));

function decodePageNumbering(theCode,verbose) {
 var theCodes = [[1096971116, ["absolute", "absolute numbering"]],
  nbsp;[1935897710, ["section", "section numbering"]]];
 var verbosePtr = 0;
 if (verbose) { verbosePtr = 1 }
 switch (theCode) {
  case PageNumberingOptions.absolute :
   return theCodes[0][1][verbosePtr];
  case PageNumberingOptions.section :
   return theCodes[1][1][verbosePtr];
  default :
   throw (String(theCode) + "not recognized")
 }
}
You can try both settings by changing the [0] to [1] at the end of the first statement (on the second line).

OK, so now let's try this script (with a new, single-page document open):
app.generalPreferences.pageNumbering = [PageNumberingOptions.section, 
  PageNumberingOptions.absolute][0];

var myPage = app.activeDocument.pages[0];
myPage.appliedSection.sectionPrefix = "Test-";
myPage.appliedSection.includeSectionPrefix = true;
alert(myPage.name);
and you'll quickly find that toggling the state of the page numbering options (by changing that [0] to [1] again) has no effect at all on what the alert says. So much for my assertion 1 above. I think that what that option does is determine the form you must use when you supply a page number to certain methods, for example if you want to export a pdf or print a page range from your document. Otherwise, it has no effect on working with page numbers.

Another test:
var myPage = app.activeDocument.pages[0];
myPage.appliedSection.sectionPrefix = "Test-";
myPage.appliedSection.includeSectionPrefix = false;
alert(myPage.name);
This gives you an alert that just says 1

But is it a number?
var myPage = app.activeDocument.pages[0];
myPage.appliedSection.sectionPrefix = "Test-";
myPage.appliedSection.includeSectionPrefix = false;
alert(myPage.name.constructor.name);
No it isn't; it's a string. But what if we try to treat it as a number?
var myPage = app.activeDocument.pages[0];
myPage.appliedSection.sectionPrefix = "Test-";
myPage.appliedSection.includeSectionPrefix = false;
alert(myPage.name - 1);
Well, what do you know, it works! This is one of those things about JavaScript that on the one hand allows you to be lazy; after all, most people have a prefix of "" in most of their single-section documents because that's the default when a new document is created, and so most scripts that just treat the page name as a number just work. And that leads you down the path of thinking that the page name is a number -- but it isn't. It's a string, that most of the time contains the string representation of a number, so most of the time a script that thinks it's a number actually does work.

But it has a bug as big as your nose right there staring you in the face and you won't ever find it until you start working with multi-section documents or you take advantage of the section prefix in one of your documents.

Sunday, October 23, 2005

 

Vertical Justification Undo

Already, I need a second script that will walk through a column restoring the space before to that used by assigned paragraph styles. This is an easy fall-out from the previous script. Instead of the spreadText() function, I have an unSpreadText() function that looks like this:
function unSpreadText(theText) {
 for (var j = theText.paragraphs.length - 1; j > 0; j--) {
  theText.paragraphs[j].spaceBefore = theText.paragraphs[j].appliedParagraphStyle.spaceBefore;
 }
}
Well, that was easy.

 

Vertical Justification

You'd think this was easy -- just turn it on in text frame options. But I've run into a case where that doesn't work. My client wants two columns in a text frame vertically aligned with each other, but the second column contains an inline graphic which is overlaid by some text. The graphic extends beyond the bottom of the text frame -- well, it does if I switch on vertical justification via text frame options. So that doesn't do the job because what he really wants is for the text in the left column to align with the bottom of the graphic in the right column.

So, we need a script.

The concept of the script would be that the size of the text frame has been manually set. The bottom falls where it falls and the need to is fit the text so that it nestles to that bottom. But which text? If I do this with all the text in each column of the text frame, then it will have precisely the effect that switching on VJ has. So, the user must click in the text column of interest.

So, task number one is to determine which column of text contains the insertion point. Usually, when I write scripts that depend on a text insertion point, I don't really care if the user has made a larger selection -- in that case, I just take the first insertion point of the selection, but in this case, I'm going to be more fussy. The script will insist on a single insertion point and will decline to proceed for any other kind of selection.

So, to get started, let's deal with that and then call a function to tell us which column contains the insertion point (note: the errorExit() function is discussed here):
//DESCRIPTION: Vertically "justify" indicated text column by adding space before.

if ((app.documents.length != 0) && (app.selection.length != 0)) {
 if (app.selection[0].constructor.name != "InsertionPoint") {
  errorExit("Indicate column by clicking an insertion point with the text tool.");
 }
 var myCol = getColNum(app.selection[0]);
} else {
 errorExit();
}
Well, it didn't take long for me to have second thoughts. Just writing this amount of code caused me to take pause and ask: What if the insertion point is in a cell of a table or in text on a path?

Interesting questions! Text on a path is clearly ineligible for this kind of processing so we must trap for that and issue an error message. Text in a cell could be eligible, but perhaps the user clicked in it by accident -- it is after all also in a text column -- the column that contains the table that contains the cell. So, for now, I'm also going to trap that possibility out of consideration (a decision aided by the fact that my immediate need doesn't involve tables).

Now the body of the script looks like this:
if ((app.documents.length != 0) && (app.selection.length != 0)) {
 var mySel = app.selection[0];
 if (mySel.constructor.name != "InsertionPoint") {
  errorExit("Indicate column by clicking an insertion point with the text tool.");
 }
 // What if insertion point is in a cell or text on a path?
 if (mySel.parent.constructor.name == "Cell") {
  errorExit("Script does not operate on text inside tables.");
 }
 if (mySel.parentTextFrames[0].constructor.name == "TextPath") {
  errorExit("Script does not operate on text on a path.");
 }
 var myCol = getColNum(mySel);
} else {
errorExit();
}
You'll see that I quickly tired of typing "app.selection[0]" so I created a variable to hold a reference to the selection. Because the parent of a text object (insertion point is a kind of text object) is either "Cell" or "Story" differentiating text on a path from text in a text frame requires a different technique from determining if the text is in a cell in a table.

Now it's time to take a crack at the getColNum function. Here's an irony: I don't care which column it is. All I really need to do this job is a reference to the text in the column. So, let's change the name of the function and the call to it to:
 var myText = getColTextRef(mySel);
Here's the function:
function getColTextRef(myIP) {
 // returns reference to text of text column that holds insertion point
 var myTF = myIP.parentTextFrames[0];
 var myIndex = myIP.index;
 var colIndexes = myTF.textColumns.everyItem().index;
 for ( j= colIndexes.length - 1; j>= 0; j--) {
  if (myIndex >= colIndexes[j]) {
   return myTF.textColumns[j].texts[0];
  }
 }
 errorExit("Something is ghastly wrong");
}
That final error ought to be impossible. The script will always find the column and exit accordingly. But because it is a conceptually possible logic path, I've put an error message in there to let me know if for some unforesee reason we do get there.

Thinking about errors makes me wonder what will happen if the user tries to run this script on an empty (or nearly empty) text column. The logic of adding space before to all except the first paragraph isn't going to get very far if there's one or fewer paragraphs. We'll need to check for that right now:
 var myText = getColTextRef(mySel);
 if (myText.paragraphs.length > 1) {
  spreadText(myText);
 } else {
  errorExit("Indicated column has fewer than two paragraphs.");
 }
So now we need the spreadText() function. All the while I've been writing about this script, I've been mulling over the best way to tackle the job of spreading vertically. The process has two parts:
  1. Determine how much space needs to be consumed
  2. Apply to each paragraph in the text, except the first, its portion of that space as extra space before
Probably the easiest way to do this is to make use of the vertical justification feature of InDesign, comparing the last baseline before with the last baseline after. If we go this route, we have to make sure that the vertical justification ends up the way it started. Here's how the first part of spreadText() looks:
function spreadText(theText) {
 // Calculate spare space by comparing last baseline with VJ on and off
 // VJ is not a property of text but its container. So:
 var origVJ = theText.parentTextFrames[0].textFramePreferences.verticalJustification;
 theText.parentTextFrames[0].textFramePreferences.verticalJustification = VerticalJustification.topAlign;
 theText.recompose();
 var firstBase = theText.lines[-1].baseline;
 theText.parentTextFrames[0].textFramePreferences.verticalJustification = VerticalJustification.justifyAlign;
 theText.recompose();
 var targetBase = theText.lines[-1].baseline;
 theText.parentTextFrames[0].textFramePreferences.verticalJustification = origVJ;
}
Although this function, so far, does nothing more than collect information, we can use ESTK to test that it works. And indeed a quick test seems to confirm that it does work. I worry that the process of changing the vertical justification to collect information won't always work. For example, what if there is a text wrap interfering with the shape of the text frame? In that case, the attempt to set justifyAlign will probably fail with an error.

That's serious enough to warrant trapping the error because otherwise we could leave the text frame with the wrong vertical alignment. So
 try {
  theText.parentTextFrames[0].textFramePreferences.verticalJustification = VerticalJustification.justifyAlign;
 } catch (e) {
  // restore VJ
  theText.parentTextFrames[0].textFramePreferences.verticalJustification = origVJ;
  errorExit("Frame has text wrap and can't be processed.");
 }
OK, I'm being a wimp and rolling over in the face of text wrap, but dealing with that is a very big deal and I certainly don't have the time right now.

Uh oh! A quick test reveals that attempting to set justifyAlign when it's ineligible doesn't cause a run-time error. The command is just ignored. That sounds like a bug to me. However, it does mean that the script will do nothing in that case. Perhaps it's worth adding a test to make sure that targetBase and firstBase are different from each other (indeed, that the former is larger than the latter) otherwise the user might not notice that nothing happened.

And that's basically it. From here on, everything is simple math and a loop:
 if (firstBase >= targetBase) {
  errorExit("Text Column apparently already spread—perhaps a text wrap is interfering.");
 }
 // Calculate extra space needed per paragraph
 var myExtraSpace = (targetBase - firstBase)/(theText.paragraphs.length - 1);
 for (var j = theText.paragraphs.length - 1; j > 0; j--) {
 theText.paragraphs[j].spaceBefore = theText.paragraphs[j].spaceBefore + myExtraSpace;
 }
Notice that the first paragraph is ignored. That's because space before the first paragraph in a text column is ignored by the composer.

Thursday, October 20, 2005

 

Text Styles Reporter

I've just posted my first "Donationware". It's a script that produces a sectonalized report of your text styles. First the paragraph styles and then the character styles. You get to choose which aspects of your styles are reported, and you can choose to have them listed alphabetically or in based-On order. The script also lets you choose a document preset for the report it generates.

The script works only with InDesign CS2. It comes in two parts, the script and a snippet, both of which must be stored together in the Scripts folder (or a subfolder thereof) in the Presets folder of your InDesign CS2 application folder. The dependency on a snippet and the differences between CS and CS2 styles means that this script works only with CS2.

If you feel moved to donate, open the script in a text editor or ESTK for instructions. Note that the script will not run to completion if you run it from ESTK. It needs to run from the palette so that the snippet can be located.

See the Featured Downloads at left to download a copy.

Saturday, October 15, 2005

 

with and Preferences

All the time I've being using JS with ID, I've avoided the use of the with statement because various people advised me that it was dodgy. I've even seen it in print. In the JavaScript Pocket Reference published by O'Reilly, David Flanagan writes:

The with statement has some complex and non-intuitive side effects; its use is strongly discouraged.

So I've not been using it. But this morning it occurred to me that I could surely save myself a lot of typing in this function:
function setXLimportPrefs() {
 with (app.excelImportPreferences) {
  alignmentStyle = [AlignmentStyleOptions.spreadsheet,
    AlignmentStyleOptions.leftAlign,
    AlignmentStyleOptions.rightAlign,
    AlignmentStyleOptions.centerAlign][0];
  decimalPlaces = 3;
  // Ignoring errorCode for now; I don't know what it's for
  preserveGraphics = false; // Not sure what this does either
  rangeName = ""; // Hopefully leaving this blank will cause ranges to be ignored
  sheetIndex = 0;
  sheetName = "";
  showHiddenCells = true;
  tableFormatting = [TableFormattingOptions.excelFormattedTable,
    TableFormattingOptions.excelUnformattedTable,
    TableFormattingOptions.excelUnformattedTabbedText][0];
  useTypographersQuotes = true;
  viewName = "";
 }
}
And it seems to work very well. Perhaps in simple cases like this it is fine to use with. I can see how it might get more hairy if I were to start nesting them.

And while I'm sitting here pleased with myself, notice how I set the values of the two enumerations. Doing it this way saves me from having to look them up next time I want to use a variant of these preferences in a script.

-- Later that morning --

That's interesting. Using the preferences as I have them here caused the import I did next to fail with "User canceled this action." Perhaps I'd better set the errorCode to 0 after all.

Oh dear. That didn't help. I seem to have broken my ability to import Excel spreadsheets. And there I was just a few minutes ago feeling so pleased with myself. Let's see if I can still do it in the UI. ...

Yes I can.

Hmm. If I switch off the call to this function, it still works in my script, so setting this particular set of preferences causes the import to fail. Changing the preserveGraphics to true didn't help either (it seems to control the import of inline graphics -- I didn't know that Excel supported inline graphics).

Aha! The errorCode to the rescue. It tells me "Invalid Sheet" -- I bet Excel starts counting from 1 ... Yep! That was it.

I still don't understand why the errorCode is read/write, but I'm sure glad it is there.

-- Later still --

It is such an easy mistake to make and I seem to trap myself every time I do it. If, in the course of investigating a problem, I disable a call to a function, it is vital to re-enable the call before making changes in the function because otherwise your changes will go unnoticed.

Sad to say, this happened here. I still don't know what the ultimate issue is because the changes caused InDesign to start crashing rather frequently, so now I'm going through the process of restarting my computer and trying to get back to where I was so I can pin this down.

Saturday, October 01, 2005

 

Regex Tester Revisited

It has come to me that for this script to be really useful, it needs to remember the previous version of your Regex find string and use it from run to run so you can refine the string until you get it the way you really want it.

So, where to keep it between runs? I'm thinking perhaps as a label to the document you're working on or perhaps the object containing the text you're working on.

Thinks: do stories support labels? Yes, but text in cells don't. On the other hand, text in cells have parent stories, so that's one possibility. While in CS2, cells support labels.

So many choices!

Perhaps to get started I should just use the document itself. After all, you're not likely to be working on more than one of these at any particular time, and when you've got it right you can call the script one last time and copy it out of the prompt to where you really need it (probably in some other script).

OK, but let's not screw up the possibility that you are already using the document label, so I'll use a keyed label. I'll give it the key "PDSregex".

Changing the script is pretty simple. Once it is determined that there is an active document, I can create a reference to it:
if ((app.documents.length != 0) && (app.selection.length != 0)) {
 var myDoc = app.activeDocument;
And then, the part that issues the prompt needs a couple of extra lines:
  // Invite user to type regular expression
  var curExp = myDoc.extractLabel("PDSregex");
  app.activate();
  var myREtext = prompt("Type your regular expresion",curExp);
  if (myREtext == null) { errorExit() }
  myDoc.insertLabel("PDSregex",myREtext);

So, the first time you use the script with a particular document open, you'll get a blank field to start your Regex string, but after that, each time you run the script it will pick up the most recent version of your string and you can continue refining it until you get it right!

I've updated the linked script to include this logic.

 

Regex Tester

It occurred to me that constructing a script that helps test Regex commands would be a good idea, so I came up initially with this script (which uses all three of the methods and functions in the previous blog entry):
if ((app.documents.length != 0) && (app.selection.length != 0)) {
 var myRange = app.selection[0];
 if (myRange.isText()) {
  // if selection is only an insertion point, process parent text range
  if (myRange.constructor.name == "InsertionPoint") {
   myRange = getParentTextFlow(myRange);
  }
  
  // Invite user to type regular expression
  app.activate();
  var myREtext = prompt("Type your regular expresion","");
  if (myREtext == null) { errorExit() }
  myRE = new RegExp(myREtext);
  var myTest = myRE.exec(myRange.contents);
  app.activate();
  alert(myRE + " finds:\n" + myTest[1]);
 }
} else {
 errorExit("Please select some text and try again");
}
To use this script, select some text in an InDesign document (or click in a story or table cell) and run the script. It prompts you for a Regex string and displays the result in an alert. However, even as I sit here describing this version, I see a couple of problems.

First, the script focuses on the first matched substring of text. But what if you're not interested in substrings? Well, my "solution" to that is a bit clunky: surround your whole search string in parentheses.

Second, although the script looks as though it deals with non-text selections, it actually doesn't. The "else" statement is only triggered if there is either no document open or no selection.

Let's try to fix both of these by (a) being a bit smarter with the results of the exec() call and (b) by giving the script a better internal structure.
var myErr = "Please select some text and try again";
if ((app.documents.length != 0) && (app.selection.length != 0)) {
 var myRange = app.selection[0];
 if (!myRange.isText()) {
  errorExit(myErr);
 } else {
  // if selection is only an insertion point, process parent text range
  if (myRange.constructor.name == "InsertionPoint") {
   myRange = getParentTextFlow(myRange);
  }
  
  // Invite user to type regular expression
  app.activate();
  var myREtext = prompt("Type your regular expresion","");
  if (myREtext == null) { errorExit() }
  myRE = new RegExp(myREtext);
  var myTest = myRE.exec(myRange.contents);
  var myReport = myRE + " finds:";
  if (myTest == null) {
   myReport = myReport + " no match";
  } else {
   myReport = myReport + "\n" + myTest[0];
   for (var j = 1; myTest.length > j; j++) {
    myReport = myReport + "\n$" + String(j) + " = " + myTest[j];
   }
  }
  app.activate();
  alert(myReport);
 }
} else {
 errorExit(myErr);
}

There, now that's what I call a useful script!

 

Standard Methods and Functions

I'm becoming concerned that my script postings here are become unwieldy because of the length of the standard methods and functions I use over and over. By way of an experiment, I'm going to post some of the more common ones here so I won't have to repeat them in posts about specific scripts:

isText() Method

This method returns true if an object is any kind of text object and false otherwise. Constructing it this ways allows it to be called like this:
if (myObj.isText()) { // myObj is text so continue
Here's the definition of the method. I always put my method definitions at the top of my scripts, immediately after the description.
Object.prototype.isText = function() {
 switch(this.constructor.name){
  case "InsertionPoint":
  case "Character":
  case "Word":
  case "TextStyleRange":
  case "Line":
  case "Paragraph":
  case "TextColumn":
  case "Text":
  case "TextFrame":
   return true;
  default :
   return false;
 }
}

errorExit(myMsg) function

This function throws up an alert if an argument is present, otherwise it just exits. For CS2 or later, it issues a beep immediately before displaying the alert.
function errorExit(message) {
 if (arguments.length > 0) {
  if (app.version != 3) { beep() } // CS2 includes beep() function.
  alert(message);
 }
 exit(); // CS exits with a beep; CS2 exits silently.
}
I always put my functions after the main body of the script, usually preceded by a comment line that indicates the start.

getParentTextFlow(theTextRef) Function

This function returns the parent text flow of the referenced text item. Notice that the function does no error checking; if you pass it something that doesn't have a parentStory property you'll get an error. The script checks to see if the referenced text is in a cell in a table, and if so it returns a reference to the text of the cell rather than the parentStory which in this case is the parentStory of the table.
function getParentTextFlow(theTextRef) {
 // Returns reference to parent story or text of cell, as appropriate
 if (theTextRef.parent.constructor.name == "Cell") {
  return theTextRef.parent.texts[0];
 } else {
  return theTextRef.parentStory;
 }
}
That's it for now. Last updated 10/1/05.

 

Heavy Week

This week has been so intense with production work that I hardly had a moment for scripting let alone posting about scripting here. However, come the weekend, and while I'm still as busy as ever, I feel it's time to reawaken my blogging instincts or the blog will simply die.

So, it happens that I've started the day by posting a useful script on the InDesign Scripting forum at the Adobe U2U forums, so I'll pick it apart some here and improve it.

This page is powered by Blogger. Isn't yours?