Easy PDF Photo-Calendars in Ruby

   

Looking at the sample calendars above you would think that humanity has failed - each day is represented by a lead image from the BBC News and seems to showcase nothing but suffering, destruction, and pain. On a more positive note, generating these PDF calendars was nothing but joy thanks to the nifty PDF::Writer library by Austin Zigler. With a few extensions and some Ruby-foo magic, my final calendar featured news-clustering, multi-month views, and a number of other goodies. Here, we'll cover the basics of generating the PDF calendar grid and populating it with photos. Let's get to it!

Creating the PDF canvas

In my project, I had a number of different print-out styles (Calendar, Treemap, etc.) and output mediums (PDF, SVG, etc.). Hence, I created a Canvas class and abstracted the PDF code inside to decouple some of the functions:

require 'rubygems'
require 'pdf/writer'
require 'RMagick'

module Canvas
  class Canvas
    attr_accessor :name

    def initialize(name)
      @name, @doc = name, Pdf.new(name)
      @maxWidth, @maxHeight = @doc.pdf.page_width, @doc.pdf.page_height
      @x, @y = 0, @doc.pdf.y - 75 # offset the y-pointer
    end

    def save() @doc.save; end
  end

  private

  class Pdf
    attr_reader :name, :pdf

    def initialize(name)
      @name = name
      @pdf = PDF::Writer.new(:orientation => :landscape)

      # Set document meta-data
      @pdf.info.title = @name
      @pdf.info.author = "Ilya Grigorik"
      @pdf.info.subject = "Calendar"
      @pdf.margins_pt(0, 0, 0, 0)   # top, left, bottom, right
    end

    def save() @pdf.save_as(@name); end
    def addImage(*attrs) @pdf.add_image_from_file(*attrs); end
  end
end

As you can see, the Canvas object will encapsulate the PDF class in our example. This is an unnecessary step if you're only working with PDF output, but we'll keep it as it is, it shouldn't complicate our code too much.

Creating the calendar view

The CalendarView class will be responsible for creating the grid and handling the calendar logic. First, the CalendarView constructor will initialize the pdf output file (super). Then, once the canvas is ready, it will go ahead and draw the calendar grid. Now, depending on the year/month view of the calendar, the first of every month will fall on a different day of we week, and we need to figure out which! Hence, we also provide a date object to the constructor (first day of the month, ex: '2007-02-01'):

module Canvas
  class CalendarView < Canvas
    def initialize(name, date)
      super(name)

      @topRow = 50
      @rowH = (@doc.pdf.page_height.to_i - @topRow) / 6
      @columnW = (@doc.pdf.page_width.to_i) / 7
      @doc.pdf.stroke_color! Color::RGB.new(140,140,140)

      # Draw calendar grid
      1.upto(6) do |n|
        @doc.pdf.line(@doc.pdf.absolute_left_margin, n*@rowH, @doc.pdf.absolute_right_margin, n*@rowH).stroke
      end

      1.upto(6) do |n|
        @doc.pdf.line(n*@columnW, @doc.pdf.absolute_bottom_margin+6*@rowH, n*@columnW, @doc.pdf.absolute_bottom_margin).stroke
      end

      # Set first day of the week to Monday & figure out the start-position for current month
      @order = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
      @startSquare = @order.index(date.strftime("%A"))-1
    end

    def addImage(file, date)
      squareNum = @startSquare + Time.parse(date).day.to_i
      row = squareNum / 7
      col = squareNum % 7

      # Calculate the x, y offsets
      x = @doc.pdf.absolute_left_margin + col * @columnW
      y = (@doc.pdf.absolute_top_margin.to_i - @topRow - @rowH * (row+1)) - 3

      # Read the source image file and it's corresponding dimensions
      imageMagick = Magick::Image.read(file).first
      imgWidth, imgHeight = imageMagick.columns, imageMagick.rows
      imgRatio = imgWidth.to_f / imgHeight.to_f

      # scale to fit in calendar, preserve aspect ratio
      imgWidth, imgHeight = @columnW, (@columnW / imgRatio).to_i if ((imgWidth > imgHeight) and (imgWidth > @columnW))
      imgHeight, imgWidth = @rowH, (@rowH * imgRatio).to_i if ((imgHeight > imgWidth) and (imgHeight > @rowH))
      imgHeight, imgWidth = @rowH, @columnW if (imgHeight == imgWidth)

      # center the photo in the calendar square
      x = x + (@columnW-imgWidth)/2 if imgWidth < @columnW
      y = y + (@rowH-imgHeight)/2 if imgHeight < @rowH

      @doc.addImage(file, x, y, imgWidth, imgHeight)
    end
  end
end

By this point, we already have the calendar layout, and now we just have to populate it with photos. To do so, we simply call on the addImage method, and provide the filename and the date of the photo - this method will automatically figure out the correct square and resize, and center the image for us!

Spiffy, on-demand PDF calendars

We're almost there. All we have to do now is define the list of photos and dates. This could be a directory listing with 'created' timestamps, or it could also be your Flickr photostream, etc. For the sake of brevity, we will simply hard-code a few photo filenames with dates:

require 'canvas.rb'
require 'calendar.rb'

# First parameter: output filename, Second parameter: Date object for the month/year of the calendar view
pdfDoc = Canvas::CalendarView.new("my-calendar.pdf", '2007-02-01')

# Load an array of images, provide full path, and the date (read exif from photo?)
images =  [['image1.jpg',  '2007-02-25'],
           ['image2.jpg', '2007-03-26']]

# Insert the images into our calendar!
images.each { |image| pdfDoc.addImage(image[0], image[1]) }
pdfDoc.save
calendar-code.zip - All Ruby files

That's it, a dynamic PDF photo-calendar in ~100 lines of code! If you're curious, you can view the full calendar for BBC news: download it first, it's rather large! For more examples on using PDF::Writer, I would also recommend a great guide by Austin Ziegler himself: Creating Printable Documents with Ruby.


Ilya Grigorik

Ilya Grigorik is a web performance engineer and developer advocate at Google, where his focus is on making the web fast and driving adoption of performance best practices at Google and beyond.