Each year, we send out over a hundred holiday cards to friends and family all over the world. But getting our contacts from Gmail into Microsoft Word to create address labels is a real pain-in-the-ass. So I wrote this little script to replace free markers in a template Word document with contacts from a Gmail contacts CSV. Just place the markers ${0} through ${4} into a .docx document where you want the names and address to go (4 minutes), export your contacts as a Google CSV (1 minute), and this script will do the rest (1 second). It will create as many copies of the template document as needed to make labels for everyone!

#!/usr/bin/perl

use strict;
use File::Slurp;
use Archive::Zip qw( :ERROR_CODES );

my ($csv_input, $docx_input) = @ARGV;

unless ($csv_input =~ /\.csv$/ and $docx_input =~ /\.docx$/) {
    die "Usage: labels <.csv file> <.docx file>\n";
}

# Read the entire Google CSV
my $csv = read_file($csv_input);

# Extract the Word document XML
my $template_file = Archive::Zip->new();
unless ($template_file->read( $docx_input ) == AZ_OK) { 
    die "Couldn't read $docx_input"; 
};
unless ($template_file->extractMemberWithoutPaths(
    "word/document.xml", "/tmp/template.xml") == AZ_OK) 
{ 
    die "Couldn't extract word/document.xml";
}
my $template_xml = read_file('/tmp/template.xml');

# Make sure we have markers to work with
unless ($template_xml =~ /\$\{0\}/) { 
    die "I think you forgot to add markers to the template"; 
}

# Clean out the weird characters that come with a Google CSV
$csv =~ s/[^\w\s\d,"\.\@\-]//g;

# Escape and unquote any quoted fields
$csv =~ s/"([^"]+)"/escape($1)/ge;

my %headers;
my @addresses;
my @lines = split /\r\n/, $csv;

# Compile a list of formatted addresses
foreach (@lines) {
    my @fields = split /,/, $_;
    if (keys %headers) {

        # Name and address
        my $name = $fields[$headers{'Name'}];
        my $address = $fields[$headers{'Address 1 - Formatted'}];
        push @addresses, unescape("$name\r\n$address");
    } 
    else {

        # Read the CSV headers
        my $i = -1;
        %headers = map { $i++; $_ => $i } @fields;
    }
}

my $i = 0;
my $current_xml = $template_xml;
foreach (@addresses) {
    if ($current_xml =~ /\$\{0\}/) {
        # We have remaining space

        # Replace markers ${0} through ${4} with address lines
        my @address_lines = split /\r\n/, $_;
        print "Reading address for $address_lines[0]\n";
        $current_xml =~ s/\$\{0\}/$address_lines[0]/;
        $current_xml =~ s/\$\{1\}/$address_lines[1]/;
        $current_xml =~ s/\$\{2\}/$address_lines[2]/;
        $current_xml =~ s/\$\{3\}/$address_lines[3]/;
        $current_xml =~ s/\$\{4\}/$address_lines[4]/;
    }
    else {

        # Current document is full
        build_document();
        $current_xml = $template_xml;
        $i++;
    }
}
build_document();

sub build_document {
    
    # Discard remaining unused markers and save document XML
    $current_xml =~ s/\$\{\d\}//g;
    write_file("/tmp/template-$i.xml", $current_xml);

    # Create a new .docx file
    $docx_input =~ /^(.+)(\.[^.]+)$/;
    my $filename = "$1-$i$2";
    `cp $docx_input $filename`;

    print "Writing $filename\n";

    # Insert document XML into the new .docx file 
    my $new_doc = Archive::Zip->new();
    unless ($new_doc->read( $filename ) == AZ_OK) { 
        die "Couldn't read $filename"; 
    };
    $new_doc->removeMember("word/document.xml");
    $new_doc->addFile("/tmp/template-$i.xml", "word/document.xml");
    unless ($new_doc->overwrite() == AZ_OK) { 
        die "Couldn't save $filename"; 
    }
}

sub escape {
    my ($value) = @_;   
    $value =~ s/\n/\\n/g;
    $value =~ s/\r/\\r/g;
    $value =~ s/,/\\_/g;
    return $value;
}

sub unescape {
    my ($value) = @_;
    $value =~ s/\\n/\n/g;
    $value =~ s/\\r/\r/g;
    $value =~ s/\\_/,/g;
    return $value;
}