zoeys.computer/lib/zoeyscomputer_web/live/gist_live/og_image.ex
2024-10-26 22:00:45 -04:00

278 lines
8.2 KiB
Elixir

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()
"""
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1200" height="630" viewBox="0 0 1200 630"
xmlns="http://www.w3.org/2000/svg">
<defs>
<style type="text/css">
#{font_face_styles()}
* {
font-family: 'Iosevka', monospace !important;
}
/* Catppuccin Macchiato Theme Colors */
.background { fill: #24273a; } /* base */
.title { fill: #cad3f5; font-family: system-ui, -apple-system, sans-serif; } /* text */
.description { fill: #8087a2; font-family: system-ui, -apple-system, sans-serif; } /* overlay1 */
.code-background { fill: #1e2030; } /* crust */
/* Syntax Highlighting Colors - Catppuccin Macchiato */
.c, .cm, .cp, .c1, .cs { fill: #8087a2; } /* Comment - overlay1 */
.k, .kc, .kd, .kn, .kp, .kr, .kt { fill: #c6a0f6; } /* Keyword - mauve */
.s, .sb, .sc, .sd, .s2, .se, .sh, .si, .sx, .sr, .s1, .ss { fill: #a6da95; } /* String - green */
.n, .na, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nx, .py, .nt, .nv, .vc, .vg, .vi { fill: #8aadf4; } /* Name - blue */
.nb, .bp { fill: #f5a97f; } /* Built-in - peach */
.m, .mf, .mh, .mi, .mo, .il { fill: #f5bde6; } /* Number - pink */
.o, .ow { fill: #91d7e3; } /* Operator - sky */
.p { fill: #cad3f5; } /* Punctuation - text */
.w { fill: #cad3f5; } /* Whitespace - text */
.l { fill: #8aadf4; } /* Literal - blue */
.default { fill: #cad3f5; } /* Default text color - text */
</style>
<!-- Gradient definition -->
<linearGradient id="fade" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1e2030" stop-opacity="0"/>
<stop offset="50%" stop-color="#1e2030" stop-opacity="0"/>
<stop offset="100%" stop-color="#181926" stop-opacity="1"/>
</linearGradient>
</defs>
<!-- Background -->
<rect class="background" width="1200" height="630" />
<!-- Content -->
<text x="60" y="80" class="title" font-size="40" font-weight="bold">
#{escape_text(gist.title || "Gist: #{gist.id}")}
</text>
<text x="60" y="130" class="description" font-size="24">
#{escape_text(gist.desc || "No description")}
</text>
<!-- Code Preview Background -->
<rect x="60" y="160" width="1080" height="600" class="code-background" rx="8"/>
<!-- Code Content -->
<g font-family="Iosevka" font-size="#{@font_size}">
#{code_lines}
</g>
<!-- Gradient Overlay -->
<rect x="0" y="160" width="1200" height="600" fill="url(#fade)" rx="8"/>
</svg>
"""
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("&amp;", "&")
|> String.replace("&lt;", "<")
|> String.replace("&gt;", ">")
|> String.replace("&quot;", "\"")
|> String.replace("&#39;", "'")
|> String.replace("&#x27;", "'")
|> String.replace("&#x2F;", "/")
|> String.replace("&#47;", "/")
|> String.replace("&nbsp;", " ")
end
defp parse_spans(""), do: []
defp parse_spans(line) do
line
|> String.split(~r{(<span.*?>|</span>)}, include_captures: true)
|> Enum.reduce({[], nil}, fn
<<"<span class=\"", rest::binary>>, {acc, nil} ->
class = rest |> String.replace_suffix("\">", "")
{acc, class}
"</span>", {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)
"<text x=\"#{x}\" y=\"#{y_position}\" class=\"#{class}\">#{safe_content}</text>"
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("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
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