Tuesday, September 16, 2025

Generating EPC Rating Graphics for Property Portals

Paul (Founder)
Development
Developer working on visual graphics generation for property certificates

Rightmove requires Energy Performance Certificate (EPC) rating graphs in specific formats for property listings. These graphs display colour-coded energy efficiency bands (A through G), highlight current and potential ratings, and follow official government styling standards. Creating these graphics manually for hundreds of properties is impractical; agencies need automated generation that produces portal-ready images instantly.

Week 38 implemented automatic EPC image generation that transforms certificate data into professional rating graphics. This article explores how we built this system using SVG templates, ImageMagick conversion, and background job processing—ensuring every property has portal-compliant EPC graphics without manual intervention.

What Your Team Will Notice

When EPC data is imported from the government database or manually uploaded, rating graphics generate automatically within seconds. The property EPC card displays a professional energy efficiency chart showing:

  • Colour-coded rating bands (green for A/B/C, yellow for D, orange for E/F, red for G)
  • Current rating indicated with a filled marker
  • Potential rating shown with an outlined marker
  • Numeric scores alongside letter ratings
  • Government-compliant styling matching official EPC certificates

These graphics sync automatically to Rightmove and other portals when properties advertise. Updates to EPC data (new certificates, rating improvements) trigger automatic image regeneration, keeping portal listings current without manual graphic creation.

For properties with manually uploaded certificates, the same automatic generation occurs—ensuring consistent visual presentation regardless of whether EPC data came from the government API or manual entry.

Under the Bonnet: SVG Template Generation

Rather than using complex image manipulation libraries or pre-rendered graphics, we generate EPC images dynamically using SVG (Scalable Vector Graphics). SVG provides crisp rendering at any resolution, easy programmatic manipulation, and straightforward conversion to PNG format required by property portals.

Template Structure

The EPC rating graphic consists of stacked horizontal bands representing efficiency ratings:

class EpcImageService
  def initialize(epc_certificate)
    @epc = epc_certificate
  end

  def generate_image
    svg = build_svg_template
    png = convert_to_png(svg)
    attach_to_epc(png)
  end

  private

  def build_svg_template
    # SVG dimensions optimized for portal display
    width = 600
    height = 400
    band_height = 50

    svg = <<~SVG
      <svg xmlns="http://www.w3.org/2000/svg" width="#{width}" height="#{height}">
        <defs>
          <!-- Gradient definitions for rating bands -->
        </defs>

        <!-- Rating scale bands (A through G) -->
        #{build_rating_bands(band_height)}

        <!-- Current rating marker -->
        #{build_current_marker}

        <!-- Potential rating marker -->
        #{build_potential_marker}

        <!-- Score labels -->
        #{build_score_labels}
      </svg>
    SVG

    svg
  end
end

Colour-Coded Rating Bands

Each efficiency rating has specific colours matching government standards:

def build_rating_bands(band_height)
  bands = [
    { rating: 'A', color: '#008054', range: '92-100' },
    { rating: 'B', color: '#19b459', range: '81-91' },
    { rating: 'C', color: '#8dce46', range: '69-80' },
    { rating: 'D', color: '#ffd500', range: '55-68' },
    { rating: 'E', color: '#fcaa65', range: '39-54' },
    { rating: 'F', color: '#ef8023', range: '21-38' },
    { rating: 'G', color: '#e9153b', range: '1-20' }
  ]

  bands.map.with_index do |band, index|
    y_position = index * band_height

    <<~BAND
      <rect x="0" y="#{y_position}" width="500" height="#{band_height}"
            fill="#{band[:color]}" opacity="0.9" />
      <text x="520" y="#{y_position + 35}" font-size="28" font-weight="bold"
            fill="#333">
        #{band[:rating]}
      </text>
      <text x="560" y="#{y_position + 30}" font-size="14" fill="#666">
        #{band[:range]}
      </text>
    BAND
  end.join
end

This produces a vertical stack of coloured bands with ratings and score ranges labelled clearly.

Rating Markers

Current and potential ratings display as markers overlaying the appropriate bands:

def build_current_marker
  rating = @epc.current_energy_rating
  score = @epc.current_energy_efficiency
  y_position = calculate_y_position(rating)

  <<~MARKER
    <g>
      <!-- Filled marker for current rating -->
      <polygon points="#{marker_points(y_position)}"
               fill="#1e40af" stroke="#1e40af" stroke-width="2" />
      <text x="480" y="#{y_position + 8}" font-size="20" font-weight="bold"
            fill="white" text-anchor="end">
        #{score}
      </text>
      <text x="20" y="#{y_position - 10}" font-size="16" fill="#1e40af">
        Current: #{rating} (#{score})
      </text>
    </g>
  MARKER
end

def build_potential_marker
  rating = @epc.potential_energy_rating
  score = @epc.potential_energy_efficiency
  y_position = calculate_y_position(rating)

  <<~MARKER
    <g>
      <!-- Outlined marker for potential rating -->
      <polygon points="#{marker_points(y_position)}"
               fill="none" stroke="#059669" stroke-width="3"
               stroke-dasharray="5,5" />
      <text x="480" y="#{y_position + 8}" font-size="18"
            fill="#059669" text-anchor="end">
        #{score}
      </text>
      <text x="20" y="#{y_position + 30}" font-size="16" fill="#059669">
        Potential: #{rating} (#{score})
      </text>
    </g>
  MARKER
end

def calculate_y_position(rating)
  # Maps rating letter to vertical position on chart
  rating_positions = { 'A' => 25, 'B' => 75, 'C' => 125, 'D' => 175,
                       'E' => 225, 'F' => 275, 'G' => 325 }
  rating_positions[rating] || 175
end

The filled marker shows the current rating; the dashed outline shows achievable potential after recommended improvements.

ImageMagick Conversion

SVG provides resolution independence and easy manipulation, but property portals require PNG format. We use ImageMagick for conversion:

def convert_to_png(svg_content)
  require 'tempfile'
  require 'open3'

  # Write SVG to temporary file
  svg_file = Tempfile.new(['epc', '.svg'])
  svg_file.write(svg_content)
  svg_file.close

  # Convert to PNG using ImageMagick
  png_file = Tempfile.new(['epc', '.png'])

  command = [
    'convert',
    '-density', '300',           # High DPI for quality
    '-background', 'white',      # White background
    '-flatten',                  # Remove transparency
    svg_file.path,
    '-resize', '600x400!',       # Fixed dimensions for portals
    '-quality', '95',            # High quality compression
    png_file.path
  ]

  stdout, stderr, status = Open3.capture3(*command)

  unless status.success?
    raise "ImageMagick conversion failed: #{stderr}"
  end

  # Read PNG binary data
  png_data = File.binread(png_file.path)

  # Clean up temporary files
  svg_file.unlink
  png_file.unlink

  png_data
end

This conversion produces crisp PNG images at 600×400 pixels—optimal for property portal display whilst keeping file sizes manageable.

Background Job Processing

Image generation involves SVG building and ImageMagick conversion—operations taking several seconds. Processing synchronously would delay EPC import workflows. Background jobs handle generation asynchronously:

class EpcImageGenerationJob < ApplicationJob
  queue_as :default

  def perform(epc_id)
    epc = EnergyPerformanceCertificate.find(epc_id)

    # Generate PNG from EPC data
    service = EpcImageService.new(epc)
    image_data = service.generate_image

    # Attach to EPC record using ActiveStorage
    epc.rating_image.attach(
      io: StringIO.new(image_data),
      filename: "epc-rating-#{epc.certificate_number}.png",
      content_type: 'image/png'
    )
  end
end

The job queues automatically when EPC records are created or updated:

class EnergyPerformanceCertificate
  has_one_attached :rating_image

  after_commit :queue_image_generation, on: [:create, :update], if: :needs_image?

  private

  def needs_image?
    # Generate if no image exists or ratings changed
    !rating_image.attached? || saved_change_to_current_energy_rating?
  end

  def queue_image_generation
    EpcImageGenerationJob.perform_later(id)
  end
end

This ensures images generate for all EPCs without blocking user workflows.

ActiveStorage Integration

Generated images attach using ActiveStorage, providing cloud storage integration and flexible URL generation:

class EnergyPerformanceCertificate
  has_one_attached :rating_image

  def rating_image_url
    return nil unless rating_image.attached?

    # Generate URL with proper extension for portal compatibility
    rating_image.url
  end
end

ActiveStorage handles S3 uploads, URL signing, and CDN integration automatically. The URLs work across development (local storage) and production (S3) without code changes.

Ensuring File Extensions for Portal Compatibility

Property portals often require image URLs with explicit file extensions (.png, .jpg). ActiveStorage blob keys don't include extensions by default. We created a background job ensuring all image blobs have proper extensions:

class EnsureBlobKeyExtensionJob < ApplicationJob
  queue_as :low

  def perform(blob_id)
    blob = ActiveStorage::Blob.find(blob_id)

    # Check if key already has extension
    return if blob.key.include?('.')

    # Determine extension from content type
    extension = extension_for_content_type(blob.content_type)
    return unless extension

    # Generate new key with extension
    new_key = "#{blob.key}#{extension}"

    # Copy blob to new key in cloud storage
    copy_blob_to_new_key(blob, new_key)

    # Update blob record
    blob.update_column(:key, new_key)
  end

  private

  def extension_for_content_type(content_type)
    case content_type
    when 'image/png' then '.png'
    when 'image/jpeg' then '.jpg'
    when 'image/gif' then '.gif'
    else nil
    end
  end
end

This job triggers via ActiveStorage initializer:

# config/initializers/active_storage_extensions.rb
Rails.application.config.after_initialize do
  ActiveSupport.on_load(:active_storage_blob) do
    after_create_commit do
      EnsureBlobKeyExtensionJob.perform_later(id) if image?
    end
  end
end

Now all images—including EPC rating graphics—have URLs ending in proper extensions, ensuring portal compatibility.

Rightmove Integration

Generated EPC images include automatically in Rightmove property payloads:

class RightmovePropertySerializer
  def build_media_section
    media = []

    # Property photos
    media += property_photo_urls

    # EPC rating graph
    if @property.current_epc&.rating_image&.attached?
      media << {
        url: @property.current_epc.rating_image_url,
        caption: "Energy Performance Certificate",
        type: 11  # Rightmove code for EPC graphs
      }
    end

    media
  end
end

This ensures properties advertised on Rightmove include EPC graphics automatically, improving listing quality and tenant engagement.

Testing Image Generation

Testing image generation requires both unit tests (SVG building logic) and integration tests (full generation pipeline):

RSpec.describe EpcImageService do
  it "generates valid SVG with rating bands" do
    epc = create(:epc, current_energy_rating: 'C', current_energy_efficiency: 72)
    service = described_class.new(epc)

    svg = service.build_svg_template

    expect(svg).to include('<svg xmlns="http://www.w3.org/2000/svg"')
    expect(svg).to include('fill="#8dce46"')  # C rating colour
  end

  it "positions markers correctly based on ratings" do
    epc = create(:epc, current_energy_rating: 'B', potential_energy_rating: 'A')
    service = described_class.new(epc)

    svg = service.build_svg_template

    expect(svg).to include('y="75"')   # B rating position
    expect(svg).to include('y="25"')   # A rating position
  end
end

RSpec.describe EpcImageGenerationJob do
  it "generates and attaches PNG image" do
    epc = create(:epc, current_energy_rating: 'C')

    expect {
      described_class.perform_now(epc.id)
    }.to change { epc.reload.rating_image.attached? }.from(false).to(true)

    expect(epc.rating_image.content_type).to eq('image/png')
  end

  it "includes proper file extension in URL" do
    epc = create(:epc_with_image)
    url = epc.rating_image_url

    expect(url).to end_with('.png')
  end
end

These tests verify both SVG generation logic and the complete pipeline from data to portal-ready images.

What's Next

The EPC image generation foundation enables several enhancements: customisable branding (agency logos on EPC graphics), multi-language support (Welsh translations for Welsh properties), and responsive sizing (different dimensions for different portals).

Future improvements might include animated graphics showing rating improvement potential, interactive versions for agency websites where tenants can explore recommendations, and comparison graphics showing how a property's efficiency compares to similar properties in the area.

By automating EPC graphic generation, LetAdmin ensures every property has professional energy efficiency visualizations without manual design work—improving portal listing quality whilst reducing administrative overhead.