defmodule ZoeyscomputerWeb.GistLive.OgImage do @moduledoc """ Generates OpenGraph preview images for Gists using SVG with syntax highlighting. """ require Logger @external_resource "priv/static/fonts/WOFF2/Iosevka-Bold.woff2" @external_resource "priv/static/fonts/WOFF2/Iosevka-Regular.woff2" @font_bold File.read!("priv/static/fonts/WOFF2/Iosevka-Bold.woff2") @font_regular File.read!("priv/static/fonts/WOFF2/Iosevka-Regular.woff2") @line_height 36 @code_start_y 210 @code_start_x 80 @font_size 24 @char_width 12 defp font_face_styles do """ @font-face { font-family: 'Iosevka'; src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_regular)}) format('woff2'); font-weight: 400; font-style: normal; } @font-face { font-family: 'Iosevka'; src: url(data:font/woff2;charset=utf-8;base64,#{Base.encode64(@font_bold)}) format('woff2'); font-weight: 700; font-style: normal; } """ end def generate_preview(gist) do code_lines = gist.code |> preview_code() |> highlight_code(gist.lang) |> render_code_lines() """ #{escape_text(gist.title || "Gist: #{gist.id}")} #{escape_text(gist.desc || "No description")} #{code_lines} """ end defp render_code_lines(highlighted_html) do highlighted_html |> String.split("\n") |> Enum.with_index() |> Enum.map_join("\n", fn {line, index} -> y_position = @code_start_y + index * @line_height render_line(String.trim(line), y_position) end) end defp render_line("", _y_position), do: "" defp render_line(line, y_position) do spans = parse_spans(line) spans |> Enum.reduce({[], 0}, fn {content, class}, {spans, x_offset} -> span = render_span(content, class, x_offset, y_position) {[span | spans], x_offset + String.length(content)} end) |> elem(0) |> Enum.reverse() |> Enum.join("\n") end defp parse_spans(""), do: [] defp parse_spans(line) do # Handle non-span text first line |> String.split(~r{(|)}, include_captures: true) |> Enum.reduce({[], nil}, fn # Opening span tag <<">, {acc, nil} -> class = rest |> String.replace_suffix("\">", "") {acc, class} # Closing span tag "", {acc, _class} -> {acc, nil} # Content within or outside spans content, {acc, class} when byte_size(content) > 0 -> {[{content, class || "default"} | acc], class} _, acc_class -> acc_class end) |> elem(0) |> Enum.reverse() end defp render_span(content, class, x_offset, y_position) when byte_size(content) > 0 do x = @code_start_x + x_offset * @char_width "#{escape_text(content)}" end defp render_span(_, _, _, _), do: "" defp preview_code(content) do content |> String.split("\n") |> Enum.take(15) |> Enum.join("\n") end defp escape_text(text) do text |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") end defp highlight_code(code, language) when is_binary(language) do lexer = get_lexer_for_language(language) String.trim( code |> Makeup.highlight_inner_html( lexer: lexer, formatter_opts: [ highlight_tag: "span", css_class: "makeup", highlight_css_class: "hll" ] ) |> to_string() ) end defp highlight_code(code, nil) do escape_text(code) end defp get_lexer_for_language(language) do case language do "elixir" -> Makeup.Lexers.ElixirLexer "erlang" -> Makeup.Lexers.ErlangLexer "html" -> Makeup.Lexers.HtmlLexer "javascript" -> Makeup.Lexers.JsLexer "python" -> Makeup.Lexers.PythonLexer "ruby" -> Makeup.Lexers.RubyLexer "rust" -> Makeup.Lexers.RustLexer end end def get_webp(gist) do svg = generate_preview(gist) tmp_svg_path = Path.join(System.tmp_dir(), "temp_#{:rand.uniform(999_999)}.svg") Logger.debug(tmp_svg_path) try do File.write!(tmp_svg_path, svg) Logger.debug("wrote svg") try do image = tmp_svg_path |> Mogrify.open() |> Mogrify.custom("define", "svg:include-fonts=true") |> Mogrify.format("webp") |> Mogrify.save() Logger.debug("wrote image #{image.path}") {:ok, image_binary} = File.read(image.path) Logger.debug("returning binary") {:ok, image_binary} rescue e in Mogrify.Error -> { :error, "Conversion failed: #{Exception.message(e)}" } end rescue e -> {:error, "File operation failed: #{Exception.message(e)}"} after File.rm(tmp_svg_path) end end end