class

Hwaro::Content::Seo::OgPngRenderer

Inherits Reference / Object

Renders OG images directly as PNG using stb_truetype + stb_image_write. No external tools required — all rendering is done in-process.

Constants

BOLD_FONT_SEARCH_PATHS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/noto/NotoSans-Bold.ttf", "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf", "/usr/share/fonts/google-noto/NotoSans-Bold.ttf"]

Bold font variants to search for

BUNDLED_FONT_BOLD = {{ read_file("/tmp/tmp.neEHML/src/src/content/seo/../../ext/fonts/DejaVuSans-Bold.ttf") }}

Bundled DejaVu Sans Bold font (compiled into the binary as the wide-coverage Latin/Cyrillic/Greek fallback at the end of every font chain).

BUNDLED_FONT_DISPLAY = {{ read_file("/tmp/tmp.neEHML/src/src/content/seo/../../ext/fonts/SpaceGrotesk-Bold.ttf") }}

Bundled brand fonts (OFL 1.1 — license texts live next to the TTFs). Space Grotesk carries titles and descriptions; JetBrains Mono carries the terminal style. Static instances, not variable fonts — stb_truetype ignores variation axes.

BUNDLED_FONT_MONO = {{ read_file("/tmp/tmp.neEHML/src/src/content/seo/../../ext/fonts/JetBrainsMono-Bold.ttf") }}
BUNDLED_FONT_TEXT = {{ read_file("/tmp/tmp.neEHML/src/src/content/seo/../../ext/fonts/SpaceGrotesk-Medium.ttf") }}
CHANNELS = 4
CJK_FONT_SEARCH_PATHS = ["/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/Library/Fonts/Arial Unicode.ttf", "/System/Library/Fonts/AppleSDGothicNeo.ttc", "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"]

CJK-capable system fonts. A title/description containing Hangul, kana, or Han ideographs renders as blank "tofu" boxes in the Latin-only fonts above; these cover CJK and Latin, so swapping the whole font to one of these renders mixed "Noir v1.0 — 한국어" lines correctly. Ordered by coverage breadth, then likelihood of being installed.

FONT_SEARCH_PATHS = ["/System/Library/Fonts/Helvetica.ttc", "/System/Library/Fonts/ArialHB.ttc", "/System/Library/Fonts/Geneva.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf", "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", "/usr/share/fonts/google-noto/NotoSans-Regular.ttf"]

System font search paths (platform-dependent)

HEIGHT = 630
WIDTH = 1200

Class methods

available?

Check if PNG rendering is available (always true thanks to bundled fonts)

Source
build_base_layer(config : Models::Config, bg_image_path : String | Nil = nil, cached_bg : CachedImage | Nil = nil) : Bytes

Pre-render the config-only layers (background fill, background image + overlay, style pattern, top accent bar) into a reusable RGBA buffer that can be memcpy'd into each per-page pixel buffer. On large sites these layers account for the bulk of the per-page cost — the "gradient" pattern alone touches every one of the 756,000 pixels in Crystal-level math — so doing them once per build instead of once per page is the largest single win in PNG OG generation.

Z-order is preserved: the original render order is bg → bg image → pattern → top bar → text → logo → bottom bar, and the remaining per-page work (text, logo, bottom bar) is layered on top of this base in the same order.

Source
chain_measure(chain : Array(FontEntry), px_size : Float32, text : String, tracking : Float32 = 0_f32) : Float32

Measure text at px_size across the chain. tracking adds a fixed per-character advance (used for eyebrow/brand labels).

Source
chain_render(chain : Array(FontEntry), pixels : Pointer(UInt8), x : Float32, y_top : Float32, px_size : Float32, text : String, color : UInt32, opacity : Float32, tracking : Float32 = 0_f32) : Float32

Render text at px_size with per-glyph font fallback. y_top is the top of the text box (same convention as hwaro_font_render_text: baseline = y_top + scale * ascent). Returns the final x position.

Source
drop_missing_glyphs(chain : Array(FontEntry), text : String) : String

Chain variant: drop a codepoint only when no font in the chain covers it.

Source
drop_missing_glyphs(info : LibStb::HwaroFontInfo, text : String) : String

Drop codepoints info has no glyph for. stb draws missing glyphs as blank "tofu" boxes — emoji are the common case, since color emoji fonts can't be loaded here — so degrade "🔥 Title" to "Title" instead of "☐ Title". Whitespace always passes; leftover double spaces from removed runs are collapsed. Returns the original string untouched when everything is drawable.

Source
find_cjk_font

First installed CJK-capable font, or nil if none is available.

Source
find_system_font(bold : Bool = false) : String | Nil

Try to find a system font, returns file path or nil

Source
font_has_glyph?(info : LibStb::HwaroFontInfo, codepoint : Int32) : Bool

True if the font defines a glyph for codepoint (delegates to stb's glyph lookup). Used to verify CJK coverage.

Source
load_fonts(custom_font_path : String | Nil = nil, prefer_cjk : Bool = false) : FontContext | Nil

Load fonts once, return a reusable context of per-role fallback chains. Priority within every chain: user font_path → bundled brand font (Space Grotesk / JetBrains Mono) → CJK-capable system font when the content needs it → bundled DejaVu Sans Bold (wide coverage). A user font no longer replaces everything — it merely leads the chain, so glyphs it lacks still fall back to the bundled fonts. Results are memoized so repeated calls (fast-start priority + deferred passes, or watch rebuilds) do not re-scan the filesystem or re-parse TTF data.

Source
load_image(path : String, target_w : Int32, target_h : Int32) : CachedImage | Nil

Decode an image file and resize it to target dimensions. Returns nil on failure.

Source
parse_hex_color(hex : String) : UInt32

Parse "#RRGGBB" to 0xRRGGBB. Also accepts "#rgb" shorthand and "#rrggbbaa" (alpha dropped); falls back to black for invalid input.

Source
render_png(page : Models::Page, config : Models::Config, png_path : String, logo_image_path : String | Nil = nil, bg_image_path : String | Nil = nil, font_ctx : FontContext | Nil = nil, cached_logo : CachedImage | Nil = nil, cached_bg : CachedImage | Nil = nil, base_layer : Bytes | Nil = nil) : Bool

Render OG image directly to PNG file. Returns true on success. Accepts optional pre-loaded CachedImage for logo/background to avoid repeated decode+resize when generating many pages.

When base_layer is given, the config-only layers (background, pattern, top accent bar) are memcpy'd from it instead of being re-rendered per page. Callers that render many pages should build the base layer once via build_base_layer and pass it in here.

Source

Nested types