To add it, unzip the file and put the file "mirror annotations tool.js" into the Javascripts folder either in the application folder, or
C:\Users\[your user name]\AppData\Roaming\Tracker Software\PDFXEditor\3.0\Javascripts\
(If there's no Javascripts folder, make one)
The script also works if you copy and paste the source below into the JavaScript console and run it from there.
Abilities:
- Mirrors Lines, Polylines, Polygons, Squares, Rectangles, Circles, and Pencil annotations
- Repositions & rotates Text, Callouts, Stamps, Dimensions so that they match the rest of the mirrored markup
- Can select which axis of the selected annotations to mirror about, and whether mirroring horizontally and/or vertically
- Can mirror a copy of the annotations instead of mirroring the selected annotations; however, I prefer to copy and paste first, and then mirror that because then the mirrored annotations are selected already if you need to move them to the correct location.
- Cannot mirror stamps, pictures or other items that are an object that you can't edit in PDFX-Change. If you absolutely must mirror these items, you generally can by first flattening them, and then you can mirror them with the edit tool and the right click menu.
- Doesn't mirror form elements.
- Does not mirror text, but it repositions it and changes the left/right alignment for horizontal mirrors. Fixed as of build 370: It currently can't get the left or right alignment of the text correct (bug fix promised in version 10 of pdfx-change viewtopic.php?p=166841#p166841 )
- Bullet or numbered lists disappear when mirrored (they become just indented rows of text). I suspect this property is not available to javascript.
- Fixed as of build 367.0 Pencil tool annotations get completely messed up because of a bug in PDFX-Change.
- I don't think it's possible to change the selection within a javascript, so I can't make it select the new annotations if doing a mirror copy.
- Fixed as of build 370: If the view is rotated, "horizontal" and "vertical" in the dialog won't be correct - as of build 370 viewState.pageViewRotation gives view rotation so this is fixed.
- Adds a long list of items to the undo, (one for each item flipped) so it takes quite a few "undo"s to undo the flip.
- Add a comment if you see something else, I'll try to fix it.
Code: Select all
// tool data and custom icon
var iconSet_mirrorTool = { mirror:
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFC0C0C00000000000000000FF91C8F6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFC9C9C9FFB5B5B50000000000000000FF7BBFF6FFA1D0F50000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFB6B6B6FFB5B5B50000000000000000FF7BBFF7FF7DBFF60000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFB1B1B1FFB5B5B50000000000000000FF7BBFF7FF70BBF7FFBAD8F5000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFB4B4B4FFB1B1B1FFB5B5B50000000000000000FF7BBFF6FF71BBF7FF77BDF70000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFBBBBBBFFB1B1B1FFB1B1B1FFB5B5B50000000000000000FF7BBFF7FF71BBF7FF71BBF7FF87C4F600000000000000000000000000000000000000000000000000000000000000000000000000000000FFAFAFAFFFB1B1B1FFB1B1B1FFB5B5B50000000000000000FF7BBFF7FF71BBF7FF71BBF7FF6FB9F6000000000000000000000000000000000000000000000000000000000000000000000000FFB6B6B6FF989898FFAAAAAAFFB1B1B1FFB6B6B60000000000000000FF7BBFF7FF71BBF7FF63B4F6FF42A5F5FF84BFF50000000000000000000000000000000000000000000000000000000000000000FF999999FF989898FF999999FFAAAAAAFFB6B6B60000000000000000FF7BBFF7FF63B4F6FF45A6F5FF42A5F5FF43A5F500000000000000000000000000000000000000000000000000000000FF9B9B9BFF989898FF989898FF989898FF999999FFAEAEAE0000000000000000FF6FB8F6FF43A6F5FF42A5F5FF42A5F5FF42A5F5FF7AB7F50000000000000000000000000000000000000000FFCCCCCCFF9A9A9AFF989898FF989898FF989898FF989898FFA2A2A20000000000000000FF58ADF5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF45A6F5FFAED2F500000000000000000000000000000000FFA2A2A2FF989898FF989898FF989898FF989898FF989898FFA1A1A10000000000000000FF57ADF5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF58AEF4000000000000000000000000FFB2B2B2FF989898FF989898FF989898FF989898FF989898FF989898FFA1A1A10000000000000000FF56ACF5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF42A5F5FF7CBCF50000000000000000FFA4A4A4FF989898FF989898FF989898FF989898FF989898FF989898FFA1A1A10000000000000000FF56ADF5FF41A5F5FF41A5F5FF41A5F5FF41A5F5FF41A5F5FF41A5F5FF5FAFF500000000FFCBCBCBFFAFAFAFFFAFAFAFFFAFAFAFFFB0B0B0FFAFAFAFFFAFAFAFFFB0B0B0FFB5B5B50000000000000000FF7EBEF5FF71B9F5FF71B9F5FF71B9F4FF71B9F5FF70B9F5FF71B9F5FF71B9F6FFA7D0F500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
iconStream:function(val){var data=this[val];
return {count:0, width:20, height:20, read:function(nBytes){return data.slice(this.count,this.count+=2*nBytes)}}}
};
// This adds a button to the Add-on Tools toolbar
app.addToolButton( {
cName: "mirrorAnnotationsTool",
cLabel: "Mirror",
oIcon: iconSet_mirrorTool.iconStream("mirror"),
cTooltext: "Flip/Mirror Comments…",
cEnable: "event.rc = (this.selectedAnnots && this.selectedAnnots.length)",
cExec: "mirrorAnnotationsTool(this)" }
);
// This adds a menu item to the edit menu
app.addMenuItem( {
cName: "mirrorAnnotations",
cUser: "Flip/Mirror Comments…",
oIcon: iconSet_mirrorTool.iconStream("mirror"),
cParent: "Edit",
nPos: 13,
cEnable: "event.rc = (this.selectedAnnots && this.selectedAnnots.length)",
cExec: "mirrorAnnotationsTool(this)" }
);
/*
script to flip (mirror) selected annotations
v 2.3 date Jul 04, 2023
only tested on PDF-Xchange Editor v10.0
* history:
v2.0 Mar 14 2023 initial release
v2.1 May 31 2023 clean up code, reduce number of undos when changing annotations, let new annotation name be auto-generated, add option to not try keeping dimension text upright
v2.2 Jun 23 2023 mirror the alignment of rich text when it flips horizontally, fix access to global variable, use corner points instead of bounds so that rotated rectangle edges are correct.
v2.3 add .rotate to properties duplicated so that duplicated text doesn't get automatically rotated to view, try to improve how text is mirrored
*/
function mirrorAnnotationsTool(t) {
const mUtil = {
getPgRotation: function(t,page) {
// try to figure out up or down based on page rotation
let pgRotation = t.getPageRotation( {nPage:page} );
// try to include the view rotation
if (t.viewState.pageViewRotation) {
pgRotation += t.viewState.pageViewRotation;
}
if (pgRotation < 0) {
pgRotation = 360 + pgRotation;
}
while (pgRotation >= 360) {
pgRotation -= 360;
}
return pgRotation;
},
doFlip: function (selAnnots) {
// get bounding box of annotations
let aRec = this.getBounds(selAnnots);
// get flip point of annotations
let aCtr = this.getAxisPt(aRec, FLIPAXIS);
// flip the annotations
this.flipAnns(aCtr,selAnnots);
},
getAxisPt: function ( bBox, axis) {
// axis must be one of the keys for the array below. There's no error checking.
// The bBox array consists of [x min, ymin, x max, y max]
let b = [ bBox.slice(0,2),bBox.slice(2,4) ];
// Array of which index to use for each position [x1,x2],[y1,y2]
let axPick = {
"AxTL": [[0,0],[1,1]],
"AxTC": [[0,1],[1,1]],
"AxTR": [[1,1],[1,1]],
"AxML": [[0,0],[0,1]],
"AxMC": [[0,1],[0,1]],
"AxMR": [[1,1],[0,1]],
"AxBL": [[0,0],[0,0]],
"AxBC": [[0,1],[0,0]],
"AxBR": [[1,1],[0,0]]
};
// use the flip axis as the key to get array
// no error checking here
let axTr = axPick[ axis ];
// calculate the axis
// [ (x1+x2)/2, (y1+y2)/2 ]
let axCtr = [ (+b[axTr[0][0]][0]+b[axTr[0][1]][0])/2 , (+b[axTr[1][0]][1]+b[axTr[1][1]][1])/2 ];
return axCtr;
},
// rotate a point
rotatePoint (point, degrees, ctr) {
let rads = degrees * Math.PI / 180;
let x = point[0] || 0; // ||0 in case undefined
let y = point[1] || 0;
let x0 = ctr[0];
let y0 = ctr[1];
let newx = (x - x0) * Math.cos(rads) - (y - y0) * Math.sin(rads) + x0;
let newy = (x - x0) * Math.sin(rads) + (y - y0) * Math.cos(rads) + y0;
return [newx, newy];
},
// rotate a shape, coords are in form [x1,y1,x2,y2,...] or [[x1,y1],[x2,y2],...]
rotateRect (coords, degrees, ctr) {
if (degrees) {
if (ctr === undefined) {
// assume centerpoint
ctr = [0,0];
}
let rotRec = [];
let i=0;
// step through coordinates
while ( i<coords.length ) {
if ( Array.isArray(coords[i]) ) {
// keep as a pair
rotRec = rotRec.concat( [this.rotateRect(coords[i], degrees, ctr)] );
i++;
} else { // assumes that i+1 contains a number
rotRec = rotRec.concat( this.rotatePoint( [coords[i],coords[i+1]], degrees, ctr ));
i+=2;
}
}
return rotRec;
} else { // didn't need to rotate
return coords;
}
},
// returns array containing [x min, ymin, x max, y max] of all the annotations
getBounds: function (anns) {
let theBounds = [];
let aRec = [];
let axPt = [];
//var m = new Matrix2D;
for (let tAnn in anns) {
// loop through all selected annotations
// outer bounds of this annotation in default user space
// this is the outside of the border
aRec = anns[tAnn].rect;
// rotation is about the center of .rect
axPt = this.getAxisPt(aRec,"AxMC");
// bounding box is approximate for polyline, polygon, ink so grab their actual coordinates
switch(anns[tAnn].type) {
case "PolyLine":
case "Polygon":
// to do this correctly, need to find the center of all the points,
// then rotate all points about center, then get the bounds.
aRec = anns[tAnn].vertices;
break;
case "Ink":
// using all points in the gestures
aRec = anns[tAnn].gestures;
break;
default:
// need corner points - these are at the outside edge of the line
aRec = this.getCorners( aRec );
// need to include coordinates from leader arrow
if (anns[tAnn].callout && anns[tAnn].callout.length > 0) {
// include the arrow coordinates in the bounds
aRec = aRec.concat( anns[tAnn].callout );
}
}
// the coordinates need to be rotated by .rotation and flattened
aRec = this.rotateRect( aRec, anns[tAnn].rotation, axPt ).flat(Infinity);
// aRec is [x1,y1,x2,y2,...]
for (let i = 0; i<(aRec.length-1) ; i+=2) {
// assign min x value
if ( typeof theBounds[0] == "undefined" || aRec[i] < theBounds[0] ) {
theBounds[0]=aRec[i];
}
// assign max x value
if ( typeof theBounds[2] == "undefined" || aRec[i] > theBounds[2] ) {
theBounds[2]=aRec[i];
}
// assign min y value
if ( typeof theBounds[1] == "undefined" || aRec[i+1] < theBounds[1] ) {
theBounds[1]=aRec[i+1];
}
// assign max y value
if ( typeof theBounds[3] == "undefined" || aRec[i+1] > theBounds[3] ) {
theBounds[3]=aRec[i+1];
}
}
}
return theBounds;
},
// get the rotated corner points of the bounds [x1,y1,x2,y2]
getCorners( bounds, rotation=0, center=[0,0], linethk=0 ) {
const seq = [[0,1], [2,1], [2,3], [0,3]];
let corners = seq.map( ipt => [ bounds[ipt[0]], bounds[ipt[1]] ] );
// move corner by line thickness
if ( 0 != linethk )
corners = this.moveCoords( corners, center, linethk );
// return the rotated coordinates of the corners
return this.rotateRect( corners, rotation, center );
},
// move coordinates by half line thickness
moveCoords( coords, center, linethk ) {
return coords.map( cpt => cpt.map( (e,i) => (e + Math.sign( center[i]-e )*linethk/2) ));
},
// flip a line
flipLine: function ( flipPt, tPoints) {
return tPoints.map( p => this.moveLineArray( flipPt, p ));
},
// move points in line array
moveLineArray ( flipPt, lPoints) {
// step through array in form [x1,y1,x2,y2,...]
let mPts = [];
for (let j=0;j<lPoints.length;j+=2) {
// flip in x direction
mPts[j] = FLIPDIR.horiz ? (flipPt[0]*2-lPoints[j]) : lPoints[j];
// flip in y direction
mPts[j+1] = FLIPDIR.vert ? (flipPt[1]*2-lPoints[j+1]) : lPoints[j+1];
}
return mPts;
},
doRotation: function (r,a) {
if ( undefined === a ) a = 0;
if (FLIPDIR.vert) r = a - r;
if (FLIPDIR.horiz) r = 2*a - r;
return r;
},
// try to switch text alignment
flipTextAlign: function (tObj) {
let changes = {};
let align = tObj.alignment;
if ( FLIPDIR.horiz ) {
// just change the alignment
if ( align != 1)
changes.alignment = 2 - align;
if (tObj.richContents.length) {
let mirRC = [];
let changed = false;
for ( let rc of tObj.richContents ) {
// I'm changing the actual rich contents object, maybe bad?
if ( "center" != rc.alignment ) {
changed = true;
rc.alignment = ("right" == rc.alignment ? "left" : "right");
}
mirRC.push( rc );
}
if (changed)
changes.richContents = mirRC;
}
}
return changes;
},
// return properties to duplicate an annotation
duplicateAnn: function (an,revs,notIncl) {
// exclude elements from a "not included" array.
if (undefined == notIncl) notIncl = ["author", "name", "doc", "rect"];
let aProps = an.getProps();
for ( let p of notIncl ) {
if ( undefined !== aProps[p] )
delete aProps[p];
}
// workaround - bug in pdfxchange that overwrites contents with empty richContents
if ( aProps.richContents && !aProps.richContents.length ) {
delete aProps.richContents;
}
// plug in the revised values (if any)
for (let i in revs) {
aProps[i] = revs[i];
}
return aProps;
},
// flip the annotations
flipAnns: function (flipPt, anns) {
let tPoints = [];
for (let tAnn in anns) {
// work on properties, not the annotation itself (reduce number of undos)
let cAnn = anns[tAnn];
let revProps={};
//console.println(anns[tAnn].type);
switch(cAnn.type) {
case "Line":
tPoints = this.flipLine(flipPt,cAnn.points);
// for dimension lines, need to change leaderlength
if (cAnn.leaderLength && FLIPDIR.vert) {
revProps.leaderLength = -cAnn.leaderLength;
}
// also, for dimension lines, the point order affects which way up the leader text is
// assumption here is that for horizontal flip, we don't want the text upside down.
if ("LineDimension"==cAnn.intent && FLIPDIR.horiz) {
if (DIMTXDIR) {
tPoints = tPoints.slice(1,2).concat(tPoints.slice(0,1));
} else {
revProps.leaderLength = -revProps.leaderLength || -cAnn.leaderLength; // in case it was already flipped above
}
}
revProps.points = tPoints;
// lines don't need to be rotated
break;
case "PolyLine":
case "Polygon":
revProps.vertices = this.flipLine(flipPt,cAnn.vertices);
revProps.rotation = this.doRotation( cAnn.rotation );
break;
case "FreeText":
// move callout arrow
if (cAnn.callout && cAnn.callout.length > 0) {
revProps.callout = this.moveLineArray( flipPt, cAnn.callout );
}
// try to set alignment
if ( TXALIGN )
Object.assign( revProps, this.flipTextAlign(cAnn) );
// no break - need to also move center and rotate, same as other items
case "Text":
// text rotation - prevent it from getting aligned with view rotation
// revProps.rotate = cAnn.rotate;
// no break
case "Circle":
case "Stamp":
case "Square":
case "Squiggly":
// move center and rotate
revProps.rect = this.moveLineArray( flipPt, cAnn.rect );
revProps.rotation = this.doRotation( cAnn.rotation, 180 );
break;
case "Ink":
tPoints = cAnn.gestures.map( g => this.flipLine(flipPt,g) );
revProps.gestures = tPoints;
revProps.rotation = this.doRotation( cAnn.rotation );
break;
}
// duplicate the annotation
if (FLIPCOPY) {
// add current user as author
revProps.author = identity.name;
t.addAnnot( this.duplicateAnn( cAnn, revProps) );
} else {
// mirror original annotation with single undo
anns[tAnn].setProps( revProps );
}
}
},
};
// dialog box to ask for mirror direction
const flipDialog = {
pageRotation: 0,
initialize: function (dialog) {
let dir = this.getUp(FLIPDIR.vert,FLIPDIR.horiz);
let defLoad = {
"vDir": dir.v,
"hDir": dir.h,
"copy": FLIPCOPY,
"dTxt": DIMTXDIR,
"mTxt": TXALIGN
};
defLoad[this.getAxDir(FLIPAXIS,false)] = true;
dialog.load(defLoad);
},
commit:function (dialog) { // called when OK pressed
let results = dialog.store();
// try to figure out which way is up
let dir = this.getUp(results["vDir"],results["hDir"]);
FLIPDIR.vert = dir.v;
FLIPDIR.horiz = dir.h;
// get axis
const axes = ["AxTL","AxTC","AxTR","AxML","AxMC","AxMR","AxBL","AxBC","AxBR"];
for (let i in axes) {
if (results[axes[i]]) {
FLIPAXIS = this.getAxDir(axes[i],true); // need to rotate the saved one based on current page rotation
}
}
// Mirror and copy
FLIPCOPY = results["copy"];
// Dimtext
DIMTXDIR = results["dTxt"];
// Text alignment
TXALIGN = results["mTxt"];
// return "ok"
},
// try to figure out up or down based on document rotation
getUp: function (v, h) {
let pageDir = {v:v,h:h};
if( 0 == (this.pageRotation-90)%180 ) {
pageDir = {v:h,h:v};
}
return pageDir;
},
// try to figure out where the axis is based on document rotation
// i'm sure there's a better way to do this with matrices
getAxDir: function (FLIPAXIS,reverse) {
const axesR = ["AxTL","AxTC","AxTR","AxMR","AxBR","AxBC","AxBL","AxML"]; // no AxMC
let retAxis = axesR.indexOf(FLIPAXIS);
if (retAxis > -1) {
switch(this.pageRotation) {
case 90:
retAxis += 2 * (reverse ? -1 : 1);
break;
case 180:
retAxis += 4 * (reverse ? -1 : 1);
break;
case 270:
retAxis += 6 * (reverse ? -1 : 1);
}
retAxis += (retAxis > 7) ? -8 : 0 ;
retAxis += (retAxis < 0) ? 8 : 0 ;
return axesR[retAxis];
}else{ // either there was no axis saved, or it's the middle center
return "AxMC";
}
},
description: {
name: "Mirror Annotations", // Dialog box title
align_children: "align_left",
width: 350,
//height: 200,
elements:
[
{
type: "cluster",
name: "Mirror Direction",
align_children: "align_left",
elements:
[
{
type: "check_box",
name: "Mirror Horizontally",
item_id: "hDir"
},
{
type: "check_box",
name: "Mirror Vertically",
item_id: "vDir"
},
]
},
{ // mirror location radio button grid
type: "cluster",
name: "Mirror Location",
align_children: "align_left",
elements:
[ {
type: "view",
align_children: "align_row",
elements:
[ {
type: "radio",
groupid: "Axes",
item_id: "AxTL"
},{
type: "radio",
groupid: "Axes",
item_id: "AxTC"
},{
type: "radio",
groupid: "Axes",
item_id: "AxTR"
},
]
},{
type: "view",
align_children: "align_row",
elements:
[ {
type: "radio",
groupid: "Axes",
item_id: "AxML"
},{
type: "radio",
groupid: "Axes",
item_id: "AxMC"
},{
type: "radio",
groupid: "Axes",
item_id: "AxMR"
},
]
},{
type: "view",
align_children: "align_row",
elements:
[ {
type: "radio",
groupid: "Axes",
item_id: "AxBL"
},{
type: "radio",
groupid: "Axes",
item_id: "AxBC"
},{
type: "radio",
groupid: "Axes",
item_id: "AxBR"
},
]
}
]
},
{ // copy check box
type: "check_box",
name: "Copy and Mirror Selection",
item_id: "copy"
},
{ // maintain text alignment
type: "check_box",
name: "Mirror right/left alignment of text",
item_id: "mTxt"
},
{ // maintain dimension text
type: "check_box",
name: "Maintain dimension text direction",
item_id: "dTxt"
},
{
alignment: "align_right",
type: "ok_cancel",
ok_name: "Ok",
cancel_name: "Cancel"
}
]
}
}
// get all selected annotations
let anns = t.selectedAnnots;
let fA;
if (anns && anns.length>0){
// Load saved variables
({FLIPDIR, FLIPAXIS, FLIPCOPY, DIMTXDIR, TXALIGN} = MIRRORTOOL_GLOBAL_VALS.get() || {FLIPDIR:FLIPDIR, FLIPAXIS:FLIPAXIS, FLIPCOPY:FLIPCOPY, DIMTXDIR:DIMTXDIR, TXALIGN:TXALIGN});
// set pageRotation in dialog
flipDialog.pageRotation = mUtil.getPgRotation(t,anns[0].page);
fA = app.execDialog(flipDialog);
if ("ok" == fA) {
mUtil.doFlip(anns);
// save globals
MIRRORTOOL_GLOBAL_VALS.set({FLIPDIR:FLIPDIR, FLIPAXIS:FLIPAXIS, FLIPCOPY:FLIPCOPY, DIMTXDIR:DIMTXDIR, TXALIGN:TXALIGN});
}
} else {
fA = "No Annotations selected.";
}
return fA;
}
// to save dialog settings in case global isn't working
var FLIPDIR = { horiz: true, vert: false }, FLIPAXIS, FLIPCOPY, DIMTXDIR = true, TXALIGN = false;
// needs function to access global variables
const MIRRORTOOL_GLOBAL_VALS = new class {
constructor(name) {
this.get = app.trustedFunction(() => {
app.beginPriv();
try{
if ( global[name] )
return JSON.parse( global[name] );
}catch{
console.println("Error accessing global variable '"+name+"'. Try:\n either uncheck 'Enable global object security policy',\n or (preferably) edit the file 'GlobData': Delete the line that begins with /D after the line \n/"+name+" <<");
}});
this.set = app.trustedFunction( value => {
app.beginPriv();
try{
global[name] = JSON.stringify( value );
global.setPersistent( name, true);
}catch{}});
}
}("MirrorToolGlobalVals")