class

Hwaro::Content::Seo::OgImage

Inherits Reference / Object

Auto-generate OG (Open Graph) preview images as SVG files. Produces 1200x630 SVG images with the page title, description, site name, and optional logo — all without external dependencies.

Constants

BAND_ECHO_GAP = 22
BAND_ECHO_H = 8
BAND_HEIGHT = 200
BAND_TOP = 210

band: a full-width solid band behind the (knocked-out) title, echoed by a thin secondary band above it.

BAUHAUS_TEXT_W = 600
BAUHAUS_TEXT_X = 90

bauhaus: flat geometric art composition on the right side.

BRAND_BASELINE = HEIGHT - 64
BRAND_GAP = 14
BRAND_SIZE = 24

Brand row: a small accent tick + the site name in text color.

BRAND_TICK_H = 24
BRAND_TICK_W = 5
BRUTALIST_FRAME = 14
BRUTALIST_INSET = 36

brutalist: thick framed panel with a hard offset shadow block.

BRUTALIST_OFFSET = 20
BRUTALIST_TEXT_X = 88
DESC_LINE_H = 1.45
DESC_MAX_LINES = 2
DESC_OPACITY = 0.62
DESC_RATIO = 0.42
DIAG_WEDGE_X0 = 700

diagonal: stripe wedge in the bottom-right corner triangle (DIAG_WEDGE_X0, HEIGHT) - (WIDTH, HEIGHT) - (WIDTH, DIAG_WEDGE_Y1).

DIAG_WEDGE_Y1 = 150
DOTS_TITLE_TOP = 330

dots: corner-weighted halftone fade; text sits lower-left.

EDITORIAL_KICKER_SIZE = 19
EDITORIAL_KICKER_Y = 132
EDITORIAL_RULE_BOT = 546
EDITORIAL_RULE_TOP = 84
EDITORIAL_RULE_X0 = 80

editorial ("magazine front"): hairline rules + kicker.

EDITORIAL_RULE_X1 = 1120
EDITORIAL_TITLE_TOP = 190
FRAMED_BRACKET_ARM = 40
FRAMED_BRACKET_INSET = 44
FRAMED_BRACKET_W = 3
FRAMED_BRAND_Y = HEIGHT - 88
FRAMED_INSET = 26

framed ("invitation card"): a neutral hairline frame + accent corner brackets; the only centered composition.

FRAMED_TITLE_TOP = 250
FRAMED_WIDTH = 1
FRAMED_WRAP_W = 880
GEOMETRIC_STYLES = {"split", "band", "brutalist"}
GRID_FOCAL_X = 56

grid ("blueprint"): focal crosshair + registration marks.

GRID_FOCAL_Y = 470
HALFTONE_FIELD_X = 660
HALFTONE_TEXT_W = 620
HALFTONE_TEXT_X = 90

halftone: print-style dot field growing toward the right edge.

HEIGHT = 630
LOGO_BOTTOM_OFFSET = 100
LOGO_MARGIN = 80
LOGO_SIZE = 48
LOGO_TEXT_GAP = 12
LOGO_TOP_Y = 20
MARGIN_X = 80

--- Shared typographic design system (SVG + PNG renderers) --- One margin grid, one line-height model, one description hierarchy, and one brand treatment — so the styles differ in composition, not in sloppy metrics.

MASTHEAD_EYEBROW_SIZE = 20
MASTHEAD_EYEBROW_Y = 96

default ("masthead"): eyebrow on top, title anchored high.

MASTHEAD_TITLE_TOP = 210
MIME_TYPES = {".png" => "image/png", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".svg" => "image/svg+xml", ".gif" => "image/gif", ".webp" => "image/webp"}
MODERN_STYLES = {"editorial", "framed", "artistic", "hero", "surreal", "monument"}

--- Style families (single source of truth, shared by SVG + PNG renderers) ---

"Modern" styles are typography/panel-driven and reuse the classic background patterns. "Geometric" styles paint bold, distinctive background shapes (color blocks, bands, frames). "Signature" styles render a complete, self-contained composition (terminal window, bauhaus shapes, halftone fade) and ignore the soft text panel.

MONUMENT_BRAND_RIGHT = 1120
MONUMENT_RULE_H = 6
MONUMENT_RULE_W = 64
MONUMENT_RULE_Y = 208
MONUMENT_TITLE_TOP = 250

monument: accent rule ABOVE the title, brand row bottom-right.

PNG_YIELD_INTERVAL = 8

Yield frequency inside the heavy OG PNG (and fallback SVG) rendering worker loop (see generate()).

Yielding after every single render adds scheduler overhead on CPU-bound work. Yielding too rarely can starve the HTTP accept fiber during serve --fast-start background OG generation.

8 is a pragmatic balance for current workloads.

RENDER_REVISION = 2

Bump to invalidate incremental OG caches when the renderer's design changes without any config change (it feeds compute_config_hash). rev 2: 2026-07 typography + style redesign.

SIGNATURE_STYLES = {"terminal", "bauhaus", "halftone"}
SPLIT_BOTTOM_X = 320
SPLIT_EDGE = 16
SPLIT_TEXT_X = 540
SPLIT_TOP_X = 480

--- Geometric style layout (shared by SVG + PNG so both stay in sync) --- split: a diagonal color block on the left; text lives on the right.

SVG_DISPLAY_FONT = "'Space Grotesk', 'DejaVu Sans', system-ui, -apple-system, sans-serif"

Font stacks for the SVG fallback renderer. They lead with the bundled brand faces so environments that have them render in parity with the PNG output, then degrade gracefully.

SVG_MONO_FONT = "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace"
TERMINAL_BAR_H = 64
TERMINAL_GHOST_ROWS = {300, 440, 240}
TERMINAL_INSET = 36

terminal: a code-editor window with a title bar + traffic lights.

TERMINAL_LIGHTS = {"#ff5f57", "#febc2e", "#28c840"}
TERMINAL_RADIUS = 18
TERMINAL_TEXT_X = 110
TITLE_LINE_H = 1.12
TITLE_MAX_LINES = 3
WAVES_TEXT_REGION_H = 410

waves: text centers over the calm region above the tide bands.

WIDTH = 1200

Class methods

adjust_lightness(hex : String, delta : Float64) : String

Lighten (positive delta) or darken (negative) a hex color in HSL space.

Source
asset_digest(path : String | Nil) : String

Content digest of an on-disk asset (logo / background image). Replacing the file at the same path must invalidate cached OG images — hashing only the path string left stale logos baked into cached images forever.

Source
balanced_word_wrap(text : String, max_chars : Int32) : Array(String)

Balanced title wrap (character-count analog of the PNG renderer's measured version): greedy wrap first; when the last line is an orphan (much shorter than the longest line), re-wrap against a tighter target so line lengths even out. Only accepted when it does not add lines.

Source
band_line_capacity(font_size : Int32) : Int32

How many title lines fit inside the fixed-height color band used by the band style. Beyond this the title overflows the band and, being drawn in the background color, renders invisibly off-band.

Source
cap_band_title(lines : Array(String), font_size : Int32) : Array(String)

Cap a band-style title to the lines that fit the band.

Source
cap_lines(lines : Array(String), max : Int32) : Array(String)

Cap lines to max, marking the last kept line with an ellipsis so the truncation is visible rather than silent.

Source
compute_config_hash(config : Models::Config) : String

Compute a hash of OG-relevant config properties. RENDER_REVISION is folded in so a renderer design change regenerates cached images on existing sites even though no config value changed.

Source
compute_page_hash(page : Models::Page) : String

Compute a hash of page content that affects OG image rendering.

Source
contains_cjk?(text : String) : Bool

True if the text contains CJK ideographs, kana, hangul, or fullwidth forms — glyphs the bundled/system Latin fonts cannot render, so PNG OG images would show blank "tofu" boxes unless a CJK-capable font_path is configured.

Source
derive_secondary(accent_hex : String) : String

Derive a punchy complementary color from a hex string via HSL.

Source
file_to_data_uri(file_path : String) : String

Convert a file to a data URI with base64 encoding

Source
generate(pages : Array(Models::Page), config : Models::Config, output_dir : String, verbose : Bool = false, partial : Bool = false, parallel : Bool = true)

Generate OG images for all pages that lack a custom image. Sets page.image to the generated SVG path so that og:image meta tags pick it up automatically.

partial signals that the caller is only handing in a subset of the site's pages (e.g. --fast-start runs this once for the priority subset and again for the deferred remainder). In partial mode we accumulate manifest entries across calls rather than overwriting; in full mode (default) we still truncate so manifest entries — and the disk files they describe — for deleted pages get pruned naturally.

Source
geometric?(style : String) : Bool
Source
hex_to_hsl(hex : String) : Tuple(Float64, Float64, Float64)

Convert "#RRGGBB" to {hue(0-360), saturation(0-1), lightness(0-1)}.

Source
hsl_to_hex(h : Float64, s : Float64, l : Float64) : String

Convert HSL back to a "#rrggbb" hex string.

Source
load_manifest(manifest_path : String) : Tuple(String, Hash(String, String))

Load the OG manifest file. Returns {config_hash, entries}.

Source
logo_coordinates(position : String) : Tuple(Int32, Int32)

Compute logo (x, y) for a given position string. Shared by both SVG and PNG renderers.

Source
mix_hex(base : String, other : String, t : Float64) : String

Mix two hex colors in RGB space (t toward other). Mirrors the PNG renderer's lerp_color so gradient stops match across formats.

Source
modern?(style : String) : Bool
Source
neutral_line_hex(bg_hex : String) : String

A quiet hairline color derived from the background: slightly lighter on dark backgrounds, slightly darker on light ones.

Source
no_accent_bars?(style : String) : Bool

Styles that drop the classic thin top/bottom accent bars for a cleaner, more modern composition.

Source
normalize_hex(hex : String) : String | Nil

Normalize a user-supplied hex color to a bare 6-digit "rrggbb" form. Accepts "#rgb" shorthand (expanded to "rrggbb"), "#rrggbb", and "#rrggbbaa" (alpha is dropped). Returns nil for anything that isn't a valid hex color so callers can fall back deterministically.

Source
render_style_background(style : String, accent : String, bg : String, secondary : String, has_bg_image : Bool = false) : String

Render each style's signature background: bold geometric shapes (split / band / brutalist) plus generated backdrops for the modern styles (artistic gradient, hero glow, surreal aurora, framed frame). The generated modern backdrops are skipped when a background image is present so a user photo shows through. Returns "" for plain styles.

Source
render_style_pattern(style : String, accent : String, bg : String, opacity : Float64, scale : Float64) : String

Render a style/pattern SVG snippet based on the configured style. Every pattern is a composition with a focal point rather than uniform wallpaper; opacity acts as the peak alpha with internal falloff. Mirrors the PNG renderer's render_pattern.

Source
render_svg(page : Models::Page, config : Models::Config, logo_data_uri : String | Nil = nil, bg_data_uri : String | Nil = nil) : String

Render an SVG image for a page

Source
resolve_secondary(ai : Models::AutoImageConfig) : String

Resolve the second color for two-tone geometric styles. Falls back to a complementary tone auto-derived from the accent color.

Source
save_manifest(manifest_path : String, config_hash : String, entries : Hash(String, String))

Save the OG manifest file.

Source
shift_hue(hex : String, degrees : Float64, min_sat : Float64 = 0.0) : String

Rotate a hex color's hue by degrees, optionally forcing a minimum saturation so derived tones stay vivid on flat compositions.

Source
signature?(style : String) : Bool
Source
split_into_segments(text : String) : Array(String)

Split text into wrappable segments: whitespace-separated words for Latin text, individual characters for CJK ranges. Public so OgPngRenderer can reuse it.

Source