Meme-Generator mit jQuery und Canvas

In diesem Tutorial werde ich euch zeigen, wie man mit dem HTML-Element Canvas einen Meme-Generator erstellen kann. Das Ergebnis könnt ihr hier in meinem Demo-Script sehen:

MEME-GENERATOR

Für alle, die nicht wissen, was ein Meme ist, hier nun eine kurze Aufklärung:

Bei Memes handelt es sich um Pseudo-Bilder mit einer meist mehr oder weniger sinnhaften Textüberblendung. Von der Themenwahl her gibt es praktisch keine Vorgaben, wodurch deren Entstehung jeweils meist auf Zufall oder die individuelle Kreativität des Erstellers zurückzuführen ist. Memes sind zu einem wahren Hype im Internet geworden,  was man aber vielleicht auf deren leichte Erstellungsmöglichkeiten zurückführen könnte.

 

CANVAS – WENN DER BROWSER ZEICHNEN LERNT

Bei Canvas handelt es sich um ein HTML-Element mit frei definierbarer Höhe und Breite – vergleichbar mit einer Leinwand –  innerhalb dessen mit Hilfe von Javascript “gezeichnet” werden kann.

Hierbei sind die Funktionen aber nicht nur auf Linien und Rechtecke beschänkt, sondern es können auch Kreisbögen, Farbverläufe und Texte verarbeitet werden. Wer noch eins drauflegen möchte, kann damit dann sogar Bézierkurven darstellen. Auch der Einsatz von externen Grafiken ist (bis zu einem gewissen Punkt) möglich.  So können diese nicht nur skaliert oder neu positioniert, sondern auch in deren Format beschnitten werden.

Dabei behandelt Canvas jedes eingefügte Objekt bzw. Objektgruppe als eigenständiges Element, welches individuell verschoben, skaliert oder auch rotiert werden kann.

 

Bei unserem Meme-Generator werden wir ein beliebiges Bild oder Foto auswählen können und anschließend zwei Textbereiche individuell mit Hilfe von ein paar Slider-Elementen darauf ausrichten.

Nachdem wir hier nur mit Javascript arbeiten, ist für die Grundfunktionalität auch kein serverseitiges Script bzw. Skriptsprache (wie beispielsweise PHP) notwendig.

 

Schritt 1: HTML-Markup für Konfigurator anlegen

Unser Meme-Generator wird im Prinzip nur aus drei Teilen bestehen: einer HTML-Seite mit den Konfigurationsoptionen, einer minimalistischen CSS-Datei (da wir mit dem CSS-Framework Bootstrap arbeiten) und einer Javascript-Datei mit unserer Logik.

Beginnen wir also mit dem ersten Teil, der HTML-Konfigurationsseite.

Im Grunde legen wir uns hier (wie ihr auch im Beispiel-Screenshot von oben sehen könnt) nur ein Bootrap Grid-Layout an,  bei dem links das mit Canvas generierte Meme, und rechts daneben die Konfigurationssoptionen (mit Hilfe von Slider-Elementen) angezeigt werden.

<html>
	<head>
		<title>Meme Generator</title>
		
		<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
		<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" />
		<link rel="stylesheet" href="style.css"/>
	</head>
	<body>
	
	<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
		<a class="navbar-brand" href="#">Meme Generator</a>
		<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsExampleDefault" aria-controls="navbarsExampleDefault" aria-expanded="false" aria-label="Toggle navigation">
			<span class="navbar-toggler-icon"></span>
		</button>

		<div class="collapse navbar-collapse" id="navbarsExampleDefault">
			<ul class="navbar-nav mr-auto">
				<li class="nav-item active">
					<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
				</li>
				<li class="nav-item">
					<a class="nav-link" href="#">Gallery</a>
				</li>
				<li class="nav-item">
					<a class="nav-link" href="#">About</a>
				</li>
			</ul>
		</div>
	</nav>

	<div class="container">
	
		<div class="page-header">
			<h1><i class="fa fa-2x fa-paint-brush" aria-hidden="true"></i> Create your awesome Meme now!</h1>
		</div>
	
		<p>&nbsp;</p>
		
		<div class="row">
			<div class="col-md-4">
			
				<!-- BEGIN: Canvas rendering of generated Meme -->
				<canvas id="meme" class="img-thumbnail img-fluid">
					Unfortunately your browser does not support canvas.
				</canvas>
				<!-- END: Canvas rendering of generated Meme -->
			
			
				<a href="javascript:;" class="btn btn-primary btn-block" id="download_meme">
					<i class="fa fa-download" aria-hidden="true"></i> Download
				</a>
			
			
				<img id="start-image" src="image.jpg" alt="" />
			</div>
			<div class="col-md-8">
			
				<div class="row">
					<div class="col-md-12">
					
						<div class="input-group">
							<span class="input-group-addon">Image:</span>
							<input type="file" class="form-control" id="imgInp" />
						</div>
						
					</div>
				</div>
				
				
				
				<hr />
				
				<h3>Text 1:</h3>
				
				<div class="form-group">
					<input type="text" class="form-control form-control-lg" value="Have you expected this to be" id="text_top" />
				</div>
				
				<div class="form-group">
					<div class="row">
						<label class="control-label col-md-3" for="text_top_offset">Offset from top:</label>
						<div class="col-md-7">
							<input style="width:100%" id="text_top_offset" type="range" min="0" max="500" value="50"/>
						</div>
						<div class="col-md-2 setting-value">
							<span id="text_top_offset__val">50</span>px
						</div>
					</div>
				</div>
				
				
				<p>&nbsp;</p>
				
				<h3>Text 2:</h3>
				
				<div class="form-group">
					<input type="text" class="form-control form-control-lg" value="funny?" id="text_bottom" />
				</div>
				
				<div class="form-group">
					<div class="row">
						<label class="control-label col-md-3" for="text_bottom_offset">Offset from top:</label>
						<div class="col-md-7">
							<input style="width:100%" id="text_bottom_offset" type="range" min="0" max="500" value="450"/>
						</div>
						<div class="col-md-2 setting-value">
							<span id="text_bottom_offset__val">450</span>px
						</div>
					</div>
				</div>
				
				
				<hr />
				
				<p>&nbsp;</p>

				<div class="card">
					<div class="card-header">
						<i class="fa fa-cogs" aria-hidden="true"></i> More options
					</div>
					<div class="card-body">
					
						<div class="row">
							<div class="col-md-12">
							
								<div class="form-group">
									<div class="row">
										<label class="control-label col-md-3" for="canvas_size">Meme size:</label>
										<div class="col-md-7">
											<input style="width:100%" id="canvas_size" type="range" min="0" max="1000" value="500"/>
										</div>
										<div class="col-md-2 setting-value">
											<span id="canvas_size__val">500</span>px
										</div>
									</div>
								</div>
								
								<div class="form-group">
									<div class="row">
										<label class="control-label col-md-3" for="text_font_size">Font size:</label>
										<div class="col-md-7">
											<input style="width:100%" id="text_font_size" type="range" min="0" max="72" value="28"/>
										</div>
										<div class="col-md-2 setting-value">
											<span id="text_font_size__val">28</span>pt
										</div>
									</div>
								</div>
								
								<div class="form-group">
									<div class="row">
										<label class="control-label col-md-3" for="text_line_height">Line height:</label>
										<div class="col-md-7">
											<input style="width:100%" id="text_line_height" type="range" min="0" max="100" value="15"/>
										</div>
										<div class="col-md-2 setting-value">
											<span id="text_line_height__val">15</span>pt
										</div>
									</div>
								</div>
								
								<div class="form-group">
									<div class="row">
										<label class="control-label col-md-3" for="text_stroke_width">Stroke width:</label>
										<div class="col-md-7">
											<input style="width:100%" id="text_stroke_width" type="range" min="0" max="20" value="4"/>
										</div>
										<div class="col-md-2 setting-value">
											<span id="text_stroke_width__val">4</span>pt
										</div>
									</div>
								</div>
								
							</div>
						</div>
					
					</div>
				</div>

				
				
			</div>
		</div>
		
		
	</div>

	
	<!-- Bootstrap core JavaScript
	================================================== -->
	<!-- Placed at the end of the document so the pages load faster -->
	<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
	<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
	<script src="app.js"></script>
	
	</body>
</html>

Wie ihr hier seht, reicht es für Canvas vollkommen aus, einfach einen “canvas” HTML-Tag zu definieren. Optional können die Abmessungen anstatt via CSS-Definitionen auch direkt inline mitgegeben werden. Der Text innerhalb des Canvas-Tags wird nur in Browsern angezeigt, die diese Technologie nicht unterstützen.

Die Slider-Elemente bekommen alle eine eigenständige ID zugewiesen, über welche wir später mittels Javascript bzw. jQuery zugreifen werden. So bekommen wir nicht nur deren eingestellten Wert, sondern können deren Start- und Endbereich auch dynamisch anhand der Metadaten der Bilddatei (im Bezug auf dessen Abmessungen) setzen.

 

Schritt 2: CSS-Datei vorbeiten

Wie zuvor bereits erwähnt, wird sich sich CSS-Datei style.css sehr minimalistisch gestalten, da wir hier in diesem Tutorial auf die Standard-Elemente von Bootstrap zurückgreifen.

body {
  padding-top: 5rem;
}

img#start-image {
	position: absolute;
	left: -10000px;
	top: -10000px;
}

.setting-value {
	text-align: center;
	font-weight: bold;
}

Das Bootstrap-Framework selbst haben wir vorhin bereit über deren CDN eingebunden (inkl. den zugehörigen Javascript-Ressourcen).

Das Bild mit der ID “start-image” dient lediglich als Träger unserer Quell-Bilddatei für das Canvas-Rendering. Aus diesem Grund verschieben wir es der Einfachheit halber aus dem sichtbaren Bereich vom Bildschirm raus.

 

Schritt 3: Javascript-Logik einbauen

Um die Javascript-Logik einbauen zu können, erstellen wir uns zuallererst die (bereits im HTML verlinkte) Datei namens app.js mit folgendem jQuery-Grundgerüst.

$(document).ready(function(){

	// here will be the whole application logic...

});

Innerhalb dieses Blocks folgen nun alle weiteren Schritte.

Dafür beginnen wir als Erstes mit der Initialisierung vom Canvas-Element mit der ID “meme”:

var canvas = document.getElementById('meme');
ctx = canvas.getContext('2d');

In der Variable ctx steht uns nun innerhalb des jQuery-Scopes der Anwendungskontext unseres Canvas-Element zur Verfügung, das selbst über die Variable canvas zur Verfügung steht.

Beginnen wir damit, unserer Applikation Leben einzuhauchen, indem wir die Haupt-Funktion für die Meme-Generierung erstellen. Dazu erstellen wir zuerst eine leere Funktion namens drawMeme().

// core drawing function
var drawMeme = function() {
	// here will be our "drawing-code" for our memes...

};

Die eigentliche Funktionslogik hierfür folgt dann ein wenig später.

Nachdem wir vorhin in unserem HTML-Markup bereits die beiden Textfelder für den oberen und unteren Textbereich sowie auch die Slider-Eingabefelder für die Konfigurationsmöglichkeiten angelegt haben, registrieren wir nun unsere drawMeme()-Funktion für alle relevanten Events dieser Felder.

In erster Linie sind dies change, keyup und keydown für die Felder mit Texteingabe, sowie der change-Event für unsere Slider-Eingabefelder.

// register event listeners

$(document).on('change keydown keyup', '#text_top', function() {
	drawMeme();
});

$(document).on('change keydown keyup', '#text_bottom', function() {
	drawMeme();
});

$(document).on('input change', '#text_top_offset', function() {
	$('#text_top_offset__val').text( $(this).val() );
	drawMeme();
});

$(document).on('input change', '#text_bottom_offset', function() {
	$('#text_bottom_offset__val').text( $(this).val() );
	drawMeme();
});

$(document).on('input change', '#text_font_size', function() {
	$('#text_font_size__val').text( $(this).val() );
	drawMeme();
});

$(document).on('input change', '#text_line_height', function() {
	$('#text_line_height__val').text( $(this).val() );
	drawMeme();
});

$(document).on('input change', '#text_stroke_width', function() {
	$('#text_stroke_width__val').text( $(this).val() );
	drawMeme();
});

$(document).on('input change', '#canvas_size', function() {
	$('#canvas_size__val').text( $(this).val() );
	drawMeme();
});

Der zusätzliche input-Event bei diesen Feldern sorgt dafür, dass diese Handler auch während der Benutzung des Slider-Eingabgefelds (also während dem Hin- und Herziehen mit der Maus) getriggert werden.

Zusätzlich zeigen wir den gesetzten Wert dieser Felder noch im Konfigurationsformular an, indem wir mit Hilfe der text-Funktion diesen in das entsprechende Element übergeben, und dessen Inhalt mit dem Neuen überschreiben.

Nun beginnen wir, nach und nach unsere drawMeme()-Funktion aufzubauen.

// core drawing function
var drawMeme = function() {
	var img = document.getElementById('start-image');

	var fontSize = parseInt( $('#text_font_size').val() );
	var memeSize = parseInt( $('#canvas_size').val() );
	
	// set form field properties
	$('#text_top_offset').attr('max', memeSize);
	$('#text_bottom_offset').attr('max', memeSize);
	
	
};

Wir holen uns hier in die Variable img unsere Quell-Bilddatei, welche wir mit der ID “start-image” versehen haben. Uns steht nun das entsprechende Javascript-Objekt davon in dieser Variable zur Verfügung.

Der Grund, warum hier mit dem blanken Javascript-Objekt, anstatt dem jQuery-Equivalent gearbeitet wird, ist, dass wir für die Canvas-Funktionen dieses Javascript-Objekt in dieser unberührten Form benötigen.

Des Weiteren greifen wir auch die gewünschte Größe unseres Memes vom Eingabefeld ab, und setzen im weiteren Schritt auch dementsprechend die Maximum-Werte für unsere Offset-Konfigurationsoptionen (für die vertikale Verschiebung der Texte), damit die Slider-Elemente auch entsprechend begrenzt sind.

Als Nächstes konfigurieren wir das Canvas-Element, indem wir dessen Abmessungen festlegen. Im Zuge dessen übergeben wir diesem mittels der Canvas-Funktion drawImage() auch unsere Quelle-Bilddatei, die wir vorhin in der Variable img gespeichert haben.

// initialize canvas element with desired dimensions
canvas.width = memeSize;
canvas.height = memeSize;

ctx.clearRect(0, 0, canvas.width, canvas.height);

// calculate minimum cropping dimension
var croppingDimension = img.height;
if( img.width < croppingDimension ){
	croppingDimension = img.width;
}

ctx.drawImage(img, 0, 0, croppingDimension, croppingDimension, 0, 0, memeSize, memeSize);

Um Verzerrungen vom Quell-Bild zu vermeiden, nutzen wir die von Canvas bereitgestellte Beschneidungsfunktion (“Cropping”). Den Maximalwert dafür holen wir uns direkt von unserer Quell-Datei, indem wir dessen Höhe und Breite miteinander vergleichen, und so die geeignete Größe ermitteln.

Nachdem ein Meme in der Regel immer quadratisch ist (Größe in der Variable memeSize definiert), sollte die Bilddatei ebenfalls im gleichen quatratischen Bildverhältnis (Größe in der Variable croppingDimension definiert) vorliegen.

Die Quell-Bilddatei wird in diesem Fall auf die Meme-Abmessungen herunterskaliert, und zu einem quadratischen Bild zurecht geschnitten. Der Einfachheit halber verzichten wir auf weitere Ausrichtungen für diesen “Cropping”-Vorgang.

Um die Texte nun in unserem Meme-Canvas positionieren zu können, schreiben wir uns eine kleine Hilfsfunktion wrapText() (außerhalb der drawMeme()-Funktion):

// build inner container for wrapping text inside
var wrapText = function(context, text, x, y, maxWidth, lineHeight, fromBottom) {
	var pushMethod = (fromBottom) ? 'unshift' : 'push';

	lineHeight = (fromBottom) ? -lineHeight : lineHeight;

	var lines = [];
	var y = y;
	var line = '';
	var words = text.split(' ');

	for (var n = 0; n < words.length; n++) {
		var testLine = line + ' ' + words[n];
		var metrics = context.measureText(testLine);
		var testWidth = metrics.width;

		if (testWidth > maxWidth) {
			lines[pushMethod](line);
			line = words[n] + ' ';
		} else {
			line = testLine;
		}
	}
	lines[pushMethod](line);

	for (var k in lines) {
		context.strokeText(lines[k], x, y + lineHeight * k);
		context.fillText(lines[k], x, y + lineHeight * k);
	}
};

Im Grunde übergeben wir hier nur den Canvas-Scope über den Parameter context, ein paar Parameter, die den einzufügenden Text  betreffen, sowie dessen Positionierungs-Eigenschaften.

Es geht hier darum, anhand der Schriftgröße und dem Zeilenabstand die konkreten X- und Y-Koordinaten zu berechnen, und in den Canvas-Scope (Variable context) zu übergeben – ihr könnt das aber mal “einfach so” übernehmen.

Zurück in der drawMeme()-Funktion legen wir nun anhand unserer Konfigurationsfelder aus der HTML-Maske die Grundeinstellungen für den Canvas-Text fest.

Anschließend ändern wir die Texteingabe auf Großbuchstaben, und übergeben die gewünschte Y-Position. Die X-Koordinate ist genau die Hälfte der Meme-Breite, da der Text mittig positioniert werden soll.

ctx.lineWidth  = parseInt( $('#text_stroke_width').val() );
ctx.font = fontSize + 'pt sans-serif';
ctx.strokeStyle = 'black';
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';

var text1 = $('#text_top').val();
text1 = text1.toUpperCase();
x = memeSize / 2;
y = parseInt( $('#text_top_offset').val() );

var lineHeight = fontSize + parseInt( $('#text_line_height').val() );
var maxTextAreaWidth = memeSize * 0.85;

wrapText(ctx, text1, x, y, maxTextAreaWidth, lineHeight, false);

Mit der Variable maxTextAreaWidth schränken wir die maximale Textbreite auf 85% ein. Dies hat aber in erster Linie nur optische Gründe.

Das Gleiche wiederholen wir dann auch für den unteren Text, nur dass wir die Grundeinstellungen der Schrift nicht mehr neu definieren, sondern einfach für die entsprechenden Eigenschaften überschreiben müssen.

ctx.textBaseline = 'bottom';
var text2 = $('#text_bottom').val();
text2 = text2.toUpperCase();
y = parseInt( $('#text_bottom_offset').val() );

wrapText(ctx, text2, x, y, maxTextAreaWidth, lineHeight, true);

Damit ihr nun auch beliebige Bilder auswählen könnt, verleihen wir unserem File-Eingabefeld auch die entsprechende Funktion:

// read selected input image from upload field and display it in browser
$("#imgInp").change(function(){
	var input = this;
	
	if (input.files && input.files[0]) {
		var reader = new FileReader();
		
		reader.onload = function (e) {
			$('#start-image').attr('src', e.target.result);
		}

		reader.readAsDataURL(input.files[0]);
	}
	
	window.setTimeout(function(){
		drawMeme();
	}, 500);
});

Wenn ein neues Bild ausgewählt wird, liest der Javascript-FileReader dieses ein und setzt dessen Base64-String als Source (“src”-Attribut) unserer Quell-Bilddatei (mit der ID “start-image”).

Anschließend triggern wir wieder die drawMeme()-Funktion, damit diese nun das neue Bild einliest und entsprechend verarbeitet.

 

Nun sollte sich auf der Meme-Generator-Seite bereits etwas tun, wenn ihr mal einen der Slider-Elemente oder Eingabefelder ändert, da dann mindestens ein Change-Event getriggert wird, durch welchen die drawMeme()-Funktion aufgerufen wird.

Damit auch gleich beim Seitenaufruf was zu sehen ist, rufen wir die drawMeme()-Funktion einfach initial beim Laden der Seite auf:

// init at startup
window.setTimeout(function(){
	drawMeme();
}, 100);

Das funktionierende Beispiel könnt ihr euch hier herunterladen:

DEMO HERUNTERLADEN

 

Schritt 4: Optionalen Download-Link hinzufügen

Ihr könnt von eurem Canvas-Element mit Hilfe der Funktion toDataURL() jederzeit das aktuelle Bild als Base64-String auslesen.

Eine Möglichkeit wäre es, diesen Base64-String als “href”-Attribut für euren Download-Link zu setzen, und mittels dem “download”-Attribut den gewünschten Dateinamen anzugeben.

$('#download_meme').click(function(e){
	$(this).attr('href', canvas.toDataURL());
	$(this).attr('download', 'meme.png');
});

Beachtet hier aber, dass dies nicht von allen Browsern unterstützt wird, und demensprechend nicht überall funktionieren wird.

Im Idealfall solltet ihr diesen Base64-String beispielsweise mittels jQuery.post() oder jQuery.ajax an ein serverseitiges Script senden, welches euch den String dann mit dem entsprechenden “Content-Type”-Header (“image/png”) wieder zurückliefert.

So wäre sichergestellt, dass es wirklich in allen Browsern funktioniert.

 

Fazit

Mit Canvas bekommt ihr ein mächtiges Tool in die Hand gelegt, wenn es darum geht, dynamisch Grafiken im Browser mittels Javascript zu zeichnen.

Am häufigsten werdet ihr Canvas im Zusammenhang mit Graph-Libraries finden, da sich diese Technik vor allem im Statistik-Bereich sehr etabliert hat.

Mit Canvas zu arbeiten ist nicht besonders schwierig. Das einzige, was schnell mal kompliziert werden kann, ist – wie in unserem Beispiel des Meme-Generators – meist die korrekte Ermittlung bzw. Berechnung der einzelnen Positionen der generierten Elemente.