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 @space_width @char_width 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} -> indent_level = calculate_indent_level(line) y_position = @code_start_y + index * @line_height render_line(String.trim_leading(line), y_position, indent_level) end) end defp calculate_indent_level(line) do leading_spaces = line |> String.replace(~r/^(\s+).*$/, "\\1") |> String.length() div(leading_spaces, 2) end defp render_line("", _y_position, _indent), do: "" defp render_line(line, y_position, indent_level) do spans = parse_spans(line) indent_offset = indent_level * 2 * @space_width spans |> Enum.reduce({[], indent_offset}, fn {content, class}, {spans, x_offset} -> unescaped_content = unescape_html(content) span = render_span(unescaped_content, class, x_offset, y_position) {[span | spans], x_offset + String.length(unescaped_content) * @char_width} end) |> elem(0) |> Enum.reverse() |> Enum.join("\n") end defp unescape_html(text) do text |> String.replace("&", "&") |> String.replace("<", "<") |> String.replace(">", ">") |> String.replace(""", "\"") |> String.replace("'", "'") |> String.replace("'", "'") |> String.replace("/", "/") |> String.replace("/", "/") |> String.replace(" ", " ") end defp parse_spans(""), do: [] defp parse_spans(line) do line |> String.split(~r{(|)}, include_captures: true) |> Enum.reduce({[], nil}, fn <<">, {acc, nil} -> class = rest |> String.replace_suffix("\">", "") {acc, class} "", {acc, _class} -> {acc, nil} 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 # Re-escape the content for the SVG output safe_content = escape_text(content) "#{safe_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