Hacking your Address Book with C (or Objective C), Racket and LaTeX

[article index] [] [@mattmight] [rss]

For the third sixth year in a row, Apple changed how Address Book Contacts prints mailing labels. This used to cause endless holiday card grief.

Rather than reformat our data yet again, six years ago, I took control of it.

In this article, you'll find:

  • a C program to dump the OS X Address Book into S-Expressions;
  • a (new for December 2012) equivalent Objective-C program;
  • a Racket program to convert those S-Expressions into a .tex file; and
  • a 2015 modification that dumps to the TinyPrints CSV format.

The C program shows how to interact with OS X in C, and it explores the Address Book API. It's a good reference for an Address Book plug-in. The same is true of the Objective-C version.

The Racket program creates mailing labels. It's smart about spouses, partners and titles, and it's easy to extend if special cases arise.

Even if you're not a Mac user, you could still compile your addresses to the simple, human-readable S-Expression format to print them as labels.

The LaTeX file generated by the Racket program renders as a PDF suitable for printing on Avery mailing labels in their 5160 or 8160 layouts.

Update: In December 2012, I added support for the Avery 8162 (inkjet) and Avery 5162 (laser) label formats.

Update: In December 2013, I added support for the Avery 18664 (inkjet) and Avery 5664 (laser) label formats.

Update: In November 2014, I added support for the Avery 5163 and Avery 5263 label formats.

Why bother?

My wife and I keep our address books synced on our phones and computers.

We shouldn't have to retype or reformat all that information onto mailing labels when the holidays roll around.

Apple's Address Book program tries to be smart about printing labels when it detects spouses and children.

The problem is that it's smart in a different way with each revision to OS X.

Each year, my wife has had to figure out the new rules for how to encode spouses and partners, and then reformat the address book.

It's a pain.

So, this year, I permanently solved the problem with about 150 lines of C and 150 lines of Racket.

We dumped the Address Book data and formatted labels for printing according to our own standard. It took less time than reformatting by hand.

Dumping the OS X Address Book

The OS X Address Book has a C API and an Objective-C API. Both have a roughly one-to-one mapping with each other.

I originally picked the C API for dumping the Address Book data, but only because I'd done more C hacking than Objective-C hacking.

[Update: I had the chance to rewrite it in Objective-C, and it's substantially cleaner.]

abdump.c (or abdump.m) accepts a group name as its first argument, and dumps each person in the group as an S-Expression:

#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <AddressBook/ABAddressBookC.h>
#include <AddressBook/ABGlobalsC.h>
#include <CoreFoundation/CoreFoundation.h>

// The global address book:
ABAddressBookRef AB ;

// Convert a Core Foundation string 
// to a null-terminated C string:
const char* cstring (CFStringRef s) {
  return (const char*)
  CFStringGetCStringPtr(s, 
                        kCFStringEncodingMacRoman) ;
}

int main (int argc, const char* argv[]) {
  	
  // By default, choose the group Family:
  const char* groupName = "Family" ;
  
  // Usage: $0 ["<group name>"]
  // Ex: $0 "Holiday Card List"
  if (argc >= 2)
    groupName = argv[1] ;
	
  CFStringRef cfGroupName 
  = CFStringCreateWithBytes(NULL,
                            (UInt8*)groupName,
                            strlen(groupName),
                            kCFStringEncodingMacRoman,
                            false);
	
  // Find that group:
  ABSearchElementRef matchGroupName 
  = ABGroupCreateSearchElement(kABGroupNameProperty,
                               NULL,
                               NULL,
                               cfGroupName,
                               kABEqualCaseInsensitive);
  
  // Grab the global, shared address book:
  AB = ABGetSharedAddressBook();
  
  // All groups with that name:
  CFArrayRef groupsFound 
  = ABCopyArrayOfMatchingRecords(AB, matchGroupName) ;
	
  if (CFArrayGetCount(groupsFound) == 0) {
    printf("error: couldn't find address book group '%s'\n",
           groupName);
    exit(-1) ;
  }
  
	ABGroupRef matchedGroup 
  = (ABGroupRef)CFArrayGetValueAtIndex(groupsFound, 0);
  
  // All people of the matched group:
  CFArrayRef members
  = ABGroupCopyArrayOfAllMembers(matchedGroup) ;
  
  // Total number of people:
	int len = CFArrayGetCount(members) ;  
  
  // Give each entry a unique id:
  int uid = 0 ;
  
  // Iterate over all people:
  for (int i = 0; i < len; ++i) {
    
    ABPersonRef person 
    = (ABPersonRef)CFArrayGetValueAtIndex(members, i) ;
    
    // Grab properties of each person:
    CFStringRef firstName 
    = ABRecordCopyValue(person, kABFirstNameProperty);
    
    CFStringRef lastName 
    = ABRecordCopyValue(person, kABLastNameProperty);
    
    ABMultiValueRef address
    = ABRecordCopyValue(person, kABAddressProperty);
    
    ABMultiValueRef relatives
    = ABRecordCopyValue(person, kABRelatedNamesProperty);
    
    CFStringRef title
    = ABRecordCopyValue(person, kABTitleProperty);
    
    // Print properties of each person:
    printf("((first-name \"%s\")\n", cstring(firstName)) ;
    printf(" (last-name  \"%s\")\n", cstring(lastName)) ;
    
    if (title)
      printf(" (title      \"%s\")\n", cstring(title)) ;
    
    // There could be multiple address.
    // We have to loop through all of them.
    int numAddresses = ABMultiValueCount(address) ;
    for (int k = 0; k < numAddresses; ++k) {
      
      // Check the type:
      CFStringRef addrType
      = ABMultiValueCopyLabelAtIndex(address, k) ;
      
      // Keep searching if it's not the home address:
      if (CFStringCompare(addrType,kABHomeLabel,0) != 0) 
        continue ;
      
      printf(" (address    ") ;
      
      // Addresses have multiple keys:
      // Street, State, ZIP, Country, City
      CFDictionaryRef location 
      = ABMultiValueCopyValueAtIndex(address, k) ;
      
      int size = CFDictionaryGetCount(location) ;
      
      CFStringRef keys[size] ;
      CFStringRef values[size] ;
      
      CFDictionaryGetKeysAndValues(location, 
                                   (const void**)&keys,
                                   (const void**)&values) ;
      
      for (int a = 0; a < size; ++a) {
        if (a)
          printf("             ") ;
        
        printf("(%-12s \"%s\")", 
               cstring(keys[a]), 
               cstring(values[a])) ;
        if (a+1 == size) 
          printf(")");
        
        printf("\n");
      }
    }
    
    // We have to search all relatives for the spouse/partner:
    int numRelatives = ABMultiValueCount(relatives) ;
    for (int j = 0; j < numRelatives; ++j) {
      CFStringRef relativeType
      = ABMultiValueCopyLabelAtIndex(relatives, j) ;
      
      CFStringRef relativeName 
      = ABMultiValueCopyValueAtIndex(relatives, j) ;
      
      if (!CFStringCompare(relativeType,kABSpouseLabel,0)) 
        printf(" (spouse     \"%s\")\n",
               cstring(relativeName)) ;
      
      if (!CFStringCompare(relativeType,kABPartnerLabel,0))
        printf(" (partner    \"%s\")\n",
               cstring(relativeName)) ;
    }
    printf(" (uid        %i))\n\n",     uid++) ;
  }
  return EXIT_SUCCESS;
}

To compile, use the CoreFoundation and AddressBook frameworks:

 % gcc -std=c99 -o abdump -framework CoreFoundation \
                          -framework AddressBook abdump.c

To run, supply abdump with a group name:

 % ./abdump "Holiday Card List"

The output format is human-readable, and should be self-explanatory. Each entry looks somewhat like the following:

((first-name "John")
 (last-name  "Smith")
 (address    (Street       "100 Imaginary Drive")
             (State        "XY")
             (ZIP          "12345")
             (CountryCode  "us")
             (Country      "USA")
             (City         "Metropolis"))
 (spouse     "Jane Smith")
 (uid        83))

Formatting entries into mailing labels

To format the entries into mailing labels, I took advantage of Racket's pattern-matching capabilities.

For instance, it's easy to match on a record and check whether there's a spouse and whether that spouse shares a last name.

labelize.rkt converts each entry into a LaTeX-formatted mailing label:

#lang racket

; Labelize  
; 
; Author:    Matthew Might
; Site:      http://matt.might.net/
; Original:  http://matt.might.net/articles/address-book-hacking/
;
; Converts an address book in S-Expression format
; into a .tex file suitable for printing on
; Avery {8162,5162} or {8160,5160} label layouts.

; Usage:
;
; % racket labelize.rkt [--paper=avery8160 | --paper=avery8162] [database.sx]


; Defaults to pulling in the file "entries.sx":
(define args (current-command-line-arguments))

(define paper-format 'Avery-8160)

(define file "entries.sx")

(define (process-args args)
  (match args
    [(cons "--paper=avery8160" rest)
     (set! paper-format 'Avery-8160)
     (process-args rest)]
    
    [(cons "--paper=avery8162" rest)
     (set! paper-format 'Avery-8162)
     (process-args rest)]
    
    [(cons filename '())
     (set! file filename)]
    
    ['()
     (void)]))

(process-args (vector->list args))


(define p (open-input-file file))

; Read in all entries:
(define (read-all port)
  (let ((next (read port)))
    (cond
      [(eof-object? next)   '()]
      [else                 (cons next (read-all port))])))

(define entries (read-all p))

(close-input-port p)

; Splits a name in two, e.g., "John Smith" => '("John" "Smith")
(define (split-name name)
  (cond 
    [(string? name)    
     ; =>
     (match (regexp-split #rx" +" name)
       [`(,first-name "de" "la" ,rest)
        ; =>
        (list first-name (string-append "de" " " "la" " " rest))]
       
       [`(,first-name "de" ,rest)
        ; =>
        (list first-name (string-append "de" " " rest))]
       
       [`(,first-name ,middle-name ,last-name)
        ; =>
        (list (string-append first-name " " middle-name) last-name)]
       
       [as-matched    as-matched])]
       
    [else              '()]))

; Formats an entry:
(define (format-name entry)
  (match entry
    
    ; Have a spouse?
    [(list-no-order
      `(first-name ,first-name)
      `(last-name  ,last-name)
      `(spouse     ,(app split-name `(,spouse-first ,spouse-last)))
      _ ...)
     ; =>
     (cond 
       ; Same last name:
       [(equal? spouse-last last-name)
        ; => 
        (format "~a & ~a ~a" first-name spouse-first last-name)]
       
       ; Different last names:
       [else
        ; =>
        (format "~a ~a & ~a ~a" first-name last-name spouse-first spouse-last)])]
    
    ; Partner:
    [(list-no-order
      `(first-name ,first-name)
      `(last-name  ,last-name)
      `(partner    ,partner-name)
      _ ...)
     ; =>
     (format "~a ~a & ~a" first-name last-name partner-name)]

    
    ; Unmarried, with title:
    [(list-no-order
      `(first-name ,first-name)
      `(last-name  ,last-name)
      `(title      ,title)
      _ ...)
     ; =>
     (format "~a ~a ~a" title first-name last-name)]
    
    ; Unmarried, no title:
    [(list-no-order
      `(first-name ,first-name)
      `(last-name  ,last-name)
      _ ...)
     ; =>
     (format "~a ~a" first-name last-name)]))
    
; Format address:
(define (format-address entry)
  (match entry
    
    ; Foreign address?
    [(list-no-order
      `(address . ,(list-no-order
                    `(Street   ,street)
                    `(City     ,city)
                    `(State    ,state)
                    `(ZIP      ,zip)
                    `(Country  ,(and country 
                                     (not (or "USA" "United States"))))
                    _ ...))
      _ ...)
     ; =>
     (format "~a~n~a, ~a ~a ~a" street city state zip country)]
    
    ; U.S. address?
    [(list-no-order
      `(address . ,(list-no-order
                    `(Street   ,street)
                    `(City     ,city)
                    `(State    ,state)
                    `(ZIP      ,zip)
                    _ ...))
      _ ...)
     ; =>
     (format "~a~n~a, ~a ~a" street city state zip)]
    
    [else
     (format "Insufficient address info: ~a" entry)]))

; Format an entry into label:
(define (format-label entry)
  (format "~a~n~a" 
          (format-name entry)
          (format-address entry)))

; Escape special characters in LaTeX:
(define (latex-escape string)
  (let* ([s string]
         [s (regexp-replace* #rx"#" s "\\\\#")]
         [s (regexp-replace* #rx"&" s "\\\\&")]         
         [s (regexp-replace* #rx"[\n ]*\n[\n ]*" s "\\\\\\\\\n")])
    s))


(when (eq? paper-format 'Avery-8162)
  
  ; LaTeX preamble for 8162, 2 x 14:
  (display 
"\\documentclass[letterpaper,12pt]{report}
\\usepackage{palatino} 
\\pagestyle{empty}
\\oddsidemargin  -0.80in 
\\evensidemargin -0.80in
\\marginparwidth 0pt \\marginparsep 0pt
\\topmargin -0.00in \\headsep 0pt
\\headheight 0pt
\\textheight 10.5in
\\textwidth  8in
\\raggedbottom
\\parindent=0in
\\begin{document}\n")

(define entry-count 0)

; Print out all the entries:
(for ([entry entries])
  ; Each adress is a mini-page:
  (display "\\begin{minipage}[t][1.33in]{4in}\n\\centering\n")
  (display (latex-escape (format-label entry)))
  (newline)
  (display "\\end{minipage}\n")
  (if (= (modulo entry-count 2) 1)
    (display "\\\\\n")
    (display "\\hspace{0.10in}\n"))
  (display "%")
  (newline)
  (set! entry-count (+ 1 entry-count))
  (when (= (modulo entry-count 14) 0)
    (display "\\newpage")))
  
(display "\\end{document}\n"))



(when (eq? paper-format 'Avery-8160)
  
; LaTeX preamble for 8160, 3 x 10:
(display 
"\\documentclass[letterpaper,12pt]{report}
\\usepackage{palatino} 
\\pagestyle{empty}
\\oddsidemargin  -0.80in 
\\evensidemargin -0.80in
\\marginparwidth 0pt \\marginparsep 0pt
\\topmargin -0.40in \\headsep 0pt
\\headheight 0pt
\\textheight 10.5in
\\textwidth  8in
\\raggedbottom
\\parindent=0in
\\begin{document}\n")

(define entry-count 0)

; Print out all the entries:
(for ([entry entries])
  ; Each adress is a mini-page:
  (display "\\begin{minipage}[t][0.98in]{2.5in}\n\\centering\n")
  (display (latex-escape (format-label entry)))
  (newline)
  (display "\\end{minipage}\n")
  (if (= (modulo entry-count 3) 2)
    (display "\\\\\n")
    (display "\\hspace{0.15in}\n"))
  (display "%")
  (newline)
  (set! entry-count (+ 1 entry-count))
  (when (= (modulo entry-count 30) 0)
    (display "\\newpage")))
  
(display "\\end{document}\n"))

It prints 30 addresses per sheet. It should be easy to tweak the dimensions and spacing to match any label printing standard.

Printing mailing labels

To print the labels, use Avery label paper: