Wednesday, October 15, 2025

Professional PDF Inspection Reports with Headless Chrome

Paul (Founder)
Development

Property inspection reports must be professional, comprehensive, and easily shareable. Landlords expect formatted documents with property photos, detailed observations, and clear maintenance recommendations—not plain text emails or unformatted data exports. Generating high-quality PDFs programmatically whilst maintaining design consistency and handling embedded images creates several technical challenges.

Week 42 implemented PDF inspection report generation using Grover, a Ruby gem wrapping headless Chrome for rendering HTML to PDF. This approach provides complete CSS support for professional layouts, accurate photo embedding, and rendering fidelity matching the web interface. This article explores how we built reliable PDF generation handling complex reports with multiple photos whilst deploying successfully to Heroku's containerized environment.

What Your Team Will Notice

Each inspection now has a "Download PDF" button generating a professional report instantly. Click the button, and within seconds a formatted PDF downloads showing the complete inspection: property address and details at the top, inspection metadata (date, inspector, type), room-by-room observations with embedded photos, maintenance issues highlighted with priority levels, and signature images documenting tenant acknowledgment.

The PDF layout mirrors professional property reports: consistent typography using web fonts, property photos sized appropriately for printed pages, section headers clearly delineating different observation areas, and page breaks positioned sensibly to avoid awkward splits mid-observation.

Photos embed directly in the PDF with correct resolution—no external image references or missing pictures when reports are shared. Landlords receive complete documents they can view, print, and archive without depending on system access or external resources.

Generated PDFs are mobile-friendly: reports display correctly on phones and tablets, text remains readable without excessive zooming, and photos scale appropriately for small screens. This flexibility accommodates landlords reviewing reports on various devices without requiring special PDF readers or desktop computers.

Under the Bonnet: Grover PDF Generation

Grover provides a Ruby interface to Puppeteer (headless Chrome automation):

# Gemfile
gem 'grover', '~> 1.1'

# config/initializers/grover.rb
Grover.configure do |config|
  config.options = {
    format: 'A4',
    margin: {
      top: '0.5in',
      right: '0.5in',
      bottom: '0.5in',
      left: '0.5in'
    },
    print_background: true,
    prefer_css_page_size: true,
    display_header_footer: false
  }
end

This configuration establishes default settings for all PDFs: A4 page size common in UK property management, comfortable margins for printed output, background colours and images enabled for branded headers, and CSS page size control allowing template flexibility.

Inspection Report Template

The PDF generates from an HTML ERB template designed specifically for print:

<!-- app/views/inspections/report.html.erb -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <%= stylesheet_link_tag 'pdf_reports', media: 'all' %>

  <style>
    @page {
      size: A4;
      margin: 0.5in;
    }

    body {
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
      font-size: 11pt;
      line-height: 1.5;
      color: #1a202c;
    }

    .report-header {
      border-bottom: 3px solid #3182ce;
      padding-bottom: 1rem;
      margin-bottom: 2rem;
    }

    .property-details {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 1rem;
      margin-bottom: 2rem;
      page-break-inside: avoid;
    }

    .observation-section {
      margin-bottom: 2rem;
      page-break-inside: avoid;
    }

    .room-header {
      background: #edf2f7;
      padding: 0.5rem 1rem;
      font-weight: 600;
      margin-bottom: 0.5rem;
      border-left: 4px solid #3182ce;
    }

    .photo-grid {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 1rem;
      margin: 1rem 0;
    }

    .photo-item img {
      width: 100%;
      height: auto;
      border: 1px solid #e2e8f0;
      border-radius: 4px;
    }

    .maintenance-alert {
      background: #fff5f5;
      border-left: 4px solid #e53e3e;
      padding: 1rem;
      margin: 1rem 0;
      page-break-inside: avoid;
    }

    .signature-section {
      margin-top: 3rem;
      page-break-before: avoid;
    }

    .signature-image {
      border: 1px solid #e2e8f0;
      padding: 1rem;
      display: inline-block;
      max-width: 300px;
    }

    .signature-image img {
      width: 100%;
      height: auto;
    }
  </style>
</head>
<body>
  <!-- Report Header -->
  <div class="report-header">
    <h1>Property Inspection Report</h1>
    <div class="agency-name"><%= current_tenant.name %></div>
  </div>

  <!-- Property Details -->
  <div class="property-details">
    <div>
      <strong>Property:</strong><br />
      <%= @inspection.property.display_address %>
    </div>
    <div>
      <strong>Inspection Date:</strong><br />
      <%= @inspection.inspection_date.strftime('%d %B %Y') %>
    </div>
    <div>
      <strong>Inspection Type:</strong><br />
      <%= @inspection.inspection_type.titleize %>
    </div>
    <div>
      <strong>Conducted By:</strong><br />
      <%= @inspection.conducted_by&.name || 'Agency Staff' %>
    </div>
  </div>

  <!-- Room Observations -->
  <% if @inspection.room_observations.present? %>
    <h2>Room-by-Room Observations</h2>

    <% @inspection.room_observations.each do |room, observations| %>
      <div class="observation-section">
        <div class="room-header"><%= room.titleize %></div>
        <p><%= simple_format(observations) %></p>

        <!-- Room photos -->
        <% room_photos = @inspection.inspection_photos.for_room(room) %>
        <% if room_photos.any? %>
          <div class="photo-grid">
            <% room_photos.each do |photo| %>
              <div class="photo-item">
                <%= image_tag(photo.image_url, alt: "#{room} photo") %>
                <% if photo.description.present? %>
                  <p class="photo-caption"><%= photo.description %></p>
                <% end %>
              </div>
            <% end %>
          </div>
        <% end %>
      </div>
    <% end %>
  <% end %>

  <!-- General Observations -->
  <% if @inspection.general_observations.present? %>
    <div class="observation-section">
      <h2>General Observations</h2>
      <p><%= simple_format(@inspection.general_observations) %></p>
    </div>
  <% end %>

  <!-- Maintenance Issues -->
  <% if @inspection.maintenance_issues.any? %>
    <h2>Maintenance Issues</h2>

    <% @inspection.maintenance_issues.each do |issue| %>
      <div class="maintenance-alert">
        <strong><%= issue.title %></strong>
        <% if issue.priority.present? %>
          <span class="priority-badge <%= issue.priority %>">
            <%= issue.priority.titleize %> Priority
          </span>
        <% end %>
        <p><%= issue.description %></p>
      </div>
    <% end %>
  <% end %>

  <!-- Unassociated Photos -->
  <% general_photos = @inspection.inspection_photos.where(room_name: nil) %>
  <% if general_photos.any? %>
    <h2>Additional Photos</h2>
    <div class="photo-grid">
      <% general_photos.each do |photo| %>
        <div class="photo-item">
          <%= image_tag(photo.image_url, alt: "Inspection photo") %>
          <% if photo.description.present? %>
            <p class="photo-caption"><%= photo.description %></p>
          <% end %>
        </div>
      <% end %>
    </div>
  <% end %>

  <!-- Signature -->
  <% if @inspection.inspection_signature.present? %>
    <div class="signature-section">
      <h2>Signature</h2>
      <p>
        <strong>Signed by:</strong> <%= @inspection.inspection_signature.signed_by_name %><br />
        <strong>Date:</strong> <%= @inspection.inspection_signature.signed_at.strftime('%d %B %Y') %>
      </p>
      <div class="signature-image">
        <%= image_tag(@inspection.inspection_signature.image_url, alt: "Signature") %>
      </div>
    </div>
  <% end %>
</body>
</html>

This template structures content for optimal printing: logical section hierarchy, page break controls preventing awkward splits, embedded photos with proper sizing, and professional styling matching agency branding.

Controller PDF Generation

The controller action generates PDFs on-demand:

class InspectionsController < ApplicationController
  def show
    @inspection = Inspection.find(params[:id])
    @photos = @inspection.inspection_photos.ordered

    respond_to do |format|
      format.html # Regular show view
      format.pdf do
        render_pdf
      end
    end
  end

  private

  def render_pdf
    html = render_to_string(
      template: 'inspections/report',
      layout: false,
      locals: { inspection: @inspection }
    )

    pdf = Grover.new(html, **pdf_options).to_pdf

    send_data pdf,
              filename: pdf_filename,
              type: 'application/pdf',
              disposition: 'attachment'
  end

  def pdf_options
    {
      format: 'A4',
      margin: {
        top: '0.5in',
        right: '0.5in',
        bottom: '0.5in',
        left: '0.5in'
      },
      print_background: true,
      display_url: request.base_url, # For resolving relative asset paths
      wait_until: 'networkidle0' # Wait for images to load
    }
  end

  def pdf_filename
    property_ref = @inspection.property.reference.parameterize
    date = @inspection.inspection_date.strftime('%Y-%m-%d')
    "inspection-#{property_ref}-#{date}.pdf"
  end
end

This implementation renders the ERB template to HTML string, passes it to Grover for Chrome rendering, and returns the PDF as a downloadable file with descriptive filename.

Image Embedding Challenges

Embedded photos require special handling to appear correctly in PDFs:

# app/helpers/inspection_helper.rb
module InspectionHelper
  def photo_image_url(photo)
    # Use absolute URLs for PDF rendering
    if photo.image.attached?
      Rails.application.routes.url_helpers.rails_blob_url(
        photo.image,
        host: Rails.application.config.action_mailer.default_url_options[:host],
        protocol: 'https'
      )
    end
  end
end

Absolute URLs ensure Puppeteer can fetch images during rendering. The wait_until: 'networkidle0' option ensures all images load before PDF generation completes.

Heroku Deployment Configuration

Deploying headless Chrome to Heroku requires specific buildpack configuration:

# Procfile
web: bundle exec puma -C config/puma.rb

# app.json (for Heroku deployment)
{
  "buildpacks": [
    {
      "url": "heroku/ruby"
    },
    {
      "url": "https://github.com/jontewks/puppeteer-heroku-buildpack"
    }
  ],
  "env": {
    "GROVER_NO_SANDBOX": {
      "description": "Run Chrome without sandbox for Heroku",
      "value": "true"
    }
  }
}

# config/initializers/grover.rb (production settings)
if Rails.env.production?
  Grover.configure do |config|
    config.options = {
      # ... other options ...
      launch_args: ['--no-sandbox', '--disable-setuid-sandbox'],
      executable_path: ENV.fetch('GOOGLE_CHROME_SHIM', nil)
    }
  end
end

The Puppeteer buildpack installs Chrome binaries, whilst the no-sandbox flags accommodate Heroku's containerized environment lacking sandbox permissions.

Testing PDF Generation

Testing PDF generation verifies rendering and content:

RSpec.describe "Inspection PDF generation", type: :request do
  let(:inspection) { create(:inspection, :with_photos, :with_signature) }

  it "generates PDF with correct content type" do
    get inspection_path(inspection, format: :pdf)

    expect(response.content_type).to eq('application/pdf')
    expect(response.headers['Content-Disposition']).to include('attachment')
  end

  it "includes property details in PDF" do
    get inspection_path(inspection, format: :pdf)

    # Parse PDF and check content (requires pdf-reader gem)
    pdf_text = PDF::Reader.new(StringIO.new(response.body)).pages.first.text

    expect(pdf_text).to include(inspection.property.display_address)
    expect(pdf_text).to include(inspection.inspection_date.strftime('%d %B %Y'))
  end

  it "generates filename with property reference and date" do
    get inspection_path(inspection, format: :pdf)

    filename = response.headers['Content-Disposition'].match(/filename="(.+)"/)[1]
    expect(filename).to match(/inspection-#{inspection.property.reference.parameterize}/)
    expect(filename).to match(/\d{4}-\d{2}-\d{2}/)
  end
end

These tests ensure PDFs generate correctly with expected content and metadata.

What's Next

The PDF generation foundation enables sophisticated features: custom report templates per agency branding, batch PDF generation for portfolio-wide reporting, PDF email attachments automatically sending reports to landlords, and archived PDF storage providing historical inspection records.

Future enhancements might include interactive PDFs with form fields for landlord feedback, accessibility-compliant PDFs meeting WCAG standards, multilingual reports supporting international landlords, and PDF compression optimizing file sizes for email delivery.

By implementing robust PDF generation using headless Chrome through Grover, LetAdmin provides letting agencies with professional inspection reports matching traditional paper-based workflows whilst maintaining digital efficiency and integration with broader property management systems.