EAN-13 en PHP
Wat doe je als je veel vrije tijd hebt en geen zin hebt om voor de televisie te hangen? Dan ga je programmeren natuurlijk!
In mijn "PHP carrière" heb ik veel verschillende dingen gemaakt, zoals een webscraper, sudoku-solver en een IRC client. Dit zijn meestal resultaten geweest van tijdverdrijf (ook wel "hobby" genoemd) en leverden niet echt iets op waar je wat mee kunt doen. Dat moet anders kunnen, nietwaar?
Zodoende besloot ik om bij m'n volgende keer dat ik dit uitoefende – hoe heette dat ook alweer, hobby?– iets te maken waar je echt wat aan hebt. Het resultaat beschrijf ik hier.
EAN kun je dat eten?
Als je om je heen kijkt zie je altijd interessante dingen, zo viel mijn oog op de streepjescode van {sluipreclame hier}. Ik dacht, zo moelijk kan dat toch niet zijn om je eigen streepjescode te genereren vanuit een reeks cijfers? Nee, het is zeer makkelijk eigenlijk!
Ik was reeds op de hoogte dat er veel verschillende soorten barcodes zijn, waarbij EAN13 de meest gebruikte is in de supermarkten. Derhalve besloot ik om hiermee aan de slag te gaan.
First things first
Aangezien er verschillende soorten barcodes zijn en ik vrij veel vrije tijd heb, bedacht ik dat ik in de toekomst wellicht ook met andere types barcode aan de slag zou gaan. Om het later gemakkelijk in één library te zetten besloot ik om te beginnen met een abstract class waar de individuele types barcodes vanuit extenden. In een bestandstructuur kun je dit zien als de master, waarbij alle andere klasses uitgaan van dit zgn "skelet".
<?php
abstract class digitBarCodes {
abstract public function calculateChecksum($code);
abstract public function getBitarray($code);
public function drawBarcode($something) {
/* Deze functie moet nog geschreven worden, maar zal later een afbeelding
genereren en returnen. */
}
}
In deze superclass zullen verder alle eventuele ondersteunende functies komen die gebruikt worden in door de verschillende barcode types. Uitleg over de getBitarray-functie volgt, maar deze zal later gebruikt worden om de daadwerkelijke afbeelding te kunnen genereren.
Je ziet bovendien de drawBarcode functie, deze kan door de verschillende type barcodes gebruikt worden om een afbeelding te laten genereren. Aangezien ik nu nog niet weet aan de hand van welke variabelen dit zal zijn, heb ik de placeholder something gebruikt. Dit zullen we later aanpassen.
Genoeg voorwerk gedaan, laten we aan de slag gaan!
Check! Sum? kun je dat eten?
Laten we beginnen met het berekenen van de checksum. Dit getal dient als controlemiddel zodat een scanner (bij de kass bijvoorbeeld) weet dat de gescande code inderdaad klopt - en niet dat hij 'n "foutje" heeft gemaakt. Het kan namelijk in de "echte wereld" voorkomen dat een barcode beschadigt is geraakt, daarom bestaat zo'n checksum code.
De checksum methode van EAN13 in het geval van een getal met een even lengte werkt als volgt:
- Van alle oneven getallen word de som genomen
- Bij alle even getallen word het getal eerst vermenigvuldigd met 3 en daar dan de som van genomen
- De twee sommen worden bij elkaar opgeteld
- Bereken modulo 10 van deze som (noemen we X)
- 10 - X en je hebt de checksum
Als het getal een oneven lengte heeft (wat in EAN13 niet voorkomt, maar wel in EAN8) worden niet de even getallen vermenigvuldigd met 3, maar de oneven getallen.
Neem als voorbeeld het getal 400638133393:
- Bereken de som van de oneven getallen (4 + 0 + 3 + 1 + 3 + 9 = 20)
- Bereken de som van elk even getal vermenigvuldigd met 3 ((3 * 0) + (3 * 6) + (3 * 8) + (3 * 3) + (3 * 3) + (3 * 3) = 69)
- 20 + 69 = 89
- Modulo 10 van 89 = 9
- 10 - 9 = 1
Als je 't nog niet helemaal snapt, kijk nog even naar de uitleg op de eerder genoemde Wikipedia pagina.
And now for something completely the same
De hierboven beschreven methode werkt op papier erg gemakkelijk, maar in een script is dit niet optimaal. Je kijkt eerst hoe lang de input is, deelt daarop het getal in verschillende groepjes, voert op elk groepje een eigen berekening uit, telt de groepjes bij elkaar op en voert dan de modulo uit. Hoewel je dit letterlijk kunt implementeren, is er een handigere (en snellere?) methode om dit te doen.
We weten dat het laatste getal vermenigvuldigt zal worden met 3, het getal daarvoor met 1, het getal daarvoor met 3 et cetera. Waarom beginnen we daarom niet simpelweg achteraan en werken we vanuit daar naar voren? Bovendien hoeven we niet 2 sommen uit te rekenen, maar kunnen we simpelweg één variabele bijhouden met de som van zowel de even getallen, als de oneven.
Laten we het daarom even anders opschrijven, hoe we 't beter kunnen aanpakken in ons te schrijven script:
- Initialiseer een variabele som
- Neem het getal en draai deze om (dwz. 400638133393 word 393331836004)
- Kijk of het getal even of oneven is
- Is het getal oneven? Vermenigvuldig het getal met 3 en tel op bij de som
- Is het getal even? Tel het getal op bij de som
- Voer modulo 10 uit
- 10 - modulo
Hoewel het nog steeds 5 stappen zijn op papier, is dit veel gemakkelijker uit te voeren! Voorbeeld in PHP:
<?php
function calculateChecksum($code) {
$sum = 0;
foreach (str_split(strrev($code)) AS $index => $digit) {
//Thank god PHP thinks 1 & 0 == 0
$sum += (1 & $index) ? $digit : $digit * 3;
}
return ($sum % 10 == 0) ? 0 : 10 - ($sum % 10);
}
De strrev-functie draait het getal om (alsof het een string is, dank PHP voor weak typing!), waarbij de str_split-functie de string opdeelt in losse (een array van) cijfers. De foreach-loop itereert vervolgens over deze cijfers. De som word in elke iteratie verhoogd met het cijfer, of het cijfer * 3, afhankelijk of het cijfer even danwel oneven is.
Voor de oplettende lezers: In bovenstaande functie doen we het eigelijk nét iets anders dan beloofd met even/oneven. Dit komt komt omdat PHP een 0-index kent. Hierdoor staat het laatste getal in de oorspronkelijke code op de 0'ste plaats en het op-één-na-laatste op de 1'ste plaats.
Verder vind er in de laatste regel nog een extra controle plaats. Als de som bijvoorbeeld 90 is, zal 10 % 10 gelijk zijn aan 0. In dat geval moeten we simpelweg 0 gebruiken, in alle andere gevallen vind 10 - modulo plaats (zoals hierboven beschreven in stap 5).
And now for something completely different
Laten we aan de slag gaan met het encoderen van de code. Deze stap is nodig om de code om te zetten naar "iets" wat gebruikt kan worden om de daadwerkelijke afbeelding te maken. De encodering van de EAN13 gaat als volgt:
- Er wordt gekeken naar het eerste cijfer in de code
- Aan de hand van het eerste cijfer worden de cijfers 2-7 geëncodeert
- De laatste cijfers wordend volgens eenzelfde stramien geëncodeert
Uit deze uitleg kun je al opmaken dat het eerste getal niet in de barcode zelf voorkomt, dit heeft te maken met compatibiliteit met UPC. Voor een exacte uitleg hoe de encodering plaatsvindt, lees de tweemaal eerder genoemde Wikipedia pagina.
We maken nu de functie om de mask op te vragen aan de hand van de code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
<?php
function getStructureMask($code) {
if (!ctype_digit($code)) {
return false;
}
switch (substr($code, 0, 1)) {
case '0':
return 'LLLLLLRRRRRR';
case '1':
return 'LLGLGGRRRRRR';
case '2':
return 'LLGGLGRRRRRR';
case '3':
return 'LLGGGLRRRRRR';
case '4':
return 'LGLLGGRRRRRR';
case '5':
return 'LGGLLGRRRRRR';
case '6':
return 'LGGGLLRRRRRR';
case '7':
return 'LGLGLGRRRRRR';
case '8':
return 'LGLGGLRRRRRR';
case '9':
return 'LGGLGLRRRRRR';
}
return false; //Even though we can never actually reach this
}
We hadden hier een lookuptabel kunnen gebruiken, maar aangezien je deze functie slechts éénmaal aanroept heb ik gekozen voor een switch oplossing. Aan de hand van het eerste getal van de code geeft de functie een string terug waarmee de individuele cijfers geëncodeert kunnen worden. Zoals je ziet gebruik ik dezelfde benamingen zoals in de wikipedia pagina.
Zoals op de wikipedia pagina staat is R afgeleid van L en G van R. Hierdoor hoeven we alleen de L te implementeren (d.m.v een lookuptable, wat eigenlijk een array is). Daarna kunnen we simpelweg R en G berekenen.
Het jammere is dat je in PHP niet direct gebruik kunt maken van binary getallen, er zijn derhalve twee oplossingen:
- Gebruik een string ("01010")
- Gebruik een array met integers (array(0, 1, 0, 1, 0))
Aangezien het niet handig is om met een string te rekenen, zou je al snel een string constant omzetten naar array/string/array/string. Daarom besloot ik om te werken met een array met integers welke ik vanaf nu een bitArray noem.
Net ontdekt dat binary integers vanaf PHP 5.4.0 is ondersteund, joeppie!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
<?php
$codeLookupTable = array(
'L' => array(
'0' =>
array(0, 0, 0, 1, 1, 0, 1),
'1' =>
array(0, 0, 1, 1, 0, 0, 1),
'2' =>
array(0, 0, 1, 0, 0, 1, 1),
'3' =>
array(0, 1, 1, 1, 1, 0, 1),
'4' =>
array(0, 1, 0, 0, 0, 1, 1),
'5' =>
array(0, 1, 1, 0, 0, 0, 1),
'6' =>
array(0, 1, 0, 1, 1, 1, 1),
'7' =>
array(0, 1, 1, 1, 0, 1, 1),
'8' =>
array(0, 1, 1, 0, 1, 1, 1),
'9' =>
array(0, 0, 0, 1, 0, 1, 1)
)
);
function encode($integer, $codeBase) {
if ($codeBase == 'R') {
return bitArrayNOT(codeLookupTable['L'][$integer]);
} elseif ($codeBase == 'G') {
return array_reverse(bitArrayNOT(codeLookupTable['L'][$integer]));
} else {
return $codeLookupTable['L'][$integer];
}
}
De lookuptabel is overgenomen van de wikipedia pagina en spreekt voor zich neem ik aan. In de encode functie kijk ik simpelweg wat het getal is en in welke codeBase dit staat. Ik maak nu gebruik van een nog niet gedefineerde functie bitArrayNOT. Deze functie doet een simpele bitwise NOT-functie uitvoeren op een getal. Concreet betekent dit dat een 0 een 1 word, een 1 een 0 word. Deze functie staat overigens in de abstractclass zodat andere barcodes deze functie ook kunnen gebruiken:
1 2 3 4 5 6 7 8 9 10
<?php
abstract class digitBarCodes {
/* De andere functies staan hier nog steeds */
public function bitArrayNOT($bits) {
foreach ($bits AS $index => $bit) {
$bits[$index] = 1 ^ $bit;
}
return $bits;
}
}
Joining them up
Zo, dit alles bij elkaar begint al een aardig geheel te worden. Om alles bij elkaar te brengen gaan we een overkoepelende functie maken die aan de hand van een code een bitArray teruggeeft. Deze bitArray kunnen we later aan een plot-functie geven die de code daadwerkelijk omzet naar een barcode.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
<?php
function getBitarray($code) {
if (!ctype_digit($code) OR !ctype_digit($code)) {
return false;
}
if (strlen($code) == BARCODE_LENGTH_NO_CHECKSUM) {
$code = $code . calculateChecksum($code);
} elseif (strlen($code) == (BARCODE_LENGTH_NO_CHECKSUM + BARCODE_CHECKSUM_LENGTH)) {
if (calculateChecksum(substr($code, 0, (0 - BARCODE_CHECKSUM_LENGTH))) != substr($code, (0 - BARCODE_CHECKSUM_LENGTH))) {
return false;
}
} else {
return false;
}
$return = array(array(1, 0, 1)); // Left guard
$structureMask = str_split(getStructureMask($code));
foreach ($structureMask AS $index => $digit) {
$return[] = encode($digit, $structureMask[$index]);
if ($index == 5) {
$return[] = array(0, 1, 0, 1, 0); // Center guard
}
}
$return[] = array(1, 0, 1); // Right guard
return $return;
}
Show me that image!
De laatste stap die we nog moeten doen is het daadwerkelijk genereren van een afbeelding. Dit gaat verrassend makkelijk aangezien we 't grootste reeds gedaan hebben. De functie die ik beschrijf zullen we opnemen in de abstract-class zodat deze voor alle subclasses beschikbaar is.
Aan de hand van de bitArray die we genereren in de getBitarray-functie kunnen we heel simpel de daadwerkelijke barcode genereren. We hoeven enkel over de bitArray te itereren. Als we een 0 zien doen we niets, zien we een 1 dan zetten we een zwart lijntje. Dan schuiven we één pixel naar rechts en herhalen we dit. We weten niet op welke resolutie de afbeelding getoond worden, dus laten we een functie maken die zowel kleine als grote barcodes kan genereren.
Om een afbeelding met variabele breedte te maken moeten we eerst tellen hoeveel streepjes we zullen zetten. Een simpele count over de bitArray werkt niet omdat we werken met een multi-dimensionale array. De oplossing?
<?php
$imageWidth = count($bitArray, COUNT_RECURSIVE) - count($bitArray);
Voila!
Verder gebruik ik de functie imagelinthink zoals gevonden op in de PHP manual. De rest van de code spreekt voor zich:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
<?php
function generateBarcodeImage($bitArray, $size=1) {
$imageWidth = count($bitArray, COUNT_RECURSIVE) - count($bitArray);
$image = imagecreate(($imageWidth * $size), (80 * $size));
imagecolorallocate($image, 255, 255, 255); // White background
$imColorBarcode = imagecolorallocate($image, 0, 0, 0); // Black color
$xPos = 0;
foreach ($bitArray AS $bitArray) {
foreach ($bitArray AS $bit) {
if ($bit == 1) {
imagelinethick($image, $xPos, 0, $xPos, (80 * $size), $imColorBarcode, $size);
}
$xPos += $size;
}
}
return $image;
}
The result
Uiteraard laten we dit geen losse functies, maar hebben we dit netjes in één class gezet. Het hele resultaat tot zover is als volgt:
ean13.class.php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
<?php
abstract class digitBarCodes {
/**
* Dummy function for returning the checksum based on the
* code. Returns false if no checksum could be generated
*
* @param integer The code
* @return integer/False The checksum as digit or False
* when no checksum could be created
*/
abstract public function calculateChecksum($code);
/**
* Dummy function to generate a bitArray from the code.
* This bitArray can be used to render the actual
* barcode as an image.
* Returns False when no bitArray could be generated,
* for example when the checksum failed or non-valid
* characters were found.
*
* @param string The code with or without the checksum
* @param array/False A bitArray or False otherwise
*/
abstract public function getBitarray($code);
/**
* Dummy function to return the code if it's valid,
* if no checksum is provided the return will include this
*
* @param string The code to check
* @param string/False The valid code or False otherwise
*/
abstract public function getValidatedCode($code);
/**
* Bitwise NOT on a bitArray (an array containing 0 or 1's)
*
* @param array The bitArray
* @return array The NOT bitArray
*/
public function bitArrayNOT($bits) {
foreach ($bits AS $index => $bit) {
$bits[$index] = 1 ^ $bit;
}
return $bits;
}
/**
* Convert a bitArray to an integer
*
* @param array The bitarray
* @return integer The integer
*/
public function bitArrayToInteger($bits) {
$bitValue = 1;
$sum = 0;
foreach (array_reverse($bits) AS $index => $bit) {
$sum += $bit * $bitValue;
$bitValue *= 2;
}
return $sum;
}
public function getBarcodeImage($code, $size=1) {
$code = $this->getValidatedCode($code);
if ($code == false) {
return false;
}
return $this->generateBarcodeImage($this->getBitarray($code), $size);
}
/**
* Generates an image containing the barcode to use
* in printing or on screen.
*
* @param array The bitarray containing the code
*/
public function generateBarcodeImage($bitArray, $size=1) {
$imageWidth = count($bitArray, COUNT_RECURSIVE) - count($bitArray);
$image = imagecreate(($imageWidth * $size), (80 * $size));
imagecolorallocate($image, 255, 255, 255); // White background
$imColorBarcode = imagecolorallocate($image, 0, 0, 0); // Black color
$xPos = 0;
foreach ($bitArray AS $bitArray) {
foreach ($bitArray AS $bit) {
if ($bit == 1) {
$this->imagelinethick($image, $xPos, 0, $xPos, (80 * $size), $imColorBarcode, $size);
}
$xPos += $size;
}
}
return $image;
}
/**
* Draw a line on the image of a specific width.
*
* @link http://nl.php.net/manual/en/function.imageline.php
*
* @param resource The image resource
* @param integer The x1
* @param integer The y1
* @param integer The x2
* @param integer The y2
* @param resource The color
* @param integer The thickness of the line
* @return boolean True on success, FALSE otherwise
*/
private function imagelinethick($image, $x1, $y1, $x2, $y2, $color, $thick = 1) {
if ($thick == 1) {
return imageline($image, $x1, $y1, $x2, $y2, $color);
}
$t = $thick / 2 - 0.5;
if ($x1 == $x2 || $y1 == $y2) {
return imagefilledrectangle($image, round(min($x1, $x2) - $t), round(min($y1, $y2) - $t), round(max($x1, $x2) + $t), round(max($y1, $y2) + $t), $color);
}
$k = ($y2 - $y1) / ($x2 - $x1);
$a = $t / sqrt(1 + pow($k, 2));
$points = array(
round($x1 - (1+$k)*$a), round($y1 + (1-$k)*$a),
round($x1 - (1-$k)*$a), round($y1 - (1+$k)*$a),
round($x2 + (1+$k)*$a), round($y2 - (1-$k)*$a),
round($x2 + (1-$k)*$a), round($y2 + (1+$k)*$a),
);
imagefilledpolygon($image, $points, 4, $color);
return imagepolygon($image, $points, 4, $color);
}
}
class EAN13Barcode extends digitBarCodes {
const BARCODE_LENGTH_NO_CHECKSUM = 12;
const BARCODE_CHECKSUM_LENGTH = 1;
private $codeLookupTable = array(
'L' => array(
'0' =>
array(0, 0, 0, 1, 1, 0, 1),
'1' =>
array(0, 0, 1, 1, 0, 0, 1),
'2' =>
array(0, 0, 1, 0, 0, 1, 1),
'3' =>
array(0, 1, 1, 1, 1, 0, 1),
'4' =>
array(0, 1, 0, 0, 0, 1, 1),
'5' =>
array(0, 1, 1, 0, 0, 0, 1),
'6' =>
array(0, 1, 0, 1, 1, 1, 1),
'7' =>
array(0, 1, 1, 1, 0, 1, 1),
'8' =>
array(0, 1, 1, 0, 1, 1, 1),
'9' =>
array(0, 0, 0, 1, 0, 1, 1)
));
/**
* Function for returning the checksum based on the input.
* Returns false if no checksum could be generated
*
* @param string The barcode
* @return integer The checksum as digit
*/
public function calculateChecksum($code) {
if (!ctype_digit($code) OR !ctype_digit($code)) {
return false;
}
$sum = 0;
foreach (str_split(strrev($code)) AS $index => $digit) {
//Thank god PHP thinks 1 & 0 == 0
$sum += (1 & $index) ? $digit : $digit * 3;
}
return ($sum % 10 == 0) ? 0 : 10 - ($sum % 10);
}
/**
* Function to generate a bitArray from the code.
* This bitArray can be used to render the actual
* barcode as an image.
*
* @param string The code with or without the checksum
* @param array/False A bitArray or False otherwise
*/
public function getBitarray($code) {
$code = $this->getValidatedCode($code);
if ($code == false) {
return false;
}
$return = array(array(1, 0, 1)); // Left guard
$structureMask = str_split($this->getStructureMask($code));
foreach (str_split(substr($code, 1)) AS $index => $digit) {
$return[] = $this->encode($digit, $structureMask[$index]);
if ($index == 5) {
$return[] = array(0, 1, 0, 1, 0); // Center guard
}
}
$return[] = array(1, 0, 1); // Right guard
return $return;
}
/**
* Function to return the code if it's valid,
* if no checksum is provided the return will include this
*
* @param string The code to check
* @param string/False The valid code or False otherwise
*/
public function getValidatedCode($code) {
if (!ctype_digit($code) OR !ctype_digit($code)) {
return false;
}
if (strlen($code) == self::BARCODE_LENGTH_NO_CHECKSUM) {
return $code . $this->calculateChecksum($code);
} elseif (strlen($code) == (self::BARCODE_LENGTH_NO_CHECKSUM + self::BARCODE_CHECKSUM_LENGTH)) {
if ($this->calculateChecksum(substr($code, 0, (0 - self::BARCODE_CHECKSUM_LENGTH))) != substr($code, (0 - self::BARCODE_CHECKSUM_LENGTH))) {
return false;
}
return $code;
}
return false;
}
/**
* Returns the mask to use for the EAN13 code
*
* @param string The code (with or without the checksum)
* @return string The mask to use
*/
private function getStructureMask($code) {
if (!ctype_digit($code)) {
return false;
}
switch (substr($code, 0, 1)) {
case '0':
return 'LLLLLLRRRRRR';
case '1':
return 'LLGLGGRRRRRR';
case '2':
return 'LLGGLGRRRRRR';
case '3':
return 'LLGGGLRRRRRR';
case '4':
return 'LGLLGGRRRRRR';
case '5':
return 'LGGLLGRRRRRR';
case '6':
return 'LGGGLLRRRRRR';
case '7':
return 'LGLGLGRRRRRR';
case '8':
return 'LGLGGLRRRRRR';
case '9':
return 'LGGLGLRRRRRR';
}
return false; //Even though we can never actually reach this
}
/**
* Returns the L/G/R-code for the integer for usage with EAN13
*
* @param integer The integer to encode
* @param string The codebase (R/G/L)
* @return integer The encoded integer
*/
private function encode($integer, $codeBase) {
if ($codeBase == 'R') {
return $this->bitArrayNOT($this->codeLookupTable['L'][$integer]);
} elseif ($codeBase == 'G') {
return array_reverse($this->bitArrayNOT($this->codeLookupTable['L'][$integer]));
} else {
return $this->codeLookupTable['L'][$integer];
}
}
}
Dat is 't, de hele class netjes opgemaakt en klaar voor gebruik. Als je wilt kun je deze EAN-13 class downloaden. Als je deze class gebruikt in je eigen werk zou een bedankje erg welkom zijn!
Ja, ik weet dat dit reeds bestaat in de PEAR, maar zelf maken is veel leuker en leerzamer bovendien!
Bekijk andere blog posts