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
    ActiveStorage::Transformers::ImageProcessingTransformer.new(transformations.except(:format))
end

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.

lib/active_storage/variation_custom_formatter_extension.rb`

module ActiveStorage::VariationCustomFormatterExtension
  def transformer
    return ActiveStorage::SvgTransformer.new(transformations.except(:format)) if content_type == "image/svg+xml"
    super
  end

  ActiveStorage::Variation.prepend(self)
end

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

config/initializers/activestorage_custom_formatter.rb

ActiveSupport::Reloader.to_prepare do
  ActiveStorage::VariationCustomFormatterExtension
end

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.

config/initializers/active_storage_svg_support.rb

# 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.

lib/active_storage/svg_transformer.rb

class ActiveStorage::SvgTransformer < ActiveStorage::Transformers::Transformer
  def process(file, format:) # :doc:
    content = file.read
    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}"
      end
    end

    out = Tempfile.new(["svg_transformer", ".svg"])
    out.write(content)
    out.rewind
    out
  end

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

  # Extract the hex and alpha part of a hex color
  # - #RGBA => #RGB, AA
  # - #RRGGBBAA => #RRGGBB, 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, ""]
    end
  end

  # { 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)
    end
    content
  end
end

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!

Turbo Frame Reload Error: “element has a source URL which references itself”

I love Marco's turbo_power library for adding some extra actions to turbo. For example the turbo_frame_reload which should reload a given turbo frame.

But the turbo_frame_reload didn't work in my situation.

I've got an index view which renders all orders in a turbo-frame (with pagination).
Pressing a button next to an order should tag the order and reload the orders page.
Tagging is an action in other controller, which doesn't know anything about this order screen. But after tagging it should refresh the turbo frame with the orders.

I thought this could be easy by refreshing the turbo_frame with a turbo_stream action turbo_frame_reload. This only works if the frame has a src attribute in it.

When I set src on the this frame I get the error: element has a source URL which references itself. So this isn't working.

Workaround I created for now is the following.

I add an extra turbo_stream action named: turbo_frame_reload_with_data. This sets the source on the attribute data-src when the src attribute is empty/missing. (that's on the initial load).

So define the turboframe like this:

<%= turbo_frame_tag :orders, data_src: request.original_url do %>
.. content .. 
<% end %>

To make this work the following code is added to the application.js, adding a custom turbo-stream action.
This action sets the src of the turbo-frame when a turbo_frame_reload_with_data action is invoked

Turbo.StreamActions.turbo_frame_reload_with_data = function() {
  this.targetElements.forEach((element) => {
    let src = element.getAttribute('src') || element.getAttribute('data-src')
    element.src = ''
    element.src = src
  })
}

Note: Always setting src with data-src doesn't work. The data-src isn't updated on turbo-frame updates, only the src-flag is.

To make the rails experience nicer, a helper is added to the turbo_stream tag builder.

module TurboStreamActionsHelper
  def turbo_frame_reload_with_data(target = nil, **attributes)
  custom_action :turbo_frame_reload_with_data, target:, attributes:
  end
end
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)

In the controller performing the action, I render the following turbo-stream action. To refresh after this, the following can be returend.

render turbo_stream: turbo_stream.turbo_frame_reload_with_data('orders'),

References

Ruby remove ternary conditions

Somethimes you want to do something conditionally depending on a boolean.
(I don't like the double question mark in ternary-if)

For example:

# add a class to an element
tag.div(class: selected? ? "sample" : nil)

# executing code conditionaly:
if selected? 
    #.. code
end

This code could be rewritten like this:

# add a class to an element
tag.div(class: selected? { "sample" })

# executing code conditionaly: (this could be done, no per se an improvement)
selected? do
    #.. code
end

To use the construct above, give the boolean operation the following content.

  • if a block is given and the condition is true it invokes the block else it returns nil
  • if no block is given, the boolean is returned
def selected?
    return selected? ? yield : nil if block_given?
    @selected
end

:-)

Rails add Turbo / Hotwire to existing sprockets application

Yesterday I've added turbo/hotwire to a big rails application which evolved over several years from rails 3, to 4 to 5 to 6 and now to rails 7.

The 6-7 version updates went pretty smooth. But now we would like to rewrite our javascript-coffeescript kruft to a more modern approach.

The app is very big, so directly replacing everything is virtually impossible. That's why I introduced an incremental approach.

The 7 version is still running sprockets. (Glad I didn't introduce webpacker in version 6, which would have resulted in an even bigger kruft).

New situation

The new situation will keep sprockets for the current javascript/coffeescript, sass css and other assets.

It will introduce esbuild for building the new javascript.

The directory structure used is the following:

  • app/assets/builds: is the output folder of esbuild javascripts
  • app/assets/javascripts: contains the legacy scripts
  • app/javascript: contains the new javascripts

Changes in Gemfile

- gem uglifier
+ gem terser 

+ gem turbo-rails
+ jsbundling-rails
+ stimulus-rails

Configure sprockets / deployment

Sprockets needs to to include the esbuild build directory to embed the new javascript content.
Changes in app/assets/config/manifest.js

+//= link_tree ../build/

Add the build directory to the assets paths in config/initializers/assets.rb

Rails.application.config.assets.paths << Rails.root.join('app/assets/builds')

Uglifier was crashing on production deployment of the esbuild javascript files (those are already handled). This was solved be replacing uglifier with terser in my Gemfile.

This also needs to be enabled in the config/environments/production.rb file.

- config.assets.js_compressor = Uglifier.new(harmony: true)
+ config.assets.js_compressor = :terser

Required legacy javascript changes

Every legacy javascript file that uses the ready eventhandler is replaced by the turbo:load event

- $(document).ready ->
+$(document).on "turbo:load", ->

New javacript changes

The new new javascript files cannot be called application.js because it has the same name as the legacy name. Because the same name is generated to solve this, I use app/javascript/app.js:

// Entry point for the build script in your package.json

import { Turbo } from "@hotwired/turbo-rails"
window.Turbo = Turbo

import "./controllers"

Contents of app/javascript/controllers/index.js

// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName

import { application } from "./application"

import LegacyJsController from "./legacy_js_controller"
application.register("legacy-js", LegacyJsController)

Load the new javascripts

Add the new javascript include to the layout template of the project and `views/layouts/application.html.erb'

= <%= javascript_include_tag "application", defer: true %>
+ <%= javascript_include_tag "app", defer: true %>

- <body>
+ <body 'data-turbo'= <%= @turbo ? true : false %>' >

Because Turbo requires 422 status code on invalid form result, I've disabled Turbo by default. I've tried enabling it by default, which worked pretty good except for form-validation errors. And there are a LOT of places this happen, so for the incremental update it's better to slowly convert/change all pages for using turbo.

When a controller-action uses turbo it can set the @turbo variable to true.
The idea it to slowly introduce this to every controller. When I'm confident it works (almost) everywhere this can be inverted/removed.

Add hotwire/turbo to the package.json file

Contents of package.json

{
 "name": "projectname",
 "private": true,
 "dependencies": {
   "@hotwired/stimulus": "^3.0",
   "@hotwired/turbo-rails": "^7.3.0",
   "esbuild": "^0.17.11"
 },
 "scripts": {
   "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets"
 },
 "version": "0.1.0"
}

(Build with yarn)

Running the app

Running the app now happens via bin/dev which uses Foreman so esbuild automaticly builds the new javascript:

web: unset PORT && bin/rails server
js: yarn build --watch

How it is going

Currently I'm slowly moving legacy javascript methods to stimulus controllers.
Specialised autocomplete-inputs, select2 inputs are converted ony by one to stimulus controllers.

My experience is that simulus is very good in auto-enabling inputs on loading ajax content.
Even my legacy html-updates via ajax are updated by stimulus when they are introduced into the DOM.
I really like this solution, it's possible to keep the old legacy javascript running and build new things with the turbo/stimulus approach

Rails mysql structure.sql dump contains AUTO_INCREMENT

When rails generates a structure.sql dump for MySQL it contains the AUTO_INCREMENT value. Which is anoying because this is not something you want to happen.

CREATE TABLE `active_storage_variant_records` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `blob_id` bigint NOT NULL,
  `variation_digest` varchar(255) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_active_storage_variant_records_uniqueness` (`blob_id`,`variation_digest`),
  CONSTRAINT `fk_rails_993965df05` FOREIGN KEY (`blob_id`) REFERENCES `active_storage_blobs` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=471 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Look at the part: AUTO_INCREMENT=471

After digging through the activerecord code, I saw I could sneak in a mysqldump option for the dump call. Yeah! 👍🏻

But there isn't such option 😕. It's an ancient BUG/Feature of mysql, which of course still isn't resolved in MySQL 8. https://bugs.mysql.com/bug.php?id=20786

Why would you like to dump the table structure (without data) with the AUTO_INCREMENT value!?

As a workaround it's possible to enhance the db:schema:dump task in a custom rake file (lib/tasks/remove_autoincrement_from_dump.rake).
So the AUTO_INCREMENT part is removed from it.

Rake::Task['db:schema:dump'].enhance do
  structure_sql_path = Rails.root.join("db/structure.sql")
  if File.exist?(structure_sql_path)
    sql = File.read(structure_sql_path)
    File.write(structure_sql_path, sql.gsub(/AUTO_INCREMENT=[0-9]+/, ""))
  end
end

References: