244 lines
6.8 KiB
Elixir
244 lines
6.8 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
|
||
|
|
|
||
|
|
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' !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
.background { fill: #0D1117; }
|
||
|
|
.title { fill: #ffffff; font-family: system-ui, -apple-system, sans-serif; }
|
||
|
|
.description { fill: #8B949E; font-family: system-ui, -apple-system, sans-serif; }
|
||
|
|
.code-background { fill: #161B22; }
|
||
|
|
|
||
|
|
/* Syntax Highlighting Colors */
|
||
|
|
.c, .cm, .cp, .c1, .cs { fill: #768390; } /* Comment */
|
||
|
|
.k, .kc, .kd, .kn, .kp, .kr, .kt { fill: #f47067; } /* Keyword */
|
||
|
|
.s, .sb, .sc, .sd, .s2, .se, .sh, .si, .sx, .sr, .s1, .ss { fill: #96d0ff; } /* String */
|
||
|
|
.n, .na, .nc, .no, .nd, .ni, .ne, .nf, .nl, .nn, .nx, .py, .nt, .nv, .vc, .vg, .vi { fill: #adbac7; } /* Name */
|
||
|
|
.nb, .bp { fill: #f69d50; } /* Built-in */
|
||
|
|
.m, .mf, .mh, .mi, .mo, .il { fill: #6cb6ff; } /* Number */
|
||
|
|
.o, .ow { fill: #f47067; } /* Operator */
|
||
|
|
.p { fill: #adbac7; } /* Punctuation */
|
||
|
|
.w { fill: #E6EDF3; } /* Whitespace */
|
||
|
|
.l { fill: #6cb6ff; } /* Literal */
|
||
|
|
.default { fill: #E6EDF3; } /* Default text color */
|
||
|
|
</style>
|
||
|
|
</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="420" class="code-background" rx="8"/>
|
||
|
|
|
||
|
|
<!-- Code Content -->
|
||
|
|
<g font-family="Iosevka" font-size="#{@font_size}">
|
||
|
|
#{code_lines}
|
||
|
|
</g>
|
||
|
|
</svg>
|
||
|
|
"""
|
||
|
|
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{(<span.*?>|</span>)}, include_captures: true)
|
||
|
|
|> Enum.reduce({[], nil}, fn
|
||
|
|
# Opening span tag
|
||
|
|
<<"<span class=\"", rest::binary>>, {acc, nil} ->
|
||
|
|
class = rest |> String.replace_suffix("\">", "")
|
||
|
|
{acc, class}
|
||
|
|
|
||
|
|
# Closing span tag
|
||
|
|
"</span>", {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
|
||
|
|
"<text x=\"#{x}\" y=\"#{y_position}\" class=\"#{class}\">#{escape_text(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("&", "&")
|
||
|
|
|> 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
|