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!