in Hacking

ActiveStorage SVG Variant Transformer

In a project I'm working on (My Hero's Journey), we use a lot SVG's for the artwork of the application. The application sometimes needs a full colored image of the SVG. Other times it needs change a certain accent color. We would love to use the ActivateStorage variant support for this. It would be a good use case for these images. Just generate it once, store it and return the different variations.

The original image. (In this image the islands are colored full red (#FF0000 with transparency))

A variant of this is image is to replace the red color (#FF0000) with green.

Or changing the hue to a given color.

The problem

Active Storage supports variants for images. Though this doesn't include SVG's, because it's a vector image instead of an rasterized image.

Under the hood the ActivateStorage uses a transformer class. ActiveStorage::Transformers::Transformer

When you look at the code, at first glance it looks like it should be able to support multiple transformers. At the moment using a different transformer is not (yet) available in ActiveStorage.

The cause of this, is the definition of the transformer in the
ActiveStorage::Variation class. It's always the ImageProcessingTransformer:

def transformer

My Solution for Now

A workaround for not being able to change the transformer class, can be done by patching the ActiveStorage::Variation#tranformer method. Make sure the following class is loaded by an initializer.


module ActiveStorage::VariationCustomFormatterExtension
  def transformer
    return if content_type == "image/svg+xml"


Just invoke the constant on initialization to make Zeitwork load it.


ActiveSupport::Reloader.to_prepare do

A general implementation could to add a factory, which creates a tranformer based on a given mime-type.

To use SVG's as images in ActiveStorage, and allow variations the following configuration options are required.


# SVG shouldn't be served as binary.
Rails.application.config.active_storage.content_types_to_serve_as_binary -= ['image/svg+xml']

# Make the default content-disposition of the svg's inline. (just like another image)
Rails.application.config.active_storage.content_types_allowed_inline += ['image/svg+xml']

# Make the svg content type 'variable', so variations are enabled
Rails.application.config.active_storage.variable_content_types += ['image/svg+xml']

ActiveStorage Transformer

An ActiveStorage transformer in rails uses a hash with transformations that are applied to the given image.
For example, a variant of rasterized image can be defined by passing several operations/settings:

image_tag asset.file.variant(
  resize_to_limit: [100, 100],
  format: :jpeg,
  saver: { subsample_mode: "on", strip: true, interlace: true, quality: 80 })

A simple SVG transformer

We could also define all kinds operations for SVG files. In our application I've defined two operations (for now). replace_color, to replace a color by another, and set_hue, to change the hue of an image.

image_tag asset.file.variant(set_hue: "#FF0000", format: :svg)

Below is the implementation of this transformer class. (It uses the color_conversion gem, to perform rgb to hsl calculations)

The main entry point that's being called by ActiveStorage is process. This method is called with two arguments, file and format. The file is a temporary file which contains the original data. When you don't rewind the tempfile, you get ActiveStorage::IntegrityErrors. Because ActiveStorage requires the file to be open at the first byte in the file.

The transformations applied to this formatter can be accessed via the method transformations.


class ActiveStorage::SvgTransformer < ActiveStorage::Transformers::Transformer
  def process(file, format:) # :doc:
    content =
    transformations.each do |name, data|
      case name
      when :set_hue then content = set_hue(content, data)
      when :replace_color then content = replace_color(content, data)
      else raise "SVG Transformation not supported #{name}"

    out =["svg_transformer", ".svg"])

  # { hue: #EE00AA }
  private def set_hue(content, color_in)
    hex_color_regexp = /#[A-F0-9]{3,8}/i
    color_tone =
    content = content.gsub(hex_color_regexp) do |hex_in|
      hex, alpha = extract_alpha(hex_in)
      color =
      new_color = color_tone[:h], s: color[:s], l: color[:l])

  # Extract the hex and alpha part of a hex color
  # - #RGBA => #RGB, AA
  private def extract_alpha(hexa)
    case hexa.length
    when 5 then [hexa[0..3], hexa[4] * 2]
    when 9 then [hexa[0..6], hexa[7..8]]
    else [hexa, ""]

  # { replace_color: { "#FF0000" => "#4455aa" , ... } }
  private def replace_colors(content, key_value_map)
    key_value_map.each do |key, value|
      content = content.gsub(/#{key}/i, value)

The SVG images are drawn with Affinity Designer and exported with hex color codes. At the moment the colors of the images are simply adjusted via a regexp replace. (Full XML parsing/building could be used for complex operations)

And that's all!

I'm pretty happy with the solution. The usage of variants makes use of the groundwork of ActiveStorage handling. Which generates it just once and store it on disk. And automaticly deletes the variants when the main image is deleted.

It would be very nice if ActiveStorage added support for multiple Variant transformers for different content-type. Audio transformers, PDF transformers, CSV transformers, or docx variations. (There is a related PR request about this feature. But it seems to be stalled)

Btw, I'm aware you can do very much with SVG on the browser side, like CSS coloring and JS dynamics. This works very well when rending an SVG inline in an HTML file. When using external SVG's as images this becomes a lot harder!