Bulk search and replace images in Google Docs

Let's say you have a bunch of Google Documents with various images in them - say a logo or photographs - anything, that you'd like to replace with different images. Well, one way would be to go in there and do it all manually. If the images were differently sized you might need to do some adjustments, but eventually you'd get it done. 

But you can do it systematically with Apps Script. They key to it is to be able to recognize an image in a document so you can replace it with another. 


The example

Let's start with a document with some pictures in it. You might have a number of documents where you are using the pictures below - and you need to replace the images when there's a change.

Let's say, this.
and then finally,

Here's how it works.

You create a template document which contains all the images you want to swap - the before and after.

This template can contain anything you like, in addition to a table which has exactly 2 images on each row- the first image is the image to look for, and the second image is what to replace it with. The table can have extra columns if you like, as well as any other stuff. It's only table rows with exactly two images that are of interest. 

Using the template above, any instances of donald and hillary found in the documents being processed will be replaced by ted and bernie. Obama pictures will be left intact for now, since he has no replacement picture in the template. 

In a later run, I'd add the picture of bernie next to obama so it could replace his.

The parameters

Here's how to control how the processing is done.
  var params = {
    template:'1rje8nZ-flJTFlBJO3XYm9mzQ-G0mHEkGlCVhEAXydwE',   // this is the document that contains the image swappong template
    documents:[
      '1oI9Pefe5yjkrJKsKw1Mx6ITU_ONdTzZ-PAz3I_d48Qk'          // this is a list of documents to apply image swapping to
    ],
    scaling:SwapperEnums.SCALE_ORIGINAL_HEIGHT,               // change this as required.                                            
    attributes:SwapperEnums.KEEP_ORIGINAL,
    sections:['Header', 'Footer', 'Body']                     // where to look for images .. normally all 3
  }

You specify the id of the template, plus a list of documents to be processed as well as sections of the documents that should be searched for images to change. 

Attributes of the document such as any links can be inherited from either the original image, or from the attributes of the replacement image. Similarly the scaling can be inherited, or calculated. Here's the list of values you can specify. The proper selection of these can be used to keep the format of the document being processed whilst still retaining the proper proportions of the replacement image.
var SwapperEnums =  {
  INHERIT_TEMPLATE:'INHERIT_TEMPLATE',                         // inherits the attributes/scaling from the template
  KEEP_ORIGINAL:'KEEP_ORIGINAL',                               // keeps the attributes/scaling of the original
  SCALE_ORIGINAL_HEIGHT:'SCALE_ORIGINAL_HEIGHT',               // keeps the height of the original image and scales the width 
  SCALE_ORIGINAL_WIDTH:'SCALE_ORIGINAL_WIDTH'                  // keeps the width of the original image and scales the height 
};

Automatically generating a list of documents

If you don't want to specify a list of ids, you can use Creating a pile of files list from Google Drive to generate that list for you.

For example, here's how to find all the documents in the folders and subfolders of a Drive path, and use that to add document ids to the file path. 

// make sure you have this comment in the code to provoke drive authorization

// DriveApp.getFiles()

 var piles = cUseful.DriveUtils

 .setService(DriveApp)

 .getPileOfFiles (

    "/books/youtube", "application/vnd.google-apps.document", true

  );

  // just interested in the ids.

  params.documents = piles.map (function (d) {

    return d.file.getId();

  }); 

The report

You'll get a report at the end showing which documents were looked at and how many images were there. If you've used the method above to automatically create the list of files, you can enrich that result with details about the files too.

Here's and example of how you might enrich the result if you would like a report of what happened
  // do the image swapping
  var result = doImageSwapping ( params );
  
  // if used the piles method, we can enrich the results
  Logger.log (JSON.stringify(result.map (function(d,i) {
    if (piles) {
      d.path = piles[i].path;
      d.folderId = piles[i].folder.getId()
    }
    return d;
  })));

and the report would look something like this. You'll see from below that it's perfectly okay to have your template in the same pace as your files. It will never replace the template contents if it is provided as a file to process.
[{
"id": "1oI9Pefe5yjkrJKsKw1Mx6ITU_ONdTzZ-PAz3I_d48Qk",
"name": "testswap",
"imagesDetected": 5,
"imagesReplaced": 3,
"path": "/books/youtube/testswap",
"folderId": "0B92ExLh4POiZempkRXF0VGZ1RDA"
}, {
"id": "1rje8nZ-flJTFlBJO3XYm9mzQ-G0mHEkGlCVhEAXydwE",
"name": "imageswap",
"imagesDetected": 0,
"imagesReplaced": 0,
"skipped": "Document was same as template - skipped",
"path": "/books/youtube/imageswap",
"folderId": "0B92ExLh4POiZempkRXF0VGZ1RDA"
}, { ... etc..

Video example - changing logo 

+Richard Anderson posted this video showing how he used this imageswapper to change the logo on his school's documents.

The code

You'll need the cUseful library, since it uses some functions there for blob processing. Note that this technique recognizes identical inline_images even though they have been resized in Docs.

Here's the key for the cUseful library, and it's also on github, or below.

Mcbr-v4SsYKJP7JMohttAZyz3TLx7pV4j


The swapper code is simply this. All you have to do is set up the parameters in imageSwapper and run it. As usual I suggest you try it on a copy of your document(s) first. It's also on github
var SwapperEnums =  {
  INHERIT_TEMPLATE:'INHERIT_TEMPLATE',                         // inherits the attributes/scaling from the template
  KEEP_ORIGINAL:'KEEP_ORIGINAL',                               // keeps the attributes/scaling of the original
  SCALE_ORIGINAL_HEIGHT:'SCALE_ORIGINAL_HEIGHT',               // keeps the height of the original image and scales the width 
  SCALE_ORIGINAL_WIDTH:'SCALE_ORIGINAL_WIDTH'                  // keeps the width of the original image and scales the height 
 
};

function imageSwapper () {

  var params = {
    template:'1rje8nZ-flJTFlBJO3XYm9mzQ-G0mHEkGlCVhEAXydwE',   // this is the document that contains the image swappong template
    scaling:SwapperEnums.SCALE_ORIGINAL_HEIGHT,               // change this as required.                                            
    attributes:SwapperEnums.KEEP_ORIGINAL,
    sections:['Header', 'Footer', 'Body']                     // where to look for images .. normally all 3
  }
  
  // there are twp ways of specifying the documents to look at .. one is simply to provide a list of document ids.
  // as below
  params.documents = [
      '1oI9Pefe5yjkrJKsKw1Mx6ITU_ONdTzZ-PAz3I_d48Qk'          // this is a list of documents to apply image swapping to
  ];
  
  
  // OR another way to populate the documents needed, instead of populating the list manualay
  // specify the starting path of the folder to look ( you can sepcify the id if you prefer)
  // the type of files
  // and whether to recurse (look in lower level paths)
 var piles = cUseful.DriveUtils
 .setService(DriveApp)
 .getPileOfFiles (
    "/books/youtube", "application/vnd.google-apps.document", true
  );
  // just interested in the ids.
  params.documents = piles.map (function (d) {
    return d.file.getId();
  });

  
  // do the image swapping
  var result = doImageSwapping ( params );
  
  // if used the piles method, we can enrich the results
  Logger.log (JSON.stringify(result.map (function(d,i) {
    if (piles) {
      d.path = piles[i].path;
      d.folderId = piles[i].folder.getId()
    }
    return d;
  })));
              
}

function doImageSwapping (params) {
  
  // first get the template
  var template = DocumentApp.openById(params.template);
  if (!template) throw 'could not open template document ' + params.template;
  
  var templateImages = getTemplateImages (template.getBody());
  if (!templateImages.length) throw 'no image swaps found in template';
  
  // make sure all the documents exist
  return params.documents.map (function (d) {
    var doc = DocumentApp.openById(d);
    if (!doc) throw 'could not open document ' + d;
    return doc;
  })
  
  // and do the swapping
  .map (function (doc) {
    
    var result = {
      id:doc.getId(),
      name:doc.getName(),
      imagesDetected:0,
      imagesReplaced:0
    };
    
    // make sure we skip the template
    if (result.id === params.template) {
      result.skipped = "Document was same as template - skipped" ;
      return result;
    }
    
    // look in each section asked for
    params.sections.forEach (function(section) {
                  
      // get all the images in the document
      var lump = doc['get'+section]();
      if (lump) {
        lump.getImages().forEach (function(image) {
          
          var sha = cUseful.Utils.blobDigest (image.getBlob());
          result.imagesDetected++;
          
          // check for a match against template
          var match = templateImages.filter (function (d) {
            return sha === d.sha;
          });
          
          // substitute & resize
          if (match.length) {
            
            result.imagesReplaced++;  
            // get the parent of current image, add the new one
            var parent = image.getParent();
            var newImage = parent.insertInlineImage(parent.getChildIndex(image), match[0].replaceWith.getBlob());
            
            // figure out scaling
            var newHeight,newWidth;
            
            if (params.scaling === SwapperEnums.INHERIT_TEMPLATE) {
              newHeight = newImage.getHeight();
              newWidth = newImage.getWidth();
            }
            
            else if (params.scaling === SwapperEnums.KEEP_ORIGINAL) {
              
              newHeight = image.getHeight();
              newWidth = image.getWidth();
            }
            
            else if (params.scaling === SwapperEnums.SCALE_ORIGINAL_HEIGHT) {
              newHeight = image.getHeight();
              newWidth = newImage.getWidth() * newHeight / newImage.getHeight() ;
            }
            
            else if (params.scaling === SwapperEnums.SCALE_ORIGINAL_WIDTH) {
              newWidth = image.getWidth();
              newHeight = newImage.getHeight() * newWidth / newImage.getWidth() ;
            }
            
            else {
              throw  params.scaling + ' is invalid scaling treatment'
            }
            
            // attribute inheritance
            if (params.attributes === SwapperEnums.KEEP_ORIGINAL) {
              newImage.setAttributes(image.getAttributes())
            }
            
            else if (params.attributes !== SwapperEnums.INHERIT_TEMPLATE) {
              throw params.attributes + ' is invalid attribute treatment'
            }
            
            
            // deal with size
            newImage.setHeight (newHeight).setWidth(newWidth);
            
            // delete the old one
            image.removeFromParent();
            
          }
          
        });
      }
    });
    return result;
  });

}



function getTemplateImages(body) {
  
  // return an array of {searchfor:inlineimage, repaceWith:inlineimage , sha:searchSha:the sha of the searched image}
  // table = body.getTables()[0];
  var imagePairs = body.getTables().reduce(function (images,table) {
    getChildrenArray (table).forEach (function (row) {
      var rowImages = [];
      getChildrenArray (row).forEach (function (cell) {
        getChildrenArray (cell).forEach (function (para) {
          getChildrenArray(para.asParagraph()).forEach (function(elem) {
            if (elem.getType() === DocumentApp.ElementType.INLINE_IMAGE) {

              rowImages.push(elem);
            }
          });
        });
      });
      // if exactly two images were found on this row, then they are replacement templates
      if (rowImages.length === 2 ) {
        images.push ({
          searchFor:rowImages[0],
          replaceWith:rowImages[1],
          sha:cUseful.Utils.blobDigest (rowImages[0].getBlob())
        });
      }
    });
    return images;
  },[]);
  
  // check for dups.
  imagePairs.forEach(function (d) {
    if (imagePairs.filter (function (e) {
      return d.sha === e.sha;
    }).length !== 1 ) throw 'duplicate search image in template';
  });
  

  return imagePairs;
  
  function getChildrenArray (parent) {
    var children = [];
    for (var i = 0 ; i < parent.getNumChildren() ; i++) {
      children.push(parent.getChild(i));
    }
    return children;
  }

  
}



For more like this, see Google Apps Scripts snippets. Why not join our forumfollow the blog or follow me on twitter to ensure you get updates when they are available. 

You want to learn Google Apps Script?

Learning Apps Script, (and transitioning from VBA) are covered comprehensively in my my book, Going Gas - from VBA to Apps script, available All formats are available now from O'Reilly,Amazon and all good bookshops. You can also read a preview on O'Reilly

If you prefer Video style learning I also have two courses available. also published by O'Reilly.
Google Apps Script for Developers and Google Apps Script for Beginners.






Comments