, Štefan Húska
Automatické OG obrázky z titulku článku v Rails pomocou libvips
Keď technický blog nemá k článkom fotografie, zdieľanie na Facebooku či LinkedIn vyzerá pochmúrne — buď sa zobrazí generický placeholder, alebo logo stránky. Ale pridávať vlastný obrázok ku každému článku ručne je zbytočná práca, najmä ak je obsah textový. Riešením je automaticky vygenerovať cover obrázok priamo z titulku — tak, že sa text “vypáli” do grafického podkladu. Výsledok je každý raz vizuálne trochu iný, lebo odráža konkrétny titulok, ale zachováva jednotný štýl blogu.
Ako to funguje
Celá logika generovania žije v jednej triede CoverImageGenerator. Dostane titulok, otvorí PNG šablónu (pripravený podklad so správnymi rozmermi a farbami), pomocou libvips vyrendruje text a “vypáli” ho na podklad ako vrstvu. Výsledok zapíše do StringIO buffera a vráti ho — ready na priamu špecifikáciu do Active Storage attach.
# app/services/cover_image_generator.rb
class CoverImageGenerator
TEMPLATE_PATH = Rails.root.join("app/assets/images/post-cover-placeholder.png").freeze
FONTS_DIR = Rails.root.join("app/fonts").to_s
TEXT_WIDTH = 1800
TEXT_FONT = "Inter 18pt bold 100"
TEXT_DPI = 72
TEXT_SPACING = 40
PADDING_LEFT = 160
PADDING_TOP = 20
USABLE_HEIGHT_RATIO = 0.90
def initialize(title)
@title = title
end
def generate
background = Vips::Image.new_from_file(TEMPLATE_PATH.to_s)
# Make sure libvips finds the bundled Inter font
ENV["FONTCONFIG_PATH"] = FONTS_DIR
text_image = Vips::Image.text(
escape_pango(@title),
width: TEXT_WIDTH, dpi: TEXT_DPI,
font: TEXT_FONT, spacing: TEXT_SPACING
)
# Create a colored text layer: fill with brand color, then apply text as alpha
white = text_image.new_from_image([66, 63, 56])
text_with_alpha = white.bandjoin(text_image).copy(interpretation: :srgb)
# Ensure background has alpha channel
bg_with_alpha = background.has_alpha? ? background : background.bandjoin(255)
# Center text vertically in the usable area
usable_height = (background.height * USABLE_HEIGHT_RATIO).to_i
y_offset = [PADDING_TOP + (usable_height - PADDING_TOP - text_image.height) / 2, PADDING_TOP].max
# Composite text over background, then flatten to RGB
result = bg_with_alpha.composite(text_with_alpha, :over, x: [PADDING_LEFT], y: [y_offset])
result = result.flatten(background: [255, 255, 255])
buffer = result.write_to_buffer(".png")
StringIO.new(buffer)
end
private
def escape_pango(text)
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
end
end
Podkladový obrazok

Výsledný obrázok

Niekoľko komentárov ku kódu:
Vips::Image.textnevráti priehľadnú vrstvu s textom — vráti greyscale obrázok, kde biela = text a čierna = pozadie. Keby si ho priamo položil na podklad, dostal by si biely obdĺžnik s textom, nie text na podklade. Preto treba z tejto masky ručne vybudovať RGBA vrstvu:bandjoinspojí RGB farbu textu s greyscale maskou do štvorice kanálov, a až potom mácomposite(:over)čo s čím pracovať.Vips::Image.textgeneruje obrázok, kde biela = text a čierna = priehľadnosť — je to vlastne alfa maska. Preto treba najprv vytvoriť RGB vrstvu s požadovanou farbou (new_from_image([66, 63, 56])— tmavá hnedá/sivá pre tón blogu) a túto RGB vrstvu spojiť s alfa maskou cezbandjoin. Metódacomposite(:over)potom štandardným Porter-Duff algoritmom vrstvy zlúči. Nakoniecflattenodstráni alfa kanál a výstup je čisté RGB PNG.- Špeciálna pozornosť patrí aj fontu: libvips na serveri nenájde systémové fonty — preto je Inter bundlovaný priamo v repozitári (
app/fonts/Inter_18pt-Bold.ttf) afonts.confodkazuje fontconfig na tento priečinok.ENV["FONTCONFIG_PATH"]treba nastaviť tesne pred volanímVips::Image.text, inak sa font nenačíta.
Integrácia do modelu
Generátor je napojený na Post cez after_save callback s podmienkou, ktorá presne riadi, kedy sa obrázok vygeneruje — a kedy nie:
# app/models/post.rb
after_save :generate_cover_image, if: :should_generate_cover_image?
def should_generate_cover_image?
return false if custom_cover_image?
!cover_image.attached? || saved_change_to_title? || saved_change_to_custom_cover_image?
end
def generate_cover_image
io = CoverImageGenerator.new(title).generate
cover_image.attach(io: io, filename: "cover-#{slug}.png", content_type: "image/png")
end
Podmienka should_generate_cover_image? robí tri veci: ak admin nahrá vlastný obrázok (custom_cover_image?), automatické generovanie sa preskočí úplne. Ak obrázok ešte neexistuje, vygeneruje sa. A ak sa zmení titulok — obrázok sa vygeneruje znova, aby bol aktuálny. Toto je dôležitý detail: bez saved_change_to_title? by pri editácii titulku zostal starý obrázok s pôvodným textom.
Použitie v OG tagoch
V detaile článku sa obrázok jednoducho odovzdá do content_for(:og_image) ako Active Storage URL s variant parametrami pre správne rozmery 1200×630 px, ktoré vyžadujú Facebook aj LinkedIn:
<%# app/views/posts/show.html.erb %>
<% if @post.cover_image.attached? %>
<% content_for(:og_image, url_for(@post.cover_image.variant(resize_to_limit: [1200, 630]))) %>
<% end %>
Ak obrázok z nejakého dôvodu nie je priložený, helper v ApplicationHelper automaticky použije fallback /og-default.png — takže žiadna stránka nezostane bez obrázka pri zdieľaní.
Čo si z toho odniesť
libvips (cez gem ruby-vips) je výborná voľba pre server-side generovanie obrázkov v Rails — je rýchlejší ako ImageMagick a má priamu Ruby integráciu. Vzor maska + bandjoin + composite je univerzálny: rovnako možno pridávať watermarky, prekrývať logá alebo generovať šablóny pre sociálne siete. Kľúčový princíp je izolovanie logiky do service objektu a napojenie na model cez podmienený callback — tak zostane model čistý a generátor nezávisle testovateľný. A bundlovanie fontov priamo do repozitára rieši problém s konzistenciou fontu na lokálnom stroji a produkcii.