• indexa.png
  • indexb.png
(Nov. 2009) — This script has been updated @ Indiscripts.com

Equalizer, pour quoi faire ?

Alors voilà. J’ai devant moi une superbe maquette représentant un organigramme aux vertigineuses ramifications. Il consiste en des dizaines de blocs rectangulaires et ovoïdes uniformément dimensionnés. La loi de l’emmerdement maximum n’étant jamais prise en défaut, il arrive fatalement un moment où je réalise que ça ne tiendra pas dans la surface imprimable : mon plan d’occupation des sols n’avait pas anticipé l’insertion impromptue de dix nouveaux blocs que mon client lunatique collerait bien au centre de la page.

Ceux qui n’ont pas vécu dans leur chair ce supplice chinois ne peuvent pas comprendre. (Mais tout le monde ici l’a vécu, n’est-ce pas ?)

Se manifestent alors plusieurs options, parmi lesquelles un rétrécissement homothétique global, libérant la place nécessaire pour réorganiser ce terrible patchwork. L’avantage de cette méthode est de préserver les rapports entre les objets, leurs espacements, etc. Mais elle ne permet pas de contrôler avec une grande précision la taille future de chaque élément, pris individuellement.

Figure 1 - La palette Transformation ne permet pas de contrôler individuellement les objets sélectionnés

Le besoin peut s’exprimer différemment. Avant de rectifier leur agencement spatial, vous aimeriez réassigner à tous les objets une nouvelle hauteur et/ou largeur parfaitement définie et uniforme. Sans aller chercher des exemples compliqués, ce problème survient typiquement lorsque vous changez en cours de route le « protocole d’alignement ». Par exemple, des blocs composés en ligne, chacun avec une largeur spécifique, passent en colonne. Vous les réalignez comme il faut (palette Alignement), mais comme vous souffrez d’un cartésianisme incurable, vous voudriez aussi les égaliser en largeur.

Le vrai bonheur serait de les sélectionner, tous, et d’entrer dans une palette magique la nouvelle largeur désirée (par exemple, la moyenne arithmétique de toutes les largeurs). Hélas, cette palette magique n’existe pas. Le script Equalizer a été conçu pour la suppléer.

Une itération triviale

Dans son principe, Equalizer ne prélude à aucune difficulté de programmation. La conception de ce script constituerait un excellent exercice de formation pour les débutants en Javascript InDesign. Le pseudo-code réduit à l’extrême se résume grosso modo à une itération :

// Peuso-code minimaliste d'Equalizer
 
newWidth = saisie-utilisateur("Nouvelle largeur");
newHeight = saisie-utilisateur("Nouvelle hauteur");
 
selection = app.activeWindow.selection;
 
// Parcourir les objets selectionnes
pour-chaque-objet-de-selection
      {
      objet.redimensionner(newWidth,newHeight);
      }

Il suffira donc d’intégrer au plan de bataille un module de saisie (une boîte de dialogue personnalisée) et un module central besognant la boucle de redimensionnement.

Observons d’ores et déjà que les objets-cibles de la transformation sont des composants paginés (page items), peu important de savoir s’il s’agit spécialement de blocs-texte, de polygones ou d’autres sous-classes. Le script consentira à redimensionner pratiquement tout ce qui tombera sous la souris en sélection multiple.

La substance du programme est tellement simple qu’on peut s’offrir quelques raffinements. Les explications techniques viendront plus bas, voyons d’abord les raffinements proposées.

Fonctionnalités « avancées »

La figure 2 reproduit la boîte de dialogue d’Equalizer telle qu’elle se déploie dans un environnement Windows :

Figure 2 - Dialogue utilisateur d’Equalizer

On voit ci-dessus les variantes fonctionnelles offertes à l’utilisateur. D’abord, ce dernier peut égaliser les objets en largeur (ou en hauteur) sans toucher à l’autre dimension. Les deux rubriques « Redim (largeur) » et « Redim (hauteur) » sont indépendantes et désactivables si besoin.

Les zones de saisie principales reposent sur des boîtes d’édition spécialisées, de type MeasurementEditbox. Elles autorisent l’affichage dans toute unité choisie et se comportent comme les contrôles applicatifs homologues (incréments clavier, conversion automatique des unités de mesure). Par défaut, Equalizer règle ces zones de saisie en sorte qu’elles « s’expriment » dans l’unité courante de votre document (mm, pt, etc.), mais rien ne vous empêche d’entrer une valeur dans une unité différente.

Je reviens plus loin sur les aspects internes du traitement des unités de mesure.

Pendant que nous y sommes, nous dotons l’interface d’une rubrique « Rotation » qui, le cas échéant, se charge d’égaliser l’angle des objets à une valeur donnée (en degrés). Cette fonctionnalité offre une alternative à la rotation globale, laquelle serait centrée sur le pivot du cadre de sélection. Equalizer fait tourner chaque objet autour de son propre point de pivot, avec un résultat sensiblement différent.

On pourrait multiplier à loisir les extensions de ce script, telles que la mise à l’échelle, l’angle de déformation sur x... Je vous laisse le soin de les coder vous-mêmes.

Autre réglage utile, la spécification du point de référence pour chaque dimension. Celui-ci indique le bord fixe de l’objet : gauche, centre ou droite pour une redim en largeur, haut, milieu, bas pour une redim en hauteur. Cela correspond au carré de localisation situé à gauche de la palette Transformation.

Mais, ce contrôle n’existant pas parmi les « widgets » de dialogue offerts dans la hiérarchie Javascript d’InDesign, j’ai dû me contenter de simples boîtes de liste (objet Dropdown).

Au lancement, le dialogue propose certains choix par défaut : il active les deux rubriques de redimensionnement et inhibe la rubrique « Rotation », de moindre usage. Par ailleurs, il affiche dans les zones Largeur et Hauteur les valeurs moyennes que le script a calculées en scrutant préalablement les objets de la sélection. C’est une option pratique quoique tout à fait arbitraire. Une version améliorée d’Equalizer pourrait proposer à l’utilisateur de commuter à son gré entre taille moyenne, taille minimum et taille maximum (par exemple). Cela impliquerait toutefois un surcroît d’interactivité au sein de la boîte de dialogue, aspect problématique dans les versions actuelles du Javascript InDesign.

L’incompatibilité entre le modèle Javascript de Photoshop et celui d’InDesign m’effare. Beaucoup d’objets qui auraient pu (et dû !) être factorisés à la racine des deux architectures — à commencer par les composants de dialogues — n’existent que dans l’une des deux applications ou se comportent de façon royalement disparate. Ainsi, Photoshop permet de capturer et de programmer des événements au sein des contrôles tandis qu’InDesign fait l’impasse sur ce sujet...

Avant de passer au code, disons quelques mots du concept de dimension(s) sur lequel nous allons plancher. La figure 3 illustre une ambiguïté pernicieuse :

Figure 3 - Faut-il inclure ou exclure l’épaisseur du contour ?

On le voit, une lourde incertitude se fera jour lorsque l’on égalisera des objets dotés d’une bordure. Faut-il alors inclure ou exclure l’épaisseur du contour ? Dans le code sous-jacent, il va falloir démêler tout ça, mais quelle stratégie privilégier ? S’adapter à l’environnement de l’utilisateur est le meilleur cahier des charges. Ainsi, on peut supposer qu’un maquettiste travaillant quotidiennement avec l’option « Inclure l’épaisseur » active, s’attend à lire et donc à spécifier des dimensions comprenant le contour. Et si l’option est décochée, il s’attend à l’inverse. Alors, Equalizer s’adaptera silencieusement à l’option en vigueur dans l’espace de travail. C’est conformément à cette option qu’il interprétera les valeurs saisies et procédera au redimensionnement.

Là encore, on pourrait annexer à l’interface d’Equalizer une case à cocher ménageant à l’utilisateur la possibilité de commuter entre les deux interprétations. Mais il me semble que cela plomberait sans grand bénéfice l’ergonomie du programme.

Codage de l’interface dialoguée

Rien n’est plus rébarbatif que de positionner les contrôles d’une boîte de dialogue. Dans le modèle Javascript d’InDesign, l’opération est relativement simplifiée puisque le programmeur se contente de déclarer un empilement hiérarchique de conteneurs capables de s’autodimensionner.

Rappelons que le composant racine d’une boîte de dialogue est un objet Dialog que l’on injecte dans la collection app.dialogs par la méthode Dialogs::add(). Cette méthode renvoie le dialogue nouvellement créé ; il détient une propriété dialogColumns qui rassemble les conteneurs de plus haut niveau d’une boîte de dialogue, les objets de type DialogColumn. C’est à partir de ces « colonnes de dialogue » que sont créés, par emboîtement, les contrôles ou conteneurs enfants.

Dans notre cas, une seule DialogColumn encapsule l’interface générale :

Figure 4 - Croquis de la boîte de dialogue Equalizer

La croquis préparatoire de la figure 4 permet de cerner immédiatement l’architecture de la boîte :

DialogColumn

      EnablingGroup      (bloc Largeur)

            DialogRow

                  StaticText

            DialogRow

                  MeasurementEditbox

                  Dropdown

      EnablingGroup      (bloc Hauteur)

            DialogRow

                  StaticText

            DialogRow

                  MeasurementEditbox

                  Dropdown

      EnablingGroup      (bloc Angle)

            DialogRow

                  StaticText

            DialogRow

                  AngleEditbox

Le code va donc ressembler à ceci (je laisse en pointillés les paramètres de création) :

with( app.dialogs.add(...) )
      {
      with( dialogColumns.add() )
            {
             with( enablingGroups.add(...) )
                  { // BLOC LARGEUR
                  with( dialogRows.add() )
                        staticTexts.add(...);
                  with( dialogRows.add() )
                        {
                        measurementEditboxes.add(...);
                        dropdowns.add(...);
                        }
                  }
            with( enablingGroups.add(...) )
                  { // BLOC HAUTEUR
                  with( dialogRows.add() )
                        staticTexts.add(...);
                  with( dialogRows.add() )
                        {
                        measurementEditboxes.add(...);
                        dropdowns.add(...);
                        }
                  }
            with( enablingGroups.add(...) )
                  { // BLOC ANGLE
                  with( dialogRows.add() )
                        staticTexts.add(...);
                  with( dialogRows.add() )
                        angleEditboxes.add(...);
                  }
            }

Comme les trois « blocs » présentent un bâti similaire et que je déteste réécrire les mêmes lignes de code, j’ai forgé une méthode DialogColumn::createCheckBlock() chargée de produire chaque bloc EnablingGroup dans la colonne hôte et de renseigner un objet conservant toutes références utiles aux contrôles. Cette méthode reçoit en arguments les paramètres de création (texte, liste, unité de mesure, etc.) de chaque bloc.

Il s’ensuit une simplification notoire de la fonction showDialog(...) déclarant et affichant le dialogue. Fragment de code :

var dlg = app.dialogs.add( {name:dlgTitle, canCancel:true} );
with(dlg)
      {
      with(dialogColumns.add())
            {
            var dlgWidthBlock = createCheckBlock
                  (
                  "Redim (largeur)",      // titre du bloc
                  true,                        // case cochee
                  "Largeur",                  // titre du champ edit
                  units[0],                  // unite de mesure
                  '' + wha.w,                  // valeur par def.
                  ["Gauche","Centre","Droite"] // liste pt ref.
                  );
                        
            var dlgHeightBlock = createCheckBlock
                  (
                  "Redim (hauteur)",      // titre du bloc
                  true,                        // case cochee
                  "Hauteur",                  // titre du champ edit
                  units[1],                  // unite de mesure
                  '' + wha.h,                  // valeur par def.
                  ["Haut","Milieu","Bas"] // liste pt ref.
                  );
            var dlgAngleBlock = createCheckBlock
                  (
                  "Rotation",      // titre du bloc
                  false,            // case decochee
                  "Angle",            // titre du chp edit
                  null,            // flag null -> angleEditbox
                  wha.a,            // valeur par def.
                  null                  // pas de liste
                  );
            }
      }
 
// Affichage du dialogue
if (dlg.show() == true) ...

Je vous renvoie au fichier source d’Equalizer pour étudier plus complètement la gestion de cette boîte de dialogue.

Unités de mesure

Il faut considérer ici deux réseaux d’influences. Primo, l’espace de travail de l’utilisateur possède ses propres unités (Édition › Préférences › Unités et incréments...), lesquelles peuvent d’ailleurs différer en largeur et en hauteur. Il est logique de fournir dans Equalizer une interface qui soit synchronisée avec ces unités préférentielles. Secundo, les contrôles de type MeasurementEditbox de la boîte de dialogue offrent une propriété editUnits qui, si elle permet de spécifier l’unité de travail, est souvent mal comprise.

Sachez en effet que MeasurementEditbox::editUnits indique seulement l’unité d’affichage de la zone d’édition. Cela signifie que si editUnits est réglée à MeasurementUnits.millimeters, a) les valeurs saisies seront affichées en millimètres (avec le suffixe « mm »), b) elles seront entendues comme millimétriques si l’utilisateur n’indique pas d’unités, c) elles seront converties en millimètres si l’utilisateur saisit explicitement une autre unité. Mais elles ne seront pas pour autant restituées en millimètres lorsque vous consulterez la propriété MeasurementEditbox::editValue après la validation du dialogue !

En effet, MeasurementEditbox::editValue est exprimée en points, quelle que soit editUnits. (Attention, la documentation PDF de référence contient une coquille dans cette section !) Il faut donc avoir bien à l’esprit que ce qui est affiché et saisi en façade est une chose, tandis que les valeurs manipulées en aval par le programme en sont une autre.

Le module ExtendScript embarqué avec InDesign CS2 offre au programmeur un objet UnitValue qui simplifie considérablement le casse-tête des conversions entre unités de mesure (cf. pages 66 et suivantes du document PDF intitulé Adobe InDesign CS2 Scripting Guide). Mais, pour l’instant, nous voulons que nos scripts tournent aussi sous InDesign CS1, c’est pouquoi nous nous en tiendrons aux méthodes traditionnelles.

Ceci étant posé, il y a deux façons d’aborder le traitement des valeurs métriques. Soit on se débrouille pour travailler toujours (moyennant les conversions ad hoc) avec des données exprimées dans l’unité de mesure de l’utilisateur. Soit on se débrouille pour présenter (afficher) les valeurs dans l’unité-utilisateur tout en bossant systématiquement, en interne, avec l’unité par défaut (le point).

Pour tout dire, le fonctionnement du contrôle MeasurementEditbox m’a conduit a préféré une voie intermédiaire ! Comme la zone d’édition est facile à configurer pour un affichage dans l’unité préférentielle, j’adopte cette dernière jusqu’à l’ouverture du dialogue. Les calculs préliminaires (valeurs moyennes, etc.) sont donc exécutés sans modifier l’unité courante. Puis, comme MeasurementEditbox rapatrie des données exprimées en points, le programme bascule soudain dans cette unité de travail. Je ne sais pas si c’est la meilleure solution, mais elle permet d’évacuer définitivement tout problème de conversion.

Les redimensionnements (geometricBounds, etc.) s’effectuant par défaut dans l’unité courante de l’utilisateur, on aurait pu aussi surcharger le code en spécifiant « pts » un peu partout, lors des affectations. Mais je ne suis pas trop pour la surcharge de code...

Implémentation radicale:
      — mémoriser au départ les unités-utilisateurs ;
      — modifier les préférences pour forcer l’unité points ;
      — exécuter le module principal ;
      — restaurer les unités-utilisateurs.

C’est l’objet des deux méthodes Document::getUnits() et Document::setUnits() que voici :

Document.prototype.getUnits = function()
//----------------------------------------------------------
// recupere dans un tableau les unites preferentielles
// (pour pouvoir les restaurer a la fin)
{
return(Array(
      this.viewPreferences.horizontalMeasurementUnits,
      this.viewPreferences.verticalMeasurementUnits));
}
 
Document.prototype.setUnitsTo = function(newUnits)
//----------------------------------------------------------
// units est soit une valeur simple (horiz=vert),
// soit un array(horizUnits, vertUnits)
{
var arrUnits = (newUnits.length)? newUnits : new Array(newUnits,newUnits);
this.viewPreferences.horizontalMeasurementUnits = arrUnits[0];
this.viewPreferences.verticalMeasurementUnits = arrUnits[1];
}

Avec cette artillerie, le module principal peut tranquillement travailler en points après la fermeture du dialogue :

units = app.activeDocument.getUnits();
app.activeDocument.setUnitsTo(MeasurementUnits.points);
 
// ici, on travaille en points!
// etc., etc.
 
app.activeDocument.setUnitsTo(units);
Notez que la méthode Document::getUnits possède un autre emploi : nous l’exploitons aussi lorsque nous transmettons les unités d’affichage à l’interface dialoguée (voir MeasurementEditbox::editUnits supra).

Dimensions et redimensionnements

Je ne vais pas trop m’attarder sur les modules centraux du script, Array::getAverageWHA() et Array::Equalizer(), dont vous comprendrez facilement la logique. La classe support est un Array tout simplement parce que la sélection initialement effectuée par l’utilisateur (app.activeWindow.selection) est de ce type. Le tableau en question va être préalablement cloné via Array::clone() afin de nous laisser les mains libres pour neutraliser certains objets qui pourraient provoquer des erreurs (notamment les objets verrouillés).

Appelée en premier, la méthode Array::getAverageWHA() a pour mission tout à la fois de scruter la sélection, de tamiser et de faire le compte des composants susceptibles d’être transformés, enfin de calculer les dimensions moyennes qui seront transmises à la boîte de dialogue en guise de nouvelles largeurs et hauteurs par défaut. Le processus s’appuie sur une structure d’objet siglée « WHA » qui collecte essentiellement les propriétés Width (largeur), Height (Hauteur), Angle. Cette structure de données rayonne un peu partout dans le programme, elle permet de transmettre les informations de façon compacte.

Paradoxalement, l’aspect le plus intéressant du script se cache plutôt dans les méthodes « accessoires », celles qui calculent et manipulent les dimensions. En effet, les objets InDesign ne fournissent pas d’interface simple pour récupérer ou affecter des dimensions. La classe PageItem n’expose pas de propriété width ou height, ni de méthode setWidth ou setHeight.

Bien qu’on puisse exploiter la méthode PageItem::resize() à des fins de redimensionnement absolu, celle-ci a pour fonction présumée de modifier les échelles horizontale et/ou verticale de l’objet considéré, avec une armada de paramètres booléens assez pénibles.

On peut expliquer ces apparentes lacunes par le fait que, justement, la notion de dimension (largeur, hauteur) souffre de l’ambivalence que nous avons signalée plus haut, selon que l’on considère ou ignore l’épaisseur des contours. Comme nous avons décidé de nous adapter à l’espace de travail de l’utilisateur, nous nous intéresserons ici à la propriété :
app.transformPreferences.dimensionsIncludeStrokeWeight
indiquant quelle préférence adopter. Pour éviter de faire exploser votre écran, j’abrégerai dISW cet indicateur booléen.

Les objets dérivant de PageItem offrent alors deux propriétés bien distinctes : PageItem::visibleBounds et PageItem::geometricBounds. Dans les deux cas, ce sont des tableaux de quatre coordonnées métriques disposées dans le sens top, left, bottom, right (autre exemple d’incompatibilité bizarroïde avec le Javascript de Photoshop). On pourra dès lors calculer une hauteur par une soustraction de la forme bounds[2] – bounds[0] ; et une largeur au moyen de bounds[3] – bounds[1].

La propriété visibleBounds donne les coordonnées rectangulaires du cadre virtuel dans lequel s’inscrit l’objet avec son contour. Cela fournit donc les dimensions totales du composant, ce qui correspond à dISW == true. À l’inverse, geometricBounds décrit les coordonnées intérieures du composant en faisant abstraction de l’épaisseur éventuelle de son contour (dISW == false).

Conclusion : nous devons trouver un moyen ingénieux (?) d’exécuter tous les calculs soit en visibleBounds, soit en geometricBounds, en fonction du paramètre initialement connu dISW. Une approche bête et méchante se satisferait de multiplier des if-else à tout va, partout où nous calculons et recalculons des dimensions. Mais, la plupart de ces opérations étant encapsulées dans une boucle qui parcourt tous les composants sélectionnés, la bêtise et la méchanceté de l’approche deviendrait carrément pénalisante sur le plan des performances.

Notre malice congénitale nous suggère de faire une fois pour toutes le test fatidique, afin de créer dynamiquement les méthodes adéquates :

if (app.transformPreferences.dimensionsIncludeStrokeWeight)
      {
      // Appliquer le systeme de coords visibleBounds
 
      Object.prototype.getBounds = function()
            {return(this.visibleBounds);}
 
      Object.prototype.setBounds = function(b)
            {this.visibleBounds=b;}
      }
else
      {
      // Appliquer le systeme de coords geometricBounds
 
      Object.prototype.getBounds = function()
            {return(this.geometricBounds);}
 
      Object.prototype.setBounds = function(b)
            {this.geometricBounds=b;}
      }

Vous admettrez avec moi que les langages de scripts, si médiocres soient-ils à d’autres égards, permettent de faire des choses bien savoureuses dans le domaine de l’écriture dynamique de code.

Dans le même ordre d’idée, vous trouverez une mise en œuvre plaisante de l’instruction eval() au sein de la méthode Array::Equalizer(). Elle permet de « brancher » dynamiquement les opérations choisies par l’utilisateur (largeur et/ou hauteur et/ou angle) sans avoir à injecter de tests répétitifs à l’intérieur de la boucle de traitement. Une technique similaire a été exposée dans le script MagicFit.

J’arrive à la fin de ce carnet de notes sans avoir mentionné la fonction la plus « arithmétique » du script, celle qui se charge du redimensionnement en tenant compte du point de référence (gauche/centre/droite ou haut/milieu/bas). On procède par simple réaffectation du tableau de coordonnées de l’objet — grâce à notre méthode générale setBounds(). J’ai conçu une procédure Object::resizeDim qui travaille aussi bien en largeur qu’en hauteur selon les arguments fournis. Elle ne fait qu’abstraire (et centraliser) le processus fondamental de redimensionnement d’un segment dont on connaît les extrémités a et b.

Le point de référence est passé sous la forme d’un code numérique : 0 pour redimensionner le segment en laissant l’extrémité a fixe ; 1 pour redimensionner le segment de part et d’autre en laissant le milieu fixe ; 2 pour redimensionner le segment en laissant l’extrémité b fixe :

Figure 5 - Redimensionner un segment

La figure 5 donne un avant-goût des petits calculs rudimentaires effectués dans la méthode Object::resizeDim().

Ladite méthode réserve au lecteur attentif une ultime subtilité : avant d’être redimensionné, l’objet subit une rotation temporaire à angle nul. Il faut en effet avoir à l’esprit que les coordonnées (bounds) ne sont pertinentes pour le calcul des dimensions que si l’objet est « à plat ». On pourrait assurément se lancer dans une débauche d’opérations trigonométrique mettant à contribution le sinus et le cosinus de l’angle pour « redresser » les coordonnées, mais je doute que ce soit beaucoup plus efficace que la méthode proposée ici. N’hésitez pas toutefois à m’envoyer vos trouvailles si vous pensez tenir une solution plus élégante...

Script final

Les instructions d’installation d’Equalizer sont données en entête dans le script lui-même, Equalizer.js, inclus dans ce fichier zip téléchargeable.

Notez que le script fournit un « commutateur linguistique » pour ajuster l’interface en langue anglaise. Il suffit de passer à 0 la variable LANG déclarée en début de code (ligne 33).

Ce programme est bien sûr en libre usage, mais n’oubliez pas d’indiquer la source et l’auteur si vous le mentionnez ou le réexploitez. Merci de signaler tout bug éventuel à l’adresse du rédacteur en chef.

(Nov. 2009) — This script has been updated @ Indiscripts.com

BlogNot! est une émission produite par Marc Autret depuis 2004, à consommer de préférence en cuves acclimatées aux spécifications XHTML et CSS.
Pour harceler la rédaction : marcautret(at)free(point)fr