, Š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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
  end
end

Podkladový obrazok

post-cover-placeholder.png

Výsledný obrázok

cover-automaticke-og-obrazky-z-titulku-clanku-v-rails-pomocou-libvips.png

Niekoľko komentárov ku kódu:

  • Vips::Image.text nevrá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: bandjoin spojí RGB farbu textu s greyscale maskou do štvorice kanálov, a až potom má composite(:over) čo s čím pracovať.
  • Vips::Image.text generuje 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 cez bandjoin. Metóda composite(:over) potom štandardným Porter-Duff algoritmom vrstvy zlúči. Nakoniec flatten odstrá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) a fonts.conf odkazuje fontconfig na tento priečinok. ENV["FONTCONFIG_PATH"] treba nastaviť tesne pred volaním Vips::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.