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
LEXICAL_OUTPUT_ROOT = "/hwaro-output-root"

Stand-in output directory for callers that don't know the real one (assign_lazy_urls receives only the config). Containment is decided lexically — File.expand_path resolves "."/".." without touching the filesystem — so any sufficiently deep absolute root gives the same verdict a real output directory would.

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
MAX_SLUG_BYTES = 240

The OG image slug for a page. Uses the URL-based slug to avoid collisions between pages with the same title in different sections (e.g. /posts/hello/ and /guides/hello/).

The gsub("/", "-") collapses distinct URLs that differ only in slash-vs-hyphen placement (e.g. /posts/foo/ and /posts-foo/) onto the same slug. Left unresolved, the second page overwrites the first's manifest entry and both Pass-2 workers issue a concurrent File.write to the SAME .png/.svg path (torn file under -Dpreview_mt), and one page advertises an OG image rendered for the other. Append a short stable hash of the URL so each distinct URL owns a path. seen_slugs (slug => owning page.url) carries that disambiguation state across calls within one pass. Longest bare slug: + ".svg"/".png" (4) + "-<8 hex>" collision suffix (9) stays under the 255-byte NAME_MAX.

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 = 5

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. rev 3: 2026-09 logo keeps its aspect ratio inside the LOGO_SIZE box. rev 4: 2026-10 PNG titles hard-break segments wider than the line. rev 5: 2026-10 untitled pages headline the site title; the masthead and editorial kickers are XML-escaped; JPEG backgrounds and logos honour their EXIF orientation.

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
assign_lazy_urls(pages : Array(Models::Page), config : Models::Config) : Int32

Lazy serve mode ([og.auto_image] lazy_generate = true): assign each eligible page the URL its OG image WILL live at without rendering anything, so page HTML carries the same og:image meta a full build would emit. The dev server's OgLazyImageHandler generates the file on first request. Returns the number of pages assigned.

The extension assumes PNG rendering will be available when the format asks for it; if font initialization fails at request time the handler serves the SVG fallback bytes under the advertised path (dev-only pragmatism).

Source
auto_assigned?(image : String | Nil, ai : Models::AutoImageConfig) : Bool

True when image was assigned by auto-OG generation — it points into the auto-OG output directory — rather than set by the user. Used by the serve incremental re-parse to preserve the assignment (front matter alone can't restore it) and by Pass 1 above so a preserved URL doesn't read as a "custom image" that blocks regeneration.

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_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
display_title(page : Models::Page, config : Models::Config) : String

The headline of a card: the page title, or the site title for an untitled page (the blog scaffold's home page has title = "") — the fallback og:title, feeds and llms.txt already apply.

Source
eligible_for_auto_image?(page : Models::Page) : Bool

Pages auto-OG generation applies to (mirrors the Pass-1 filters, minus the custom-image skip which callers evaluate themselves).

A page that publishes no file of its own — it lost an output-path collision, or its URL traverses out of the output directory — has nothing to advertise a card for, and its page.url-derived slug produced names like posts-..-..-escape.png. An unpublished page (future/expired kept by a preview flag) follows the draft rule: it renders for preview but stays out of generated assets.

Source
file_to_data_uri(file_path : String) : String | Nil

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, forced_slug : String | Nil = nil, static_copied : Proc(String, Bool) | Nil = nil)

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.

forced_slug is for single-page callers (the dev server's OgLazyImageHandler) whose page already advertises a slug computed by assign_lazy_urls against the FULL page set: recomputing the slug here with a fresh seen_slugs would collapse slug-colliding pages (/posts/foo/ vs /posts-foo/) onto one file. Only meaningful when pages holds exactly one page.

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
published_outputs(config : Models::Config, output_dir : String) : Array(String)

The files the last generate call left published: every image its manifest records (PNG or the SVG fallback, whichever is on disk) and the manifest itself. The hook claims them with the builder, so the Finalize prune removes them once a build stops generating — the generator's own manifest prune never runs when auto images are switched off or --skip-og-image is passed, and a warm --cache build then kept publishing every image a cold build no longer has.

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
slug_for(page : Models::Page, seen_slugs : Hash(String, String)) : String
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