ActiveStorage SVG Analyzer

For My Hero's Journey, we're building a new feature to show/hide layers of an SVG image depending on the progress you make. It's used for pretty large/complex images. (see previous post for a custom svg variant transformer)

The backend of the app, attaches layer names to certain tasks that need to be done. When finishing the task, the layer becomes visible.

The frontend is going to show this SVG, with all layers hidden that aren't available yet. (Pretty easy to do with CSS classes)

To make this performant the layer names need to be extracted from the SVG. A good place to make this happen is via an ActiveStorage Analyzer.

A Custom SVG Analyzer

The ActiveStorage::Blob model contains a metadata field, that receives the metadata. The default image analyzer only extracts width and height from the SVG.

Registering a custom anlyzer can be done with an initializer. During development I noticed ActiveStorage only runs one analyzer. The first analyzer that's valid for a given file, is being used.
That's the reason our custom analyzer is prepended to the analyzers array. (The delete/prepend squence is for development-mode reloading)

config/initializers/active_storage_svg_analyzer.rb

ActiveSupport::Reloader.to_prepare do
  Rails.application.config.active_storage.analyzers.delete ActiveStorage::CustomSvgAnalyzer

  # Important! Needs to be prepended to be placed before the othe analyzers. It seems active storage only runs a single analyzer
  Rails.application.config.active_storage.analyzers.prepend ActiveStorage::CustomSvgAnalyzer
end

The interface for an analyzer is pretty simple, the accept? class method needs to return true if the analyzer supports the given file.

The metadata method should return a hash with all metadata, this metadata is placed in the active storage blob record.

A SVG file is just a XML file, so parsing this is pretty easy with for example Nokogiri. In the example below all id's are extracted. (Affinity Designer exports the layer names as id's).

Because only 1 analyzer is run, I also included the width/height metadata attributes. (Though I don't think these are being used)

app/lib/active_storage/custom_svg_analyzer.rb

class ActiveStorage::CustomSvgAnalyzer < ActiveStorage::Analyzer
  def self.accept?(blob)
    blob.content_type == 'image/svg+xml'
  end

  def metadata
    download_blob_to_tempfile do |file|
      doc = Nokogiri::XML(file)
      width, height = extract_dimensions(doc)
      { width:, height:, layer_names: layer_names(doc) }.compact
    end
  end

  private def layer_names(doc)
    doc.xpath("//*[@id]").map { |e| e[:id] }
  end

  private def extract_dimensions(doc)
    view_box = doc.root.attributes["viewBox"]&.to_s
    return [] unless view_box

    left, top, right, bottom = view_box.split(/\s+/).map(&:to_i) # => 0 0 4167 4167

    [right - left, bottom - top]
  end
end

The result

The active_storage_blobs.metadata record now contains the following data:

{"identified":true,"width":4167,"height":4167,"layer_names":["island","island-plant-3","island-plant-2","island-plant-1","island-color-overlay","house","mask","water-well","drum2","drum1","vase3","vase2","vase1","woman-and-child","tree","racoon-tower","racoon1","racoon2","racoon-top-back","_clip1","racoon-middle-back","racoon-bottom","racoon-middle","racoon-top","single-orange","oranges"],"analyzed":true}

Data that is is pretty easy to acces, via the blob.metadata hash. See the asset model below.

models/asset.rb

class Asset
  has_one_attached :file

  def image_layer_names
    content_type_svg? ? Array(file.blob.metadata['layer_names']) : []
  end
end

That's all, thanks for reading!

Install ruby 2.7.8 on FreeBSD 14 (with rbenv)

Yeah I know, it's very old. But unfortunately i needed to use this ruby version for running errbit. (the self hosted error handler)

Trying to install install an older ruby-version on FreeBSD results in the following error (is found in the ruby-build log file )

util.c:236:1: error: expected identifier or '('
ruby_qsort(void* base, const size_t nel, const size_t size, cmpfunc_t *cmp, void *d)
^
./include/ruby/util.h:59:21: note: expanded from macro 'ruby_qsort'
-# define ruby_qsort qsort_r

This is an issue in the ruby code compiling on FreeBSD 14. The issue is mentioned for ruby 3.1 See: https://bugs.ruby-lang.org/issues/20151.
Problem is that the solution isn't backported. There isn't a ruby 2.7.9.

Quick Solution

Create the file ./rbenv/plugins/ruby-build/share/ruby-build/2.7.8-freebsd-14, with the following content

install_package "openssl-1.1.1w" "https://www.openssl.org/source/openssl-1.1.1w.tar.gz#cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8" openssl --if needs_openssl:1.0.1-1.x.x
install_package "ruby-2.7.8" "https://www.blommersit.nl/downloads/ruby-build/ruby-2.7.8-freebsd-14.tgz#58beea1e9e954efb2e7c27a3dcf5817d739049b50ae718c78d4fffe9a1e11c0b" warn_eol enable_shared standard

And install it:

rbenv install 2.7.8-freebsd-14

Further changes to run errbit:

  • remove the ruby-version from the Gemfile
  • change .ruby-version file to match 2.7.8-freebsd-14
  • bundle update puma to a later version (issue with nio4r)

Details

The issue happens because util.c file contains some q_sort function logic, which resolves incorrectly in FreeBSD 14.

The source contains the following patch (Note this is not a correct solution for all platforms, but a quick hack to make it work in FreeBSD 14).

Around line 222 a few defines are undefined

#undef HAVE_BSD_QSORT_R
#undef HAVE_QSORT_S

The patched ruby version is here for download:

The download https://www.blommersit.nl/downloads/ruby-build/ruby-2.7.8-freebsd-14.tgz#58beea1e9e954efb2e7c27a3dcf5817d739049b50ae718c78d4fffe9a1e11c0b

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