himpler.com himpler.com

Automatische Erstellung von OpenGraph Images für Hugo

9 Min.

This post is also available in english: Auto generate OpenGraph images for Hugo

Seit einiger Zeit werden diese Seiten nicht mehr über ein CMS sondern mit einem Static Site Generator erstellt. Ich verwende hier Hugo, einen in Go geschriebenen, ultraschnellen Generator, der aus einigen Layout- und Markdown Files diese Homepage generiert. Was mir bei Hugo jedoch fehlt, ist eine Möglichkeit OpenGraph Images automatisch zu generieren. Ein kleines NodeJS Script füllt diese Lücke.

OpenGraph Images

Die kleinen Vorschau Bilder machen einen geteilten Link gleich viel anschaulicher. Egal ob in sozialen Netzwerken oder inzwischen auch in diversen Messengern, wird das OpenGraph Protokoll berücksichtigt. Aber die manuelle Erstellung der Grafiken kostet Zeit und macht zusätzliche Arbeit.

Facebook OpenGraph Vorschau mit und ohne Grafik

Facebook OpenGraph Vorschau mit und ohne Grafik

Also muss eine automatisierte Lösung her. Es würde naheliegen, die OG Images direkt mit Go über den Build-Prozess von Hugo zu generieren. Da Hugo aktuell aber nicht mit Plugins umgehen kann und nicht zuletzt auch aufgrund meiner nur rudimentär vorhandenen Go-Kenntnisse, muss als Alternative ein kleines NodeJS Script herhalten.

Grafiken mit NodeJS erzeugen

Während Browser bereits eine Möglichkeit mitbringen, Grafiken mit Hilfe von JavaScript und dem Canvas-Element zu erstellen, muss bei NodeJS auf ein externes Paket zurückgegriffen werden. Hier verwende ich das Paket canvas, welches die gleichen Funktionalitäten wie das Browser-Pendant bietet.

Nach der Installation (npm install canvas) kann es im Prinzip schon losgehen. Aber werfen wir zuerst einen Blick auf meine Anforderungen:

  • Erkennung der Blog Posts
  • Auslesen der Titel aus den Dateien
  • Erstellung und Speichern des OpenGraph Images

First things first. Kümmern wir uns zunächst um die Erkennung der einzelnen Posts. Hugo verwendet eine Ordnerstruktur, die sich mit mit NodeJS-Bordmitteln leicht auslesen lässt.

const fs = require('fs');
const path = require('path');

const dir = path.join(__dirname, 'content/posts');

fs.readdir(dir, (err, files) => {
  files.forEach((folder) => {
    const stat = fs.statSync(`${dir}/${folder}`);
    if (stat && stat.isDirectory()) {
      process.stdout.write(`${folder}\n`);
    }
  });
});

Das Script gibt nun eine Liste aller Unterordner des posts Verzeichnisses aus. Jeder dieser Ordner enthält einen Post, dessen Inhalt in einer index.md Datei gespeichert ist. Neben dem eigentlichen Text verfügt die Datei auch über Meta-Informationen, wie z.B. den Titel, relevante Tags oder das Veröffentlichungsdatum, die alle dem Font Matter Standard folgen. Den Titel könnte man nun mit Hilfe eines regulären Ausdrucks herausfiltern, aber mit dem NPM-Package gray-matter ist das deutlich einfacher möglich. Das Paket liest die Front Matter Informationen einer Datei und gibt diese als JSON aus. Nach der Installation (npm install gray-matter) wird das Script wie folgt erweitert:

const fs = require('fs');
const matter = require('gray-matter');
const path = require('path');

const dir = path.join(__dirname, 'content/posts');

fs.readdir(dir, (err, files) => {
  files.forEach((folder) => {
    const stat = fs.statSync(`${dir}/${folder}`);
    if (stat && stat.isDirectory()) {
      const { data: post } = matter.read(`${dir}/${folder}/index.md`);
      process.stdout.write(`${post.title}\n`);
    }
  });
});

Damit haben wir also nun neben den richtigen Ordnerpfaden der Posts auch deren jeweiligen Titel und können mit der Erzeugung des OpenGraph Images beginnen. Damit das Bild später zum Design meiner Seite passt, habe ich mir im passenden Format kurz ein kleines Template erstellt, welches unter anderem bereits das Logo meiner Seite enthält.

Das Template für das OG Image

Das Template für das OG Image

Die Erzeugung des OG Images lagere ich der Übersicht halber in eine Funktion aus. Diese erstellt zunächst ein 1200x630px großes Canvas Element. Das ist die aktuell passende Größe für die zu erstellenden OG Images. Anschließend wird das zuvor erstellte Template als Hintergrundbild importiert.

const { createCanvas, loadImage } = require('canvas');

const generateOgImage = async (title, tags, dest) => {
  const canvas = createCanvas(1200, 630);
  const context = canvas.getContext('2d');

  const baseImage = await loadImage(path.join(__dirname, 'static/images/og-image-base.png'));
  context.drawImage(baseImage, 0, 0, width, height);
}

Nun ergänzen wir den Titel aus den ausgelesenen Meta-Informationen und speichern die Grafik als PNG.

const generateOgImage = async (title, tags, dest) => {

  // [...]

  registerFont(
    path.join(__dirname, 'static/fonts/pt-serif-caption-v9-latin-regular.ttf'),
    { family: 'PT Serif Caption' },
  );

  context.fillStyle = '#374151';
  context.font = '64px PT Serif Caption';

  context.fillText(title, 50, 90);

  const buffer = canvas.toBuffer('image/png');
  fs.writeFileSync(path.join(dest, 'feature-image.png'), buffer);
};

Nach Auswahl einer Schriftart und festlegen der Schriftgröße wird der Text auf das Canvas geschrieben. Anschließend speichern wir das erzeugte Canvas noch als Grafik im jeweiligen Ordner des Posts. Hugo sucht automatisch nach bestimmten Dateinamen (dazu unten später mehr), weshalb ich das Bild feature-image.png nenne.

Automatischer Zeilenumbruch im Canvas-Element

Nach dem ersten Testlauf werden zwar die Bilder erstellt, die Optik passt allerdings noch nicht. Unter anderem gibt es im Canvas-Element für langen Text keinen automatischen Zeilenumbruch.

Der fehlende Zeilenumbruch im Canvas-Element führt dazu, dass der Text nicht komplett dargestellt wird.

Der fehlende Zeilenumbruch im Canvas-Element führt dazu, dass der Text nicht komplett dargestellt wird.

Zwar lässt sich als dritter Parameter eine maximale Breite angeben, dies führt allerdings dazu, dass der Text einfach nur auf die Maximalbreite gestaucht wird. Für meine Zwecke also völlig unbrauchbar.

Das gewünschte Ergebnis kann man nur über eine eigene Logik erreichen.

const { createCanvas, loadImage, registerFont } = require('canvas');

// [...]

const maxWidth = 900;
const lineHeight = 90;
const paddingLeft = 50;

const words = title.split(' ');
let line = '';
let y = lineHeight;
const headlines = [];

words.forEach((w, i) => {
  const testLine = `${line}${w} `;
  const metrics = context.measureText(testLine);
  if (metrics.width > maxWidth && i > 0) {
    context.fillText(line, paddingLeft, y);
    line = `${w} `;
    y += lineHeight;
  } else {
    line = testLine;
  }
});
context.fillText(line, paddingLeft, y);

// [...]

Zunächst wird der Titel in Worte zerlegt die wiederum in einer Schleife durchlaufen werden. Mit der Canvas-Funktion measureText() wird die Zeilenbreite berechnet und anschließend mit der maximalen Breite (hier 900px) verglichen. Ist die maximale Breite noch nicht erreicht, wird ein weiteres Wort des Titels zur Zeile hinzugefügt. Wird die maximale Breite mit dem aktuellen Wort überschritten, wird die Zeile ohne das Wort auf das Canvas geschrieben und das aktuelle Wort zu einer neuen Zeile hinzugefügt. Gleichzeitig wird die Y-Position der Zeile erhöht. Der Prozess durchläuft nun alle Worte des Titels. Zum Schluss wird der übrig gebliebene Rest ebenfalls auf das Canvas geschrieben.

Textumbruch im OG Image

Textumbruch im OG Image

Ein weiterer Test zeigt, dass der Titel nun richtig umgebrochen wird. Allerdings passt mir die Positionierung noch nicht.

Den Text im Canvas richtig positionieren

Ich möchte, dass der Text vertikal in etwa zentriert dargestellt wird. Die Zentrierung ist von der Anzahl der zuvor erzeugten Zeilen abhängig. Da ich diese durch das direkte Schreiben auf das Canvas nicht kenne, muss die oben gezeigt Funktion noch einmal angepasst werden.

// [...]

let y = lineHeight / 2;
const headlines = [];

words.forEach((w, i) => {
  const testLine = `${line}${w} `;
  const metrics = context.measureText(testLine);
  if (metrics.width > maxWidth && i > 0) {
    headlines.push({ text: line, y });
    line = `${w} `;
    y += lineHeight;
  } else {
    line = testLine;
  }
});
headlines.push({ text: line, y });

// [...]

Anstelle den Text direkt auf das Canvas zu schreiben, wird der Text jetzt zusammen mit der berechneten Y-Position in einem Array gespeichert. Die Länge des Arrays ist dann die Anzahl der Zeilen. Mit dieser neuen Informationen können wir neben der Gesamthöhe des Titels auch einen Offset berechnen, der den Start der ersten Zeile bestimmt.

// [...]

	const startPosY = (height - (lineHeight * headlines.length)) / 2;
	headlines.forEach((l) => context.fillText(l.text, paddingLeft, startPosY + l.y));

// [...]
Die richtig positionierte Überschrift

Die richtig positionierte Überschrift

Um der Optik der Überschriften meines Blogs möglichst nahe zu kommen, ergänzen wir noch ein einfaches Rechteck oben links über der Überschrift.

// [...]

context.fillStyle = '#006cb0';
context.fillRect(paddingLeft, startPosY - (lineHeight / 2) - 10, 100, 5);

// [...]
Das automatisch erzeugte OG Image für diesen Post

Das automatisch erzeugte OG Image für diesen Post

Einbindung der OG Images in Hugo

Last but not least müssen die Bilder noch in Hugo eingebunden werden. Dazu bietet Hugo fertige Templates an, die wir in den <head></head> unserer Seite einbinden müssen.

<head>
  <!-- [...] -->
  {{ template "_internal/opengraph.html" . }}
  {{ template "_internal/twitter_cards.html" . }}
</head>

Hier kommt nun auch der bewusst ausgewählte Dateiname der OG Images ins Spiel. Wenn man von einer expliziten Angabe in den Front Matter Daten absieht, sucht Hugo automatisch nach Bildern, deren Dateiname zu den Filtern *feature*, *cover* oder *thumbnail* passen. Dabei wird dann auch der relative Pfad des Posts berücksichtigt und nicht wie bei der expliziten Angabe der Pfad zum Ordner static. Mehr dazu in der Hugo Dokumentation. Dort wird darüber hinaus gezeigt, welche weiteren Eigenschaften des OpenGraph Protokolls über die Front Matter Daten berücksichtigt werden. Die Ergebnisse lassen sich zum Schluss z.B. mit dem Sharing Debugger von Facebook überprüfen.

Bonus: Multilanguage

Da ich einige Posts zusätzlich in Englisch veröffentlichen möchte, sollen auch die OpenGraph Bilder mit der englischsprachigen Überschrift ausgeliefert werden. Meine mehrsprachigen Posts organisiere ich im gleichen Ordner. Sie unterscheiden sich nur im Dateinamen. Aus dem Standard index.md wird dann index.en.md. Gleiches Schema lässt sich bei den OG-Images anwenden. Wir erweitern das Script also um die Erzeugung einer englischen Version der Grafik, die als feature-image.en.png gespeichert wird. Dank der Spracherkennung von Hugo, wird mit dem englischsprachigen Post dann auch das englischsprachige OG-Image ausgeliefert.


// [...]

try {
  await fileExists(`${dir}/${folder}/index.en.md`);
  const { data: enPost } = matter.read(`${dir}/${folder}/index.en.md`);
  generateOgImage(enPost.title, enPost.tags, `${dir}/${folder}/feature-image.en.png`);
  process.stdout.write('✔');
} catch (error) {
  process.stdout.write('x');
}

Das Prinzip ist hier das gleiche wie auch bei den deutschsprachigen Grafiken: Wenn eine englische Variante des Posts existiert, wird die Funktion mit den jeweiligen englischen Parametern aufgerufen. Für die Unterscheidung des Dateinamens wird die Funktion generateOgImage noch um ein dritten Parameter erweitert. Das ganze Script findet ihr nach der kurzen Zusammenfassung.

tl;dr

Mit dem folgenden NodeJS Script lassen sich OpenGraph Images für Hugo erstellen. Dazu werden im ersten Schritt die Posts ermittelt sowie deren Meta-Informationen ausgelesen. Im zweiten Schritt wird das OG Image auf Basis eines Templates erstellt. Dabei werden zu lange Überschriften automatisch umgebrochen und der Text passend in der Grafik platziert. Ich rufe das Script manuell auf; es ist aber auch eine automatisierte Nutzung, z.B. über einen Github Workflow denkbar.

const { createCanvas, loadImage, registerFont } = require('canvas');
const fs = require('fs');
const matter = require('gray-matter');
const path = require('path');

const width = 1200;
const height = 630;
const maxWidth = 900;
const lineHeight = 90;
const paddingLeft = 50;
const dir = path.join(__dirname, 'content/posts');

const fileExists = (filePath) => new Promise((resolve, reject) => {
  fs.access(filePath, fs.F_OK, (err) => {
    if (err) return reject(err);
    return resolve();
  });
});

const generateOgImage = async (title, tags, dest) => {
  const canvas = createCanvas(width, height);
  const context = canvas.getContext('2d');

  const baseImage = await loadImage(path.join(__dirname, 'static/images/og-base-image.png'));
  context.drawImage(baseImage, 0, 0, width, height);

  registerFont(
    path.join(__dirname, 'static/fonts/pt-serif-caption-v9-latin-regular.ttf'),
    { family: 'PT Serif Caption' },
  );

  context.fillStyle = '#374151';
  context.font = '64px PT Serif Caption';

  const words = title.split(' ');
  let line = '';
  let y = lineHeight / 2;
  const headlines = [];

  words.forEach((w, i) => {
    const testLine = `${line}${w} `;
    const metrics = context.measureText(testLine);
    if (metrics.width > maxWidth && i > 0) {
      headlines.push({ text: line, y });
      line = `${w} `;
      y += lineHeight;
    } else {
      line = testLine;
    }
  });
  headlines.push({ text: line, y });

  const startPosY = (height - (lineHeight * headlines.length)) / 2;
  headlines.forEach((l) => context.fillText(l.text, paddingLeft, startPosY + l.y));

  context.fillStyle = '#006cb0';
  context.fillRect(paddingLeft, startPosY - (lineHeight / 2) - 10, 100, 5);

  const buffer = canvas.toBuffer('image/png');
  fs.writeFileSync(path.join(dest), buffer);
};

process.stdout.write('generating images ');
fs.readdir(dir, (err, files) => {
  files.forEach(async (folder) => {
    const stat = fs.statSync(`${dir}/${folder}`);
    if (stat && stat.isDirectory()) {
      // german
      try {
        await fileExists(`${dir}/${folder}/index.md`);
        const { data: post } = matter.read(`${dir}/${folder}/index.md`);
        generateOgImage(post.title, post.tags, `${dir}/${folder}/feature-image.png`);
        process.stdout.write('✔');
      } catch (error) {
        process.stdout.write('x');
      }

      // english
      try {
        await fileExists(`${dir}/${folder}/index.en.md`);
        const { data: enPost } = matter.read(`${dir}/${folder}/index.en.md`);
        generateOgImage(enPost.title, enPost.tags, `${dir}/${folder}/feature-image.en.png`);
        process.stdout.write('✔');
      } catch (error) {
        process.stdout.write('x');
      }
    }
  });
});