diff --git a/escpos/cli.py b/escpos/cli.py index d250852..8a18676 100644 --- a/escpos/cli.py +++ b/escpos/cli.py @@ -37,8 +37,8 @@ DEMO_FUNCTIONS = { {'txt': 'Hello, World!\n',} ], 'qr': [ - {'text': 'This tests a QR code'}, - {'text': 'https://en.wikipedia.org/'} + {'content': 'This tests a QR code'}, + {'content': 'https://en.wikipedia.org/'} ], 'barcodes_a': [ {'bc': 'UPC-A', 'code': '13243546576'}, @@ -87,7 +87,7 @@ ESCPOS_COMMANDS = [ }, 'arguments': [ { - 'option_strings': ('--text',), + 'option_strings': ('--content',), 'help': 'Text to print as a qr code', 'required': True, } @@ -223,10 +223,26 @@ ESCPOS_COMMANDS = [ }, 'arguments': [ { - 'option_strings': ('--path_img',), + 'option_strings': ('--img_source',), 'help': 'Path to image', 'required': True, }, + { + 'option_strings': ('--impl',), + 'help': 'Implementation to use', + 'choices': ['bitImageRaster', 'bitImageColumn', 'graphics'], + }, + { + 'option_strings': ('--high_density_horizontal',), + 'help': 'Image density (horizontal)', + 'type': str_to_bool, + }, + { + 'option_strings': ('--high_density_vertical',), + 'help': 'Image density (vertical)', + 'type': str_to_bool, + }, + ], }, { diff --git a/escpos/escpos.py b/escpos/escpos.py index 8f30a62..6ff75bf 100644 --- a/escpos/escpos.py +++ b/escpos/escpos.py @@ -21,7 +21,7 @@ from .constants import * from .exceptions import * from abc import ABCMeta, abstractmethod # abstract base class support - +from escpos.image import EscposImage class Escpos(object): """ ESC/POS Printer object @@ -49,17 +49,53 @@ class Escpos(object): """ pass - def image(self, path_img): - """ Open and print an image file + def image(self, img_source, high_density_vertical = True, high_density_horizontal = True, impl = "graphics"): + """ Print an image - Prints an image. The image is automatically adjusted in size in order to print it. + :param img_source: PIL image or filename to load: `jpg`, `gif`, `png` or `bmp` + + """ + im = EscposImage(img_source) + + if impl == "bitImageRaster": + # GS v 0, raster format bit image + density_byte = (0 if high_density_vertical else 1) + (0 if high_density_horizontal else 2) + header = GS + b"v0" + six.int2byte(density_byte) + self._int_low_high(im.width_bytes, 2) + self._int_low_high(im.height, 2); + self._raw(header + im.to_raster_format()) + + if impl == "graphics": + # GS ( L raster format graphics + img_header = self._int_low_high(im.width, 2) + self._int_low_high(im.height, 2); + tone = b'0'; + colors = b'1'; + ym = six.int2byte(1 if high_density_vertical else 2) + xm = six.int2byte(1 if high_density_horizontal else 2) + header = tone + xm + ym + colors + img_header + raster_data = im.to_raster_format() + self._image_send_graphics_data(b'0', b'p', header + raster_data); + self._image_send_graphics_data(b'0', b'2', b''); + + if impl == "bitImageColumn": + # ESC *, column format bit image + density_byte = (1 if high_density_horizontal else 0) + (32 if high_density_vertical else 0); + header = ESC + b"*" + six.int2byte(density_byte) + self._int_low_high( im.width, 2 ); + outp = [] + outp.append(ESC + b"3" + six.int2byte(16)) # Adjust line-feed size + for blob in im.to_column_format(high_density_vertical): + outp.append(header + blob + b"\n") + outp.append(ESC + b"2"); # Reset line-feed size + self._raw(b''.join(outp)) - .. todo:: Seems to be broken. Write test that simply executes function with a dummy printer in order to - check for bugs like these in the future. - - :param path_img: complete filename and path to image of type `jpg`, `gif`, `png` or `bmp` + def _image_send_graphics_data(self, m, fn, data): """ - pass + Wrapper for GS ( L, to calculate and send correct data length. + + :param m: Modifier//variant for function. Usually '0' + :param fn: Function number to use, as byte + :param data: Data to send + """ + header = self._int_low_high(len(data) + 2, 2); + self._raw(GS + b'(L' + header + m + fn + data) def qr(self, content, ec=QR_ECLEVEL_L, size=3, model=QR_MODEL_2, native=False): """ Print QR Code for the provided string @@ -84,7 +120,7 @@ class Escpos(object): if not native: # Map ESC/POS error correction levels to python 'qrcode' library constant and render to an image if model != QR_MODEL_2: - raise ValueError("Invalid QR mocel for qrlib rendering (must be QR_MODEL_2)") + raise ValueError("Invalid QR model for qrlib rendering (must be QR_MODEL_2)") python_qr_ec = { QR_ECLEVEL_H: qrcode.constants.ERROR_CORRECT_H, QR_ECLEVEL_L: qrcode.constants.ERROR_CORRECT_L, @@ -97,7 +133,7 @@ class Escpos(object): qr_img = qr_code.make_image() im = qr_img._img.convert("RGB") # Convert the RGB image in printable image - self._convert_image(im) + self.image(im) return # Native 2D code printing cn = b'1' # Code type for QR code diff --git a/escpos/image.py b/escpos/image.py new file mode 100644 index 0000000..af150ba --- /dev/null +++ b/escpos/image.py @@ -0,0 +1,85 @@ +""" Image format handling class + +This module contains the image format handler :py:class:`EscposImage`. + +The class is designed to efficiently delegate image processing to +PIL, rather than spend CPU cycles looping over pixels. + +:author: `Michael Billington `_ +:organization: `python-escpos `_ +:copyright: Copyright (c) 2016 Michael Billington +:license: GNU GPL v3 +""" + +from PIL import Image, ImageOps + +class EscposImage(object): + def __init__(self, img_source): + """ + Load in an image + + :param img_source: PIL.Image, or filename to load one from. + """ + if isinstance(img_source, Image.Image): + img_original = img_source + else: + img_original = Image.open(img_source) + + # Convert to white RGB background, paste over white background + # to strip alpha. + img_original = img_original.convert('RGBA') + im = Image.new("RGB", img_original.size, (255, 255, 255)) + im.paste(img_original, mask=img_original.split()[3]) + # Convert down to greyscale + im = im.convert("L") + # Invert: Only works on 'L' images + im = ImageOps.invert(im) + # Pure black and white + self._im = im.convert("1") + + @property + def width(self): + """ + Width of image in pixels + """ + width_pixels, _ = self._im.size + return width_pixels + + @property + def width_bytes(self): + """ + Width of image if you use 8 pixels per byte and 0-pad at the end. + """ + return (self.width + 7) >> 3 + + @property + def height(self): + """ + Height of image in pixels + """ + _, height_pixels = self._im.size + return height_pixels + + def to_column_format(self, high_density_vertical = True): + """ + Extract slices of an image as equal-sized blobs of column-format data. + + :param high_density_vertical: Printed line height in dots + """ + im = self._im.transpose(Image.ROTATE_270).transpose(Image.FLIP_LEFT_RIGHT) + line_height = 24 if high_density_vertical else 8 + width_pixels, height_pixels = im.size + top = 0 + left = 0 + while left < width_pixels: + box = (left, top, left + line_height, top + height_pixels) + im_slice = im.transform((line_height, height_pixels), Image.EXTENT, box) + im_bytes = im_slice.tobytes() + yield(im_bytes) + left += line_height + + def to_raster_format(self): + """ + Convert image to raster-format binary + """ + return self._im.tobytes() diff --git a/escpos/printer.py b/escpos/printer.py index ddd6ae5..086f8ff 100644 --- a/escpos/printer.py +++ b/escpos/printer.py @@ -20,7 +20,6 @@ import socket from .escpos import Escpos from .exceptions import * - class Usb(Escpos): """ USB printer @@ -260,3 +259,37 @@ class File(Escpos): """ Close system file """ self.device.flush() self.device.close() + +class Dummy(Escpos): + """ Dummy printer + + This class is used for saving commands to a variable, for use in situations where + there is no need to send commands to an actual printer. This includes + generating print jobs for later use, or testing output. + + inheritance: + + .. inheritance-diagram:: escpos.printer.Dummy + :parts: 1 + + """ + + def __init__(self, *args, **kwargs): + """ + + :param devfile : Device file under dev filesystem + """ + Escpos.__init__(self, *args, **kwargs) + self._output_list = [] + + def _raw(self, msg): + """ Print any command sent in raw format + + :param msg: arbitrary code to be printed + :type msg: bytes + """ + self._output_list.append(msg) + + @property + def output(self): + return b''.join(self._output_list) diff --git a/test/resources/black_transparent.gif b/test/resources/black_transparent.gif new file mode 100644 index 0000000..6c54bad Binary files /dev/null and b/test/resources/black_transparent.gif differ diff --git a/test/resources/black_transparent.png b/test/resources/black_transparent.png new file mode 100644 index 0000000..b43bbb8 Binary files /dev/null and b/test/resources/black_transparent.png differ diff --git a/test/resources/black_white.gif b/test/resources/black_white.gif new file mode 100644 index 0000000..0a044a6 Binary files /dev/null and b/test/resources/black_white.gif differ diff --git a/test/resources/black_white.jpg b/test/resources/black_white.jpg new file mode 100644 index 0000000..6539cec Binary files /dev/null and b/test/resources/black_white.jpg differ diff --git a/test/resources/black_white.png b/test/resources/black_white.png new file mode 100644 index 0000000..33ba331 Binary files /dev/null and b/test/resources/black_white.png differ diff --git a/test/resources/canvas_black.gif b/test/resources/canvas_black.gif new file mode 100644 index 0000000..49b19db Binary files /dev/null and b/test/resources/canvas_black.gif differ diff --git a/test/resources/canvas_black.jpg b/test/resources/canvas_black.jpg new file mode 100644 index 0000000..d059f43 Binary files /dev/null and b/test/resources/canvas_black.jpg differ diff --git a/test/resources/canvas_black.png b/test/resources/canvas_black.png new file mode 100644 index 0000000..52e6ba9 Binary files /dev/null and b/test/resources/canvas_black.png differ diff --git a/test/resources/canvas_white.gif b/test/resources/canvas_white.gif new file mode 100644 index 0000000..7881ce6 Binary files /dev/null and b/test/resources/canvas_white.gif differ diff --git a/test/resources/canvas_white.jpg b/test/resources/canvas_white.jpg new file mode 100644 index 0000000..516a965 Binary files /dev/null and b/test/resources/canvas_white.jpg differ diff --git a/test/resources/canvas_white.png b/test/resources/canvas_white.png new file mode 100644 index 0000000..4231a4b Binary files /dev/null and b/test/resources/canvas_white.png differ diff --git a/test/test_function_image.py b/test/test_function_image.py new file mode 100644 index 0000000..17844a0 --- /dev/null +++ b/test/test_function_image.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python +""" Image function tests- Check that image print commands are sent correctly. + +:author: `Michael Billington `_ +:organization: `python-escpos `_ +:copyright: Copyright (c) 2016 `Michael Billington `_ +:license: GNU GPL v3 +""" + +import escpos.printer as printer +from PIL import Image + +# Raster format print +def test_bit_image_black(): + """ + Test printing solid black bit image (raster) + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_black.png', impl="bitImageRaster") + assert(instance.output == b'\x1dv0\x00\x01\x00\x01\x00\x80') + # Same thing w/ object created on the fly, rather than a filename + instance = printer.Dummy() + im = Image.new("RGB", (1, 1), (0, 0, 0)) + instance.image(im, impl="bitImageRaster") + assert(instance.output == b'\x1dv0\x00\x01\x00\x01\x00\x80') + +def test_bit_image_white(): + """ + Test printing solid white bit image (raster) + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_white.png', impl="bitImageRaster") + assert(instance.output == b'\x1dv0\x00\x01\x00\x01\x00\x00') + +def test_bit_image_both(): + """ + Test printing black/white bit image (raster) + """ + instance = printer.Dummy() + instance.image('test/resources/black_white.png', impl="bitImageRaster") + assert(instance.output == b'\x1dv0\x00\x01\x00\x02\x00\xc0\x00') + +def test_bit_image_transparent(): + """ + Test printing black/transparent bit image (raster) + """ + instance = printer.Dummy() + instance.image('test/resources/black_transparent.png', impl="bitImageRaster") + assert(instance.output == b'\x1dv0\x00\x01\x00\x02\x00\xc0\x00') + +# Column format print +def test_bit_image_colfmt_black(): + """ + Test printing solid black bit image (column format) + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_black.png', impl="bitImageColumn") + assert(instance.output == b'\x1b3\x10\x1b*!\x01\x00\x80\x00\x00\x0a\x1b2') + +def test_bit_image_colfmt_white(): + """ + Test printing solid white bit image (column format) + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_white.png', impl="bitImageColumn") + assert(instance.output == b'\x1b3\x10\x1b*!\x01\x00\x00\x00\x00\x0a\x1b2') + +def test_bit_image_colfmt_both(): + """ + Test printing black/white bit image (column format) + """ + instance = printer.Dummy() + instance.image('test/resources/black_white.png', impl="bitImageColumn") + assert(instance.output == b'\x1b3\x10\x1b*!\x02\x00\x80\x00\x00\x80\x00\x00\x0a\x1b2') + +def test_bit_image_colfmt_transparent(): + """ + Test printing black/transparent bit image (column format) + """ + instance = printer.Dummy() + instance.image('test/resources/black_transparent.png', impl="bitImageColumn") + assert(instance.output == b'\x1b3\x10\x1b*!\x02\x00\x80\x00\x00\x80\x00\x00\x0a\x1b2') + +# Graphics print +def test_graphics_black(): + """ + Test printing solid black graphics + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_black.png', impl="graphics") + assert(instance.output == b'\x1d(L\x0b\x000p0\x01\x011\x01\x00\x01\x00\x80\x1d(L\x02\x0002') + +def test_graphics_white(): + """ + Test printing solid white graphics + """ + instance = printer.Dummy() + instance.image('test/resources/canvas_white.png', impl="graphics") + assert(instance.output == b'\x1d(L\x0b\x000p0\x01\x011\x01\x00\x01\x00\x00\x1d(L\x02\x0002') + +def test_graphics_both(): + """ + Test printing black/white graphics + """ + instance = printer.Dummy() + instance.image('test/resources/black_white.png', impl="graphics") + assert(instance.output == b'\x1d(L\x0c\x000p0\x01\x011\x02\x00\x02\x00\xc0\x00\x1d(L\x02\x0002') + +def test_graphics_transparent(): + """ + Test printing black/transparent graphics + """ + instance = printer.Dummy() + instance.image('test/resources/black_transparent.png', impl="graphics") + assert(instance.output == b'\x1d(L\x0c\x000p0\x01\x011\x02\x00\x02\x00\xc0\x00\x1d(L\x02\x0002') diff --git a/test/test_function_qr_native.py b/test/test_function_qr_native.py index 9a07dc5..8636e5e 100644 --- a/test/test_function_qr_native.py +++ b/test/test_function_qr_native.py @@ -12,70 +12,78 @@ from __future__ import division from __future__ import print_function from __future__ import unicode_literals -from nose.tools import with_setup - +from nose.tools import raises import escpos.printer as printer -import os from escpos.constants import QR_ECLEVEL_H, QR_MODEL_1 -devfile = 'testfile' - - -def setup_testfile(): - """create a testfile as devfile""" - fhandle = open(devfile, 'a') - try: - os.utime(devfile, None) - finally: - fhandle.close() - - -def teardown_testfile(): - """destroy testfile again""" - os.remove(devfile) - - -@with_setup(setup_testfile, teardown_testfile) -def test_function_qr_defaults(): - """test QR code with defaults""" - instance = printer.File(devfile=devfile) +def test_defaults(): + """Test QR code with defaults""" + instance = printer.Dummy() instance.qr("1234", native=True) - instance.flush() - with open(devfile, "rb") as f: - assert(f.read() == b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E0\x1d(k\x07\x001P01234\x1d(k\x03\x001Q0') + expected = b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E0\x1d' \ + b'(k\x07\x001P01234\x1d(k\x03\x001Q0' + assert(instance.output == expected) -@with_setup(setup_testfile, teardown_testfile) -def test_function_qr_empty(): - """test QR printing blank code""" - instance = printer.File(devfile=devfile) +def test_empty(): + """Test QR printing blank code""" + instance = printer.Dummy() instance.qr("", native=True) - instance.flush() - with open(devfile, "rb") as f: - assert(f.read() == b'') + assert(instance.output == b'') -@with_setup(setup_testfile, teardown_testfile) -def test_function_qr_ec(): - """test QR error correction setting""" - instance = printer.File(devfile=devfile) +def test_ec(): + """Test QR error correction setting""" + instance = printer.Dummy() instance.qr("1234", native=True, ec=QR_ECLEVEL_H) - instance.flush() - with open(devfile, "rb") as f: - assert(f.read() == b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E3\x1d(k\x07\x001P01234\x1d(k\x03\x001Q0') + expected = b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E3\x1d' \ + b'(k\x07\x001P01234\x1d(k\x03\x001Q0' + assert(instance.output == expected) -@with_setup(setup_testfile, teardown_testfile) -def test_function_qr_size(): - """test QR box size""" - instance = printer.File(devfile=devfile) +def test_size(): + """Test QR box size""" + instance = printer.Dummy() instance.qr("1234", native=True, size=7) - instance.flush() - with open(devfile, "rb") as f: - assert(f.read() == b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x07\x1d(k\x03\x001E0\x1d(k\x07\x001P01234\x1d(k\x03\x001Q0') + expected = b'\x1d(k\x04\x001A2\x00\x1d(k\x03\x001C\x07\x1d(k\x03\x001E0\x1d' \ + b'(k\x07\x001P01234\x1d(k\x03\x001Q0' + assert(instance.output == expected) -@with_setup(setup_testfile, teardown_testfile) -def test_function_qr_model(): - """test QR model""" - instance = printer.File(devfile=devfile) +def test_model(): + """Test QR model""" + instance = printer.Dummy() instance.qr("1234", native=True, model=QR_MODEL_1) - instance.flush() - with open(devfile, "rb") as f: - assert(f.read() == b'\x1d(k\x04\x001A1\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E0\x1d(k\x07\x001P01234\x1d(k\x03\x001Q0') + expected = b'\x1d(k\x04\x001A1\x00\x1d(k\x03\x001C\x03\x1d(k\x03\x001E0\x1d' \ + b'(k\x07\x001P01234\x1d(k\x03\x001Q0' + assert(instance.output == expected) + +@raises(ValueError) +def test_invalid_ec(): + """Test invalid QR error correction""" + instance = printer.Dummy() + instance.qr("1234", native=True, ec=-1) + +@raises(ValueError) +def test_invalid_size(): + """Test invalid QR size""" + instance = printer.Dummy() + instance.qr("1234", native=True, size=0) + +@raises(ValueError) +def test_invalid_model(): + """Test invalid QR model""" + instance = printer.Dummy() + instance.qr("1234", native=True, model="Hello") + +def test_image(): + """Test QR as image""" + instance = printer.Dummy() + instance.qr("1", native=False, size=1) + expected = b'\x1d(LO\x000p0\x01\x011\x17\x00\x17\x00\x00\x00\x00\x7f]\xfcA' \ + b'\x19\x04]it]et]ItA=\x04\x7fU\xfc\x00\x0c\x00y~t4\x7f =\xa84j\xd9\xf0' \ + b'\x05\xd4\x90\x00i(\x7f<\xa8A \xd8]\'\xc4]y\xf8]E\x80Ar\x94\x7fR@\x00\x00' \ + b'\x00\x1d(L\x02\x0002' + assert(instance.output == expected) + +@raises(ValueError) +def test_image_invalid_model(): + """Test unsupported QR model as image""" + instance = printer.Dummy() + instance.qr("1234", native=False, model=QR_MODEL_1) diff --git a/test/test_image.py b/test/test_image.py new file mode 100644 index 0000000..6612703 --- /dev/null +++ b/test/test_image.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python +""" Image tests- Check that images from different source formats are correctly +converted to ESC/POS column & raster formats. + +:author: `Michael Billington `_ +:organization: `python-escpos `_ +:copyright: Copyright (c) 2016 `Michael Billington `_ +:license: GNU GPL v3 +""" + +from escpos.image import EscposImage + +def test_image_black(): + """ + Test rendering solid black image + """ + for img_format in ['png', 'jpg', 'gif']: + _load_and_check_img('canvas_black.' + img_format, 1, 1, b'\x80', [b'\x80']) + +def test_image_black_transparent(): + """ + Test rendering black/transparent image + """ + for img_format in ['png', 'gif']: + _load_and_check_img('black_transparent.' + img_format, 2, 2, b'\xc0\x00', [b'\x80\x80']) + +def test_image_black_white(): + """ + Test rendering black/white image + """ + for img_format in ['png', 'jpg', 'gif']: + _load_and_check_img('black_white.' + img_format, 2, 2, b'\xc0\x00', [b'\x80\x80']) + +def test_image_white(): + """ + Test rendering solid white image + """ + for img_format in ['png', 'jpg', 'gif']: + _load_and_check_img('canvas_white.' + img_format, 1, 1, b'\x00', [b'\x00']) + +def _load_and_check_img(filename, width_expected, height_expected, raster_format_expected, column_format_expected): + """ + Load an image, and test whether raster & column formatted output, sizes, etc match expectations. + """ + im = EscposImage('test/resources/' + filename) + assert(im.width == width_expected) + assert(im.height == height_expected) + assert(im.to_raster_format() == raster_format_expected) + i = 0 + for row in im.to_column_format(False): + assert(row == column_format_expected[i]) + i = i + 1