Let's build a convenience function to load arbitrary image files and convert them to be suitable for thermal printing, using PHP libraries ImageMagick and escpos-php
.
Until now I've been manually preparing images (such as the Magic 8-Ball) in Photoshop, resizing to fit the receipt printer resolution and then applying brightness adjustments and 1-bit dithering. This isn't particularly difficult, however it is an irreversible process which locks in a specific resolution and does take some fiddling. Much better to write some code to apply the necessary transformations on-demand.
Here's what we want to do to convert the original image into something printable:
- Load the image from disk
- Remove transparency and flatten layers onto a white background
- Convert to greyscale
- Optionally rotate the image (convenience feature)
- If too wide, resize to fit receipt paper resolution
- If too narrow, add padding depending on preferred left/center/right alignment
- Brighten the image, to compensate for perceived darkness of printed images
- Dither greyscale image into a 1-bit bitmap
(Click the images to see at full size; also ensure your browser isn't downscaling — dithered bitmaps have weird aliasing artifacts when scaled.)
PHP Code: LoadPrinterOptimizedImage
The function implementation is relatively straightforward, but note that it doesn't do any error-checking. (Bad programmer, no cookie!) We assume php-imagick
and escpos-php
are installed and available.
define ('PAPER_WIDTH_PIXELS', 384);
define ('PRINTER_IMAGE_GAMMA', 1.8);
function LoadPrinterOptimizedImage (string $filename,
int $rotate = 0,
int $align = Imagick::ALIGN_CENTER,
float $gamma = PRINTER_IMAGE_GAMMA
) : ImagickEscposImage
{
$imagick = new Imagick();
$imagick->readImage ($filename);
// Remove transparency, flatten layers onto a white background
$imagick->setImageBackgroundColor ('white');
$imagick->setImageAlphaChannel (Imagick::ALPHACHANNEL_REMOVE);
$imagick->mergeImageLayers (Imagick::LAYERMETHOD_FLATTEN);
// Convert to greyscale
$imagick->transformImageColorspace (Imagick::COLORSPACE_GRAY);
// Optionally rotate the image, constrain to 90 degree increments
$rotate = 90 * ((int) round ($rotate / 90.0));
if ($rotate)
{
$imagick->rotateImage ('white', $rotate);
$imagick->setImagePage ($imagick->getImageWidth(), $imagick->getImageHeight(), 0, 0);
}
// Store image dimensions
$imgWidth = $fileWidth = $imagick->getImageWidth();
$imgHeight = $fileHeight = $imagick->getImageHeight();
// Shrink image if too wide
if ($fileWidth > PAPER_WIDTH_PIXELS)
{
$imgHeight = (int) round ((1.0 * $fileHeight * PAPER_WIDTH_PIXELS) / $fileWidth);
$imgWidth = PAPER_WIDTH_PIXELS;
$imagick->resizeImage ($imgWidth, $imgHeight, Imagick::FILTER_LANCZOS, 0.5);
$imagick->setImagePage ($imgWidth, $imgHeight, 0, 0);
}
// Align image if too narrow
else if ($fileWidth < PAPER_WIDTH_PIXELS)
{
switch ($align)
{
case Imagick::ALIGN_CENTER :
$borderWidth = (int) ((PAPER_WIDTH_PIXELS - $fileWidth) / 2);
break;
case Imagick::ALIGN_RIGHT :
$borderWidth = PAPER_WIDTH_PIXELS - $fileWidth;
break;
// Assume printer will left-align image by default
default :
$borderWidth = 0;
}
// Add a white border as an easy way to generate padding
// Overflowing border pixels are cropped off, below
if ($borderWidth > 0)
{
$imgWidth = $fileWidth + $borderWidth + $borderWidth;
$imagick->borderImage ('white', $borderWidth, 0);
$imagick->setImagePage ($imgWidth, $imgHeight, 0, 0);
}
}
// Crop image to guarantee it doesn't exceed printable width
$imgWidth = min ($imgWidth, PAPER_WIDTH_PIXELS);
$imagick->cropImage ($imgWidth, $imgHeight, 0, 0);
$imagick->setImagePage ($imgWidth, $imgHeight, 0, 0);
// Apply gamma to brighten image while keeping black and white points
$imagick->gammaImage ($gamma);
// Dither! Use palette from built-in bitmap of alternating black/white pixels
$palette = new Imagick();
$palette->newPseudoImage (32, 32, 'pattern:GRAY50');
$imagick->remapImage ($palette, Imagick::DITHERMETHOD_FLOYDSTEINBERG);
// Dither! (doesn't look as nice as floyd-steinberg)
//$imagick->quantizeImage (2, Imagick::COLORSPACE_GRAY, 0, true, false);
// Threshold image at 50% (higher values means more goes to black)
$imagick->thresholdImage (0.5 * $imagick->getQuantum());
// Convert Imagick object to ImagickEscposImage
$img = new ImagickEscposImage (null, false);
$img->readImageFromImagick ($imagick);
return ($img);
}
The resulting ImagickEscposImage
object can be passed to the Printer::bitImage()
function for printing.
Dithering Methods
There are a few ways to downsample a greyscale image into a dithered 1-bit image in ImageMagick. Using remapImage
allows you to specify Floyd-Steinberg dithering, which has more pleasing results than some other methods.
An alternative method is to call quantizeImage
, which produces adequate results in less code. It's unclear what dithering method is implemented here, but I prefer the results of remapImage
.
ImageMagick also has various ways to perform an ordered dither, but the results are inferior for this use case.
Redundant Code
There is some unnecessary code in the function above. It's there since I can't be bothered to learn the detailed internal workings of PHP IMagick, and including it makes the code theoretically more robust, without hurting anything.
Calling setImagePage
after each image manipulation is a means to ensure the virtual “canvas” of the image matches the actual dimensions of the image at each step. Likely unnecessary, but I'm unclear about what situations IMagick would allow these to become mismatched, so I leave it in.
Using thresholdImage
is definitely not needed after the dither, but I like to call it on every bitmap before printing to ensure we're dealing with a purely black-and-white image. Doesn't hurt anything here, but feel free to delete.
Finally, I include some (int)
casts and explicit floating point values in a few bits of math throughout. I truly have no idea what the type promotion rules are in a weakly-typed language like PHP, so I'm coding based on my knowledge of C, and just assuming PHP is similar. Probably some unnecessary casts and values in there, but no harm done.
Spock Module
Since Spock has been such a cooperative test subject during development, let's give him his own screaming.computer module.
function RunModule_spock (Printer $printer) : bool
{
$printer->setEmphasis (true);
$printer->text ("Spock\n");
$printer->setEmphasis (false);
$quotes = array (
'Live long and prosper',
'Peace, and long life',
'Infinite diversity in infinite combinations',
'Logic is the beginning of wisdom, not the end',
'Challenge your preconceptions, or they will challenge you',
'The needs of the many outweigh the needs of the few, or the one',
'Once you have eliminated the impossible, whatever remains, however improbable, must be the truth',
'Insufficient facts always invite danger',
'I have been, and always shall be, your friend',
'Humans smile with so little provocation',
"My congratulations, Captain \u{2014} a dazzling display of logic",
'Computers make excellent and efficient servants, but I have no wish to serve under them',
'I will do whatever logically needs to be done',
'Time is fluid... like a river with currents, eddies, backwash',
'Change is the essential process of all existence',
'Insults are effective only where emotion is present',
'It is only logical'
);
$printer->setFont (Printer::FONT_B);
$printer->text (mb_padstr (mb_wordwrap ("\u{201C}" . RandomElementFromArray ($quotes) . "\u{201D}", PAPER_WIDTH_FONTB_CHARS - 1), 1, 0) . "\n\n");
$printer->setFont (Printer::FONT_A);
$img = LoadPrinterOptimizedImage (__DIR__ . '/spock.png');
$printer->bitImage ($img);
return (true);
}
Nothing fancy here. Grab a random quote, align and wrap the text, resize and dither our image, then print it all out.
The mb_padstr
function is new code I wrote to add left and right padding to each line of a wrapped string. Many receipt printers include ESC/POS
functions to natively set left and right column limits for string output (which would achieve the same effect), but I find it easier to just manipulate the string in PHP and output normally.
You can see the effect of mb_padstr
where both lines of the quote are left-indented by one space.
Bonus: One of the “Spock quotes” is not a Spock quote at all. Before you check that link, do you know which one?
I maintain that wise Mr. Spock was likely to have said this at some point as well, so it's acceptable to include here.