Project

loomy

0.0
There's a lot of open issues
Loomy is a high-performance, declarative Ruby DSL for image composition and manipulation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 6.0
~> 13.0

Runtime

~> 2.3
~> 2.7
 Project Readme

Loomy Logo

Loomy ๐Ÿงถ

The friendly pixel-weaver for Ruby.

Gem Version License Tests Status


Loomy is a modern, high-performance image processing engine for Ruby. Think of it as a master weaver for your images: it takes raw layers (the threads) and weaves them into complex compositions using a smart, declarative DSL.

Built on top of libvips, Loomy resolves your whole composition โ€” sizes, positions, load resolutions โ€” before it touches a pixel, so every source is decoded exactly once, at exactly the size it ends up.

๐Ÿš€ Key Features

  • Batch composition: every layer is flattened in a single libvips composite call, not one composite per layer.
  • Layout before pixels: a measure/arrange pass resolves geometry up front, so each source is loaded straight at its final size instead of being decoded and then resized.
  • Hierarchical Groups: nest layers, groups and stacks to build complex layouts with shared effects.
  • Intelligent DSL: declarative, block-based syntax. Each node kind accepts only what it can actually use, so typos and misplaced properties fail with a message instead of being ignored.
  • Extensible Effects: register your own processors; custom effects take part in optimisation like the built-ins do.

How a render runs

DSL  โ†’  AST  โ†’  Pruner  โ†’  Layout (measure โ‡ข arrange)  โ†’  Renderer  โ†’  Vips::Image

The AST is immutable, so a tree can be rendered more than once and always produces the same image. Layout writes its results to a side table of frames rather than back into the tree.

๐Ÿ“ฆ Installation

Add this line to your application's Gemfile:

gem 'loomy'

And then execute:

bundle install

๐Ÿ›  Usage

1. Generating Images

Loomy offers two ways to get your results: render (write to file) and generate (get a Vips::Image object).

Render to file

Loomy.render("output.png", size: [1200, 630]) do
  layer "background.jpg"
end

Generate in-memory (Web Servers / Testing)

# Returns a Vips::Image object
image = Loomy.generate(size: [1200, 630]) do
  layer "background.jpg"
end

# Get binary buffer for HTTP response or S3
buffer = image.write_to_buffer(".png")

Canvas options

These describe the canvas itself. Every other option is forwarded to libvips as a write option (quality:, compression:, โ€ฆ), and all three entry points split them on the same list, so what generate honours render and to_blob honour too.

Option Default What it does
size: auto [width, height] in pixels. Omitted, the canvas is sized from the extent of its children.
dpi: none Resolution written into the output's metadata.
premultiplied: false Tells libvips the images being composited already carry premultiplied alpha.

premultiplied: exists to reproduce a pipeline that makes that claim โ€” it is not a description of Loomy's own layers, which are straight-alpha. It changes the arithmetic of every composite in the render, nested groups and stacks included, and it is a no-op wherever alpha is 255, so it only shows up when something translucent is blended. See Known limits for what it does to the output.

2. Hierarchical Groups

Group layers to apply effects or positioning to a set of nodes collectively.

Loomy.render("banner.png", size: [800, 400]) do
  group x: 50, y: 50 do
    layer "icon.png", width: 50
    layer "text.png", x: 60

    # Apply blur to the entire group
    blur radius: 2
  end
end

3. The Smart DSL

Forget about complex argument lists. Describe your image layout naturally:

require 'loomy'

Loomy.render("output.png", size: [1200, 630]) do
  # Background
  layer "background.jpg" do
    fit :cover
    blur radius: 10
  end

  # Overlay
  layer "avatar.png" do
    x :center
    y :center
    width "20%"
    trim true # Crop away the transparent border
  end
end

4. Reusable Styles

Define common looks and apply them anywhere.

# Define a style
Loomy.define_style :hero_layer do
  x 50
  y 100
  blend :overlay
end

Loomy.render("post.png", size: [1000, 1000]) do
  layer "texture.png" do
    use :hero_layer # Apply the style
    width 500       # Override or extend
  end
end

5. Stacks

Lay children out along an axis. align/valign set cross-axis alignment (whichever axis the children are not stacked along); distribute sets main-axis distribution.

Loomy.render("card.png", size: [400, 600]) do
  vstack spacing: 16, align: :center, distribute: :space_between do
    layer "logo.png", width: 120
    layer text: "Sold out", size: 32, color: "#111"
    layer "footer.png", width: :fill
  end
end

6. Opacity

opacity: takes a share of full opacity, from 0.0 (invisible) to 1.0 (unchanged). It works on layers, groups and stacks:

layer "watermark.png", opacity: 0.35

group opacity: 0.5 do
  layer "badge.png"
  layer text: "50% off", size: 24, color: "#fff"
end

Fading a group is not the same as fading each of its children. A group composites its children first and the fade applies to that result, so where children overlap they stay at their own relative opacities. Fading them one by one lets each fill in part of what the last left behind.

Two things worth knowing:

  • Effects run first, then opacity. A layer's blur sees the layer at full alpha; the fade happens where the finished layer meets its parent. Reversed, a blur would smear the reduced alpha into its neighbours.
  • opacity: 0 is invisible, not absent. It keeps its slot in a stack and its place in the layout, so opacity: visible ? 1 : 0 does what it reads like. Note that under the operator blend modes (:source, :in, :out, :clear, :xor) an alpha of zero is not "no contribution" โ€” it still clears what is beneath it. That is libvips' Porter-Duff semantics, not a Loomy choice.

7. Sizing and fit

width: and height: take pixels, a percentage of the parent box, or :fill:

layer "art.png", width: 200        # pixels
layer "art.png", width: "50%"      # half the parent box
layer "art.png", width: :fill      # the whole parent box on that axis

"50%" and :fill are both relative to the parent, so both need a parent with a resolvable box on that axis. Without one โ€” a layer or group directly under a canvas that was given no size: โ€” they raise Loomy::LayoutError rather than guess. :fill works on groups and stacks too, not just layers.

fit: says how a source reaches the box it was given:

fit:
:contain scale to fit inside the box, preserving aspect ratio (the default)
:cover scale until it covers the box and crop the overflow
:stretch scale each axis independently to hit the box exactly

width: :fill implies :stretch on its own: it names a box, and the layer has to reach it. fit: :cover still wins over that, because cropping is what you asked for.

trim: crops a source to its content before any of this happens, so the layer measures at the size of what is actually in the file rather than the size of the file:

layer "art.png", trim: true      # by alpha if the source has one, by colour if not
layer "art.png", trim: :auto     # the same thing, spelled out
layer "art.png", trim: :alpha    # the extent of the pixels that are not fully transparent
layer "art.png", trim: :color    # the extent of the pixels that differ from the background colour

:auto is the default and what true means, so writing it out says the same as leaving it off. The two modes it picks between answer different questions, and neither is the other's approximation:

  • :alpha ignores colour entirely and reads the alpha channel. It is exact โ€” a single pixel of content counts โ€” and nothing about the colourspace can change the answer. A source with no alpha channel has no transparent border to find, so it is left whole.
  • :color is libvips' find_trim, which measures distance from a background colour that defaults to white. It is the only mode that can trim opaque artwork with a uniform border, and the only one that can miss: a white subject on transparency is that background once flattened, so the whole image reads as border and nothing is cropped. Greyscale and CMYK sources miss the same way.

A source with nothing to find โ€” fully transparent, or entirely the background colour โ€” keeps its full size.

bounds_of measures the same thing without cropping, for positioning against artwork whose content does not fill its canvas. It takes the same modes, and has to be asked in the mode you crop in:

bounds = bounds_of "art.png", :alpha   # => Loomy::Bounds(x:, y:, width:, height:)

Trimming is a full pixel scan, paid once per source per mode. It is also why a trimmed source cannot be streamed โ€” see Performance.

8. Built-in Effects

Every effect below is declared inside a layer, group or stack block, and applies in declaration order.

Effect Neutral
blur radius: 10 0 Gaussian blur.
grayscale โ€” Desaturates the colour bands; alpha is untouched.
adjust_color brightness: 1.2, contrast: 0.9 1.0 each contrast expands around mid-grey, which is the one value it cannot move; brightness multiplies what comes out of that.
displace map: "m.png", scale: 20 scale: 0 Warps the image by up to scale pixels. A mid-grey map pixel means no displacement; the map's first band drives x and its second y.
relight map: "m.png", type: :soft, strength: 1.0 strength: 0 Washes the map over the image as light. type: is :soft or :hard.

relight's strength scales the map around mid-grey, which both blends leave alone: below 1 softens the light, above 1 hardens it, a negative value inverts it โ€” highlight becomes shadow โ€” and exactly 0 drops the effect.

An effect that cannot change a pixel is dropped before any pixel work happens, so blur radius: 0 costs nothing.

9. Custom Effects & Registry

Define an effect node and register a processor for it:

class Vignette < Loomy::AST::Effect
  def strength = properties[:strength] || 1.0

  # Optional: lets the pruner drop the effect when it cannot change a pixel.
  def no_op? = strength.zero?
end

Loomy.register_effect(Vignette, ->(image, effect, loader) {
  image.my_vips_operation(effect.strength)
})

The third argument is the render's source loader. An effect that reads a file from disk โ€” a displacement or lighting map, say โ€” should ask it rather than opening the file itself, so the read is cached for the render and any orientation tag is applied:

Loomy.register_effect(Overlay, ->(image, effect, loader) {
  target = Loomy::Render::Target.new(width: image.width, height: image.height, fit: :cover)

  image.composite(loader.load_map(effect.map, target), :over)
})

โšก Performance

Measured with bundle exec ruby bench.rb on an Apple Silicon Mac, Ruby 3.3.6, libvips 8.18.5. Composition figures materialise the result to memory, because libvips is demand-driven and Loomy.generate on its own computes no pixels; the end-to-end figures include PNG encoding, which dominates at large sizes.

Reproduce them yourself โ€” the benchmark is in the repository, and the numbers below are only as good as the machine they came from.

Composition (1024ร—1024 canvas, no encoding)

Scenario Throughput
2 layers 430 img/s
5 layers, no effects 335 img/s
5 layers, with blur + grayscale 204 img/s
4200ร—4800 source scaled to 200px inside a group 18 img/s

End to end (render to PNG on disk)

Scenario Throughput
2 layers, 1024ร—1024 91 img/s
3 layers, 4200ร—4800 6.5 img/s
Trim a 2000px source to its 500px of content 6.6 img/s

An earlier version of this table reported "~70 img/s simple" and "~60 img/s complex (5+ layers + effects)". The benchmark behind those numbers passed blur: and grayscale: as keyword arguments, which are not how effects are declared โ€” they landed in the property hash and were ignored, so the "with effects" figure was measured with no effects applied.

Peak memory

render and to_blob write the image once and drop it, so a source the tree reads exactly once can be streamed rather than decoded whole. Two layers over a 4200ร—4800 source grow RSS by +69 MB across five renders in one process, against +142 MB for the same image built with generate and written by hand. It costs nothing in throughput, which is why the table above cannot see it โ€” bundle exec ruby bench.rb reports both.

A source keeps random access when it is read more than once โ€” behind two layers, or behind a layer and an effect map โ€” or when it carries trim: or sits under a displace. generate hands the image back for you to read whenever and however often you like, so it streams nothing.

๐Ÿ—บ Roadmap

  • Percentage geometry (width: "50%"), resolved against the parent box
  • Main-axis distribution on stacks (distribute:)
  • Viewport units (vh, vw)
  • Rounded corners and masking
  • SVG support as layers
  • Smart Saliency Masking (Background Removal)

Known limits

  • SourceLoader::NO_LIMIT caps the unconstrained axis at 10,000px, so a source taller than that on its free axis is scaled down to fit the cap.
  • Loomy.styles and Loomy.effects are process-global; registration is not thread-safe, so register at boot rather than per-request.
  • Loomy.generate hands back an unevaluated image, so a source that fails to decode fails inside your own write_to_file with no Loomy frame on the stack. Graph-building failures are still translated, because those happen before generate returns. Use render/to_blob if you want the decode translated too.
  • premultiplied: true leaves the result premultiplied; Loomy does not undo it on the way out, because undoing it would destroy the reproducibility the option exists for. It only matters where the output has partial alpha: when the bottom layer is opaque and covers the canvas โ€” a product photo under a multiply filter, say โ€” the output alpha is 255 everywhere, the two representations coincide, and only the blend arithmetic changed. Effects on a group under this flag also operate on premultiplied colour, since adjust_color and relight split alpha off assuming straight colour.
  • compositing_space: is deliberately not exposed. libvips supports it, but the only useful value returns a float scRGB image in 0..1, which changes the type generate hands back and the mid-grey pivot every effect is written around.
  • Effect processors are registered per exact class, so a subclass of a registered effect needs its own registration โ€” it raises Loomy::UnknownEffect rather than inheriting the parent's processor.
  • Loomy.render is not atomic. libvips opens the destination before it pulls any pixels, so an error raised part-way through the write can leave a partial file behind โ€” whether it does is the saver's business and varies by libvips version. Write to a temporary path and rename it yourself if you need the destination to be all-or-nothing.
  • rescue Loomy::Error is not a total firewall. AST::Visitor and DSL::NodeBuilder raise NotImplementedError at subclass authors, deliberately outside the hierarchy โ€” and since NotImplementedError < ScriptError, not StandardError, a bare rescue StandardError will not catch it either.

๐Ÿšจ Errors

Everything Loomy raises descends from Loomy::Error, so one rescue covers it. Under that sit exactly two categories, and every concrete error is in one of them:

Category Means Whose fault
Loomy::DeclarationError The declaration could not be honoured as written The caller's
Loomy::ProcessingError The declaration was fine and carrying it out failed Ours, or libvips'

Branch on the category, never on the leaf. A leaf added in a later version lands in the right category on its own, so routing written once keeps working:

begin
  Loomy.to_blob(".png", size: [1200, 630]) { layer params[:image] }
rescue Loomy::DeclarationError => e   # the request asked for something impossible
  render_error(422, code: e.code, message: e.message)
rescue Loomy::ProcessingError => e    # we could not carry it out
  render_error(500, code: e.code, message: e.message)
end

Every error also answers #code with a stable symbol โ€” :invalid_source, :encode_error โ€” for putting on a wire. The category and the code are the stable parts; leaf class names are not. There is deliberately no status_code: whether a missing source is a 404, a 400 or a 422 depends on where the path came from, and only the caller knows that.

The declaration is wrong โ€” Loomy::DeclarationError

Class #code Raised when
SourceNotFound :source_not_found the file does not exist or cannot be read
InvalidSource :invalid_source the file opens but holds no image libvips can decode
InvalidColor :invalid_color a colour value could not be parsed
UnknownStyle :unknown_style use :name names a style never defined
UnknownProperty :unknown_property the node has no such property
InvalidValue :invalid_value the property exists, the value is outside its vocabulary
UnknownEffect :unknown_effect an effect was declared with no processor registered for it
LayoutError :layout_error geometry cannot be resolved โ€” a "50%" or a :fill with no parent box to be relative to

Both halves of a declaration are checked: the property name, and its value.

layer "art.png" do
  aling :center   # Loomy::UnknownProperty โ€” no such property (lists what is available)
  align :top      # Loomy::InvalidValue    โ€” :top belongs to the vertical axis
  width :fil      # Loomy::InvalidValue    โ€” not a size, so it would have read as undeclared
end

align, valign, anchor, fit, trim, distribute, blend, a canvas's premultiplied, a stack's direction, a gradient's direction and relight's type all have closed vocabularies and say what they expected. width, height and opacity are checked too, against a rule rather than a list: anything else would have reached the render as a value it could not use. A width outside the three forms in Sizing and fit reads as no size at all and the node takes the parent box; an opacity outside 0.0โ€“1.0 is refused rather than clamped, because opacity: 50 โ€” a percentage, written the way percentages are written โ€” would clamp to a fully opaque layer and look like it worked.

blend: is the one vocabulary Loomy does not own, so libvips is asked rather than copied โ€” a one-pixel composite, once per mode per process. No list here to drift from the installed version, and the error still names every mode libvips would have taken.

Carrying it out failed โ€” Loomy::ProcessingError

Class #code Raised when
BackendError :backend_error libvips refused an operation the composition asked for
EncodeError :encode_error the finished image could not be written โ€” unknown format, unknown write option, unwritable destination
InternalError :internal_error Loomy reached a state it does not handle. Always a bug in Loomy โ€” please report it

All three wrap the original exception: #cause is the Vips::Error (or, for a write option of the wrong type, the TypeError from ruby-vips), and libvips' own message is quoted under ours. That text is there to be read, never to be matched on โ€” it names the operation that refused and lists what it would have accepted, and it moves between libvips releases.

Loomy also asks libvips to refuse incomplete files, so a partial upload raises InvalidSource rather than rendering half an image against whatever the decoder had in its buffer.

๐Ÿงช Development

bundle exec rake test
bundle exec rubocop

Code smells, via RubyCritic (Reek + Flay + Flog + churn). rake critic opens an HTML report; rake critic:console prints to the terminal and is what CI runs. Both fail below a score floor set in the Rakefile โ€” treat it as a ratchet: raise it when the score rises, never lower it to make a build pass.

bundle exec rake critic

Reek's defaults assume object-heavy application code, and Loomy is a pipeline of passes plus small value objects. .reek.yml turns off the detectors that only ever fire on that shape (UtilityFunction, FeatureEnvy, DuplicateMethodCall, NilCheck, IrresponsibleModule) and says why, in the file. Everything that pointed at something real is still on.

The analysis tools live in their own bundle group, so the test matrix does not install them:

BUNDLE_WITHOUT=lint bundle install

Golden reference images are only exactly reproducible on the libvips build they were rendered with (recorded in test/test_helper.rb). To regenerate them deliberately:

bundle exec rake test:baseline

Known rough edge

Layout::Engine carries all of the geometry and RubyCritic flags it for size โ€” 24 methods, and a [node, box] parameter pair threaded through eight of them. Splitting measure from arrange is awkward because they are mutually recursive (measuring a container arranges its children), so it wants its own change rather than a drive-by.

๐Ÿ“„ License

The gem is available as open source under the terms of the MIT License.