Skip to content

Commit

Permalink
Generate OG Image from frontmatter
Browse files Browse the repository at this point in the history
  • Loading branch information
Pangoraw committed Nov 22, 2023
1 parent 08dafcc commit 44426e9
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 3 deletions.
4 changes: 4 additions & 0 deletions Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ AbstractPlutoDingetjes = "6e696c72-6542-2067-7265-42206c756150"
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
BetterFileWatching = "c9fd44ac-77b5-486c-9482-9798bd063cc6"
Configurations = "5218b696-f38b-4ac9-8b61-a12ec717816d"
Deno_jll = "04572ae6-984a-583e-9378-9577a1c2574d"
Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b"
FromFile = "ff7dd447-1dcb-4ce3-b8ac-22a812192de7"
Git = "d7ba0133-e1db-5d97-8f8c-041e4b3a1eb2"
Expand All @@ -19,6 +20,7 @@ Logging = "56ddb016-857b-54e1-b83d-db4d58db5568"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Pluto = "c3e4b0f8-55cb-11ea-2926-15256bba5781"
SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"
Scratch = "6c6a2e73-6563-6170-7368-637461726353"
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
TOML = "fa267f1f-6049-4f14-aa54-33bafae1ed76"
TerminalLoggers = "5d786b92-1e48-4d6f-9151-6b4477ca9bed"
Expand All @@ -29,6 +31,7 @@ AbstractPlutoDingetjes = "1.1"
Base64 = "1"
BetterFileWatching = "^0.1.2"
Configurations = "0.16, 0.17"
Deno_jll = "1.33"
Distributed = "1"
FromFile = "0.1"
Git = "1"
Expand All @@ -40,6 +43,7 @@ Logging = "1"
Pkg = "1"
Pluto = "0.19.18"
SHA = "0.7, 1"
Scratch = "1.2"
Sockets = "1"
TOML = "1"
TerminalLoggers = "0.1"
Expand Down
15 changes: 12 additions & 3 deletions src/Actions.jl
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ using FromFile
@from "./Configuration.jl" import PlutoDeploySettings, is_glob_match
@from "./FileHelpers.jl" import find_notebook_files_recursive
@from "./PlutoHash.jl" import plutohash
@from "./OGImage.jl" import generate_og_image, can_generate_og_image


showall(xs) = Text(join(string.(xs), "\n"))
Expand Down Expand Up @@ -244,11 +245,19 @@ function generate_static_export(
frontmatter = convert(
Pluto.FrontMatter,
get(
() -> Pluto.FrontMatter(),
get(() -> Dict{String,Any}(), original_state, "metadata"),
Pluto.FrontMatter,
get(Dict{String,Any}, original_state, "metadata"),
"frontmatter",
),
)

if settings.Export.generate_og_image && !settings.Export.baked_state && can_generate_og_image(frontmatter)
@debug "Generating OG image" export_statefile_path
local og_image_path = generate_og_image(export_statefile_path)
@debug "Generated OG image" og_image_path
frontmatter["image"] = relpath(og_image_path, dirname(export_html_path))
end

header_html = Pluto.frontmatter_html(frontmatter)

html_contents = Pluto.generate_html(;
Expand Down Expand Up @@ -302,4 +311,4 @@ function remove_static_export(path; settings, output_dir)
!settings.Export.baked_notebookfile
tryrm(export_jl_path)
end
end
end
2 changes: 2 additions & 0 deletions src/Configuration.jl
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ end
"Use the Pluto Featured GUI to display the notebooks on the auto-generated index page, using frontmatter for title, description, image, and more. The default is currently `false`, but it might change in the future. Set to `true` or `false` explicitly to fix a value."
create_pluto_featured_index::Union{Nothing,Bool} = nothing
pluto_cdn_root::Union{Nothing,String} = nothing
"Toggle OG image generation"
generate_og_image::Bool = false
end

@option struct PlutoDeploySettings
Expand Down
29 changes: 29 additions & 0 deletions src/OGImage.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Deno_jll: deno
using Scratch

"Is frontmatter complete enough to generate an OG image?"
function can_generate_og_image(frontmatter)
(haskey(frontmatter, "author") || haskey(frontmatter, "author_name")) &&
haskey(frontmatter, "title") &&
haskey(frontmatter, "image")
end

"""
Run the deno command with a [DENO_DIR](https://docs.deno.com/runtime/manual/basics/env_variables#special-environment-variables)
tied to a Scratch.jl scratch space where the deps and cache files will be installed.
"""
deno_pss(args) = withenv("DENO_DIR" => get_scratch!(@__MODULE__, "deno_dir")) do
buf = IOBuffer()
run(`$(deno()) $(args)`, Base.DevNull(), buf)
# ﬌ stdin ﬌ stdout
String(take!(buf))
end

function generate_og_image(path_to_pluto_state_file)
deno_pss([
"run",
"--allow-all", # Do we need stricter permissions?
joinpath(@__DIR__, "og_image_gen.jsx"),
path_to_pluto_state_file,
]) |> strip
end
266 changes: 266 additions & 0 deletions src/og_image_gen.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import satori from "npm:[email protected]";
import React from "npm:[email protected]";
import { Resvg } from "npm:@resvg/[email protected]";

import { encodeBase64 } from "https://deno.land/[email protected]/encoding/base64.ts";
import { walk } from "https://deno.land/[email protected]/fs/walk.ts";
import { join } from "https://deno.land/[email protected]/path/mod.ts";

// ES6 import for msgpack-lite, we use the fonsp/msgpack-lite fork to make it ES6-importable (without nodejs)
import msgpack from "https://cdn.jsdelivr.net/gh/fonsp/[email protected]/dist/msgpack-es.min.mjs";

// based on https://github.com/kawanet/msgpack-lite/blob/5b71d82cad4b96289a466a6403d2faaa3e254167/lib/ext-packer.js
const codec = msgpack.createCodec();
const packTypedArray = (x) =>
new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
codec.addExtPacker(0x11, Int8Array, packTypedArray);
codec.addExtPacker(0x12, Uint8Array, packTypedArray);
codec.addExtPacker(0x13, Int16Array, packTypedArray);
codec.addExtPacker(0x14, Uint16Array, packTypedArray);
codec.addExtPacker(0x15, Int32Array, packTypedArray);
codec.addExtPacker(0x16, Uint32Array, packTypedArray);
codec.addExtPacker(0x17, Float32Array, packTypedArray);
codec.addExtPacker(0x18, Float64Array, packTypedArray);

codec.addExtPacker(0x12, Uint8ClampedArray, packTypedArray);
codec.addExtPacker(0x12, ArrayBuffer, (x) => new Uint8Array(x));
codec.addExtPacker(0x12, DataView, packTypedArray);

// Pack and unpack dates. However, encoding a date does throw on Safari because it doesn't have BigInt64Array.
// This isn't too much a problem, as Safari doesn't even support <input type=date /> yet...
// But it does throw when I create a custom @bind that has a Date value...
// For decoding I now also use a "Invalid Date", but the code in https://stackoverflow.com/a/55338384/2681964 did work in Safari.
// Also there is no way now to send an "Invalid Date", so it just does nothing
codec.addExtPacker(0x0d, Date, (d) => new BigInt64Array([BigInt(+d)]));
codec.addExtUnpacker(0x0d, (uintarray) => {
if ("getBigInt64" in DataView.prototype) {
let dataview = new DataView(
uintarray.buffer,
uintarray.byteOffset,
uintarray.byteLength,
);
let bigint = dataview.getBigInt64(0, true); // true here is "littleEndianes", not sure if this only Works On My Machine©
if (bigint > Number.MAX_SAFE_INTEGER) {
throw new Error(
`Can't read too big number as date (how far in the future is this?!)`,
);
}
return new Date(Number(bigint));
} else {
return new Date(NaN);
}
});

codec.addExtUnpacker(0x11, (x) => new Int8Array(x.buffer));
codec.addExtUnpacker(0x12, (x) => new Uint8Array(x.buffer));
codec.addExtUnpacker(0x13, (x) => new Int16Array(x.buffer));
codec.addExtUnpacker(0x14, (x) => new Uint16Array(x.buffer));
codec.addExtUnpacker(0x15, (x) => new Int32Array(x.buffer));
codec.addExtUnpacker(0x16, (x) => new Uint32Array(x.buffer));
codec.addExtUnpacker(0x17, (x) => new Float32Array(x.buffer));
codec.addExtUnpacker(0x18, (x) => new Float64Array(x.buffer));

/** @param {any} x */
export const pack = (x) => {
return msgpack.encode(x, { codec: codec });
};

/** @param {Uint8Array} x */
export const unpack = (x) => {
return msgpack.decode(x, { codec: codec });
};

const fluentEmoji = (code) =>
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" +
code.toLowerCase() +
"_color.svg";

const emojiCache = {};
const loadEmoji = (type, code) => {
const key = type + ":" + code;
if (key in emojiCache) return emojiCache[key];

emojiCache[key] = fetch(fluentEmoji(code)).then((r) => r.text());
return emojiCache[key];
};

const loadDynamicAsset = async (type, code) => {
if (type === "emoji") {
const emojiSvg = await loadEmoji(type, code);
return `data:image/svg+xml;base64,` + encodeBase64(emojiSvg);
}

return null;
};

const HeaderComponent = ({
author,
authorImage,
title,
description,
imageUrl,
}) => (
<div
style={{
display: "flex",
height: "100%",
width: "100%",
alignItems: "center",
flexDirection: "column",
letterSpacing: "-0.02em",
fontWeight: 700,
fontFamily: 'Roboto, "Material Icons"',
background: "#8E7DBE",
}}
>
<div
style={{
height: "62%",
width: "100%",
backgroundImage:
"linear-gradient(90deg, rgb(0, 124, 240), rgb(0, 223, 216))",
display: "flex",
}}
>
{imageUrl && (
<img
style={{ objectFit: "cover" }}
height="100%"
width="100%"
src={imageUrl}
/>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
position: "absolute",
right: "20px",
top: "20px",
background: "rgba(255,255,255,200)",
padding: "5px",
borderRadius: "30px",
}}
>
<div
style={{
height: "25px",
width: "25px",
background: "salmon",
backgroundImage: authorImage
? `url(${authorImage})`
: "url(https://avatars.githubusercontent.com/u/74617459?s=400&u=85ab12d22312806d5e577de6c5a8b6bf983c21a6&v=4)",
backgroundClip: "border-box",
backgroundSize: "25px 25px",
borderRadius: "12px",
}}
>
</div>
<div
style={{ display: "flex", marginLeft: "10px", marginRight: "10px" }}
>
{author}
</div>
</div>
<div
style={{
position: "absolute",
bottom: 0,
display: "flex",
flexDirection: "column",
borderRadius: "30px 30px 0px 0px",
width: "100%",
height: "45%",
padding: "20px",
background: "white",
}}
>
<div style={{ lineClamp: 1, fontSize: "2em", marginBottom: "15px" }}>
{title}
</div>
<div style={{ lineClamp: 3, fontSize: "1.3em", color: "#aaa" }}>
{description}
</div>
</div>
</div>
);

// TODO(paul): cache this and other files in DENO_DIR?
const roboto = await (await fetch(
"https://github.com/vercel/satori/raw/main/test/assets/Roboto-Regular.ttf",
)).arrayBuffer();

const generateOgImage = async (pathToNotebook) => {
const statefileBuf = await Deno.readFile(pathToNotebook + ".plutostate");
const statefile = unpack(statefileBuf);

let authorName = statefile.metadata.frontmatter.author_name;
let authorImage = statefile.metadata.frontmatter.author_image;

if (authorName === undefined) {
authorName = statefile.metadata.frontmatter.author.map(({ name }) => name)
.join(", ", " and ");
}

if (authorImage === undefined) {
authorImage = statefile.metadata.frontmatter.author.map(({ image }) =>
image
).findLast(() => true);
}

if (!authorImage) {
authorImage = statefile.metadata.frontmatter.author.find(() => true)?.url +
".png?size=48";
}

const svg = await satori(
<HeaderComponent
author={authorName}
authorImage={authorImage}
title={statefile.metadata.frontmatter.title ??
pathToNotebook.split("/").findLast(() => true)}
description={statefile.metadata.frontmatter.description}
imageUrl={statefile.metadata.frontmatter.image}
/>,
{
width: 600,
height: 400,
fonts: [
{
name: "Roboto",
// Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here.
data: roboto,
weight: 400,
style: "normal",
},
],
loadAdditionalAsset: loadDynamicAsset,
},
);
const opts = {
background: "rgba(238, 235, 230, .9)",
fitTo: {
mode: "width",
value: 1200,
},
};

// await Deno.writeTextFile("satori.svg", svg);

const resvg = new Resvg(svg, opts);
const pngData = resvg.render();
const pngBuffer = pngData.asPng();

const b64 = encodeBase64(pngBuffer);
const dataUrl = `data:image/png;base64,${b64}`;

const pngPath = pathToNotebook + ".og-image.png";
await Deno.writeFile(pngPath, pngBuffer);

console.log(pngPath);
};

const plutostateFilePath = Deno.args[0]
const pathToNotebook = plutostateFilePath.replace(".plutostate", "");
await generateOgImage(pathToNotebook);

0 comments on commit 44426e9

Please sign in to comment.