Printing on paper with Python 🖨️

pa4kev

Kevin van der Vleuten

Posted on May 11, 2021

Printing on paper with Python 🖨️

The original print tool

In 2019, I was requested to create a solution for printing placards on paper. These placards contained serial numbers, an order number and other data related to racks with computer equipment. This data had to be represented with QR codes and bar codes so that the warehouse could scan them.

I was given discarded Lenovo T470s laptops that were put in the assembly department so the personnel could use the application. Because I was tied to Windows and had to create a GUI for easy usage, I decided to create a C# program in Microsoft Visual Studio because creating a GUI for Windows was very easy that way. It would create local HTML files using the Barcode Rendering Framework NuGet package for creating QR- and bar codes and send it to the printer set as default in Windows. It even had some logging using NLog. I just named this application "print tool".

Original placard
The placard rendered by the C# application
Alt Text


The re-implemented print tool

In 2021, I decided to integrate the print tool into another program written in Python using the Qt GUI toolkit. This program was a Python re-write of another C# project of mine. And the PyQt binding really allows for a nice GUI creation with the Qt Designer.

For the QR codes, I used the qrcode python module. It easily generates PNG files.

It is as simple as:



import qrcode
qrcode.make('1234')


Enter fullscreen mode Exit fullscreen mode

For bar codes, I used the python-barcode module.

In the original print tool, I used HTML files and printed them. The WebBrowser class handled printing of documents, which was convenient.



private void PrintDocument(object sender, WebBrowserDocumentCompletedEventArgs e) {
    // Print the document now that it is fully loaded.
    ((WebBrowser)sender).Print();

    // Dispose the WebBrowser now that the task is complete. 
    ((WebBrowser)sender).Dispose();
}

private void PrintHelpPage(Uri uri) {
    logger.Info(String.Format("Print: {0}", uri));
    // Create a WebBrowser instance. 
    WebBrowser webBrowserForPrinting = new WebBrowser();

    // Add an event handler that prints the document after it loads.
    webBrowserForPrinting.DocumentCompleted +=
    new WebBrowserDocumentCompletedEventHandler(PrintDocument);

    // Set the Url property to load the document.
    webBrowserForPrinting.Url = uri;
}


Enter fullscreen mode Exit fullscreen mode

But with Python, this proved a little difficult. I used the to win32api module for printing. Which calls an application set in Windows registry to use an print the file.



win32api.ShellExecute(0, 'print', filename, f'/d:"{win32print.GetDefaultPrinter()}"', '.', 0)


Enter fullscreen mode Exit fullscreen mode

Python uses this "print" verb, to let Windows direct a file to a printer. This is called a File Association. These file associations can be found in Windows Registry.

Here are some examples:



""C:\Program Files (x86)\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"" /p /h "%1" 
%SystemRoot%\system32\NOTEPAD.EXE /p %1
"%ProgramFiles%\Windows NT\Accessories\WORDPAD.EXE" /p "%1"
"%systemroot%\system32\mspaint.exe" /p "%1"


Enter fullscreen mode Exit fullscreen mode

Alt Text

Printing the HTML files proved to be difficult. Firefox used to have a command line parameter to do this, which is still found in Windows Registry, but this did not work. Other programs simply printed out the HTML source.

Alt Text

I tried editing the print verb for HTML files, but no luck.
Alt Text

Alt Text

Printing HTML
Printing an HTML file with notepad or WordPad also did not work.
Alt Text


Moving away from HTML, I used the xlsxwriter module. I had experience with this module, so it seemed a good choice. I could generate an Excel worksheet tempfile and put a PNG image in there.
On my development laptop, which had Excel installed, the "print" verb for xlsx files was connected to the command:



C:\Program Files (x86)\Microsoft Office\Root\Office16\EXCEL.EXE /q "%1"


Enter fullscreen mode Exit fullscreen mode

While this did print on my development laptop, the IT department did not want Excel installed on the Microsoft tablets. Their reasoning was;

  • It required licenses
  • It costs valuable storage space
  • It impacts performance

And apparently there was an agreement somehow in the company that prohibited the installment of MS Office on these tablets...

This also ruled out generation of PDF files with PyPDF, because I could not install Adobe Acrobat on these tablets either. Using deprecated, old software such as Excel Viewer and Excel Mobile would not be a good long-term option either.

I then thought the WordPad application, which is native to Windows, would be a better choice. It uses this command to print:



"%ProgramFiles%\Windows NT\Accessories\WORDPAD.EXE" /p "%1"


Enter fullscreen mode Exit fullscreen mode

I then tried to generate RTF files with Python and include images in them. The PyRTF3 module did allow me to generate RTF files with text and these properly printed as well. But its image processing always rejected PNG and JPG images because it could not properly read and parse their binary headers.

Running out of options and ideas, I decided to take a look at other file formats that WordPad could handle. There I saw the .odt extension for OpenDocument files. These files have proven to be the perfect outcome. I could choose between the odfpy module and the relatorio module. With relatorio, I could simply create a template file with OpenOffice Writer. Load the template in Python, fill in the data and images, finally print it.

Here is a stand-alone script I created during testing.



import qrcode
import relatorio
from relatorio.templates.opendocument import Template
import tempfile
import time
import win32api
import win32print


def generate_qr(qr_input, data_holder):
    with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmpfile:   
        qr = qrcode.make(qr_input)  
        qr.save(tmpfile, qr.format, quality=100)
        print(tmpfile.name)
        return (open(tmpfile.name, 'rb'), 'image/png')

def main():
    inv = {}
    inv['shipmentnumber'] = '6'
    inv['units'] = '3'
    inv['model'] = 'MyModel'
    inv['systemnumber'] = '4'
    inv['productline'] = 'TestLine'
    inv['page'] = '1'
    inv['pagetotal'] = '99'
    inv['ordernumber'] = '9341'
    inv['ordernumberqr'] = generate_qr(inv['ordernumber'], inv)

    basic = Template(source='', filepath='basic.odt')

    with open('placard.odt', 'wb') as f:
        f.write(basic.generate(o=inv).render().getvalue())

    time.sleep(2)
    filename = 'placard.odt'
    print(f'Printing: {filename}')
    win32api.ShellExecute(0, 'print', filename, f'/d:"{win32print.GetDefaultPrinter()}"', '.', 0)

if __name__ == '__main__':
    main()


Enter fullscreen mode Exit fullscreen mode

The above code works fine, however...

When I tested my application on the tablet, it printed black boxes instead of images. It turns out that images that were embedded using placeholders in a template created in Word or OpenOffice Writer, become like that when printed by WordPad. While printing on my development machine, I must have still used Word to have printed these documents.

Black boxes
Just when you think you were finished...
Alt Text


Creating the ODF file

Instead of filling in a template file with placeholders and embed images, I thought... maybe I need to create the ODF file from scratch and insert the images more directly rather than use placeholders. I turned to odfpy.

I created the document and viewed it in WordPad, all looked fine inside the program. But when printing, black boxes came out yet again.

Here is the code used with odfpy:



from barcode.codex import Code128
from barcode.writer import ImageWriter
import qrcode

import PIL.Image
from PIL.PngImagePlugin import PngImageFile, PngInfo

from odf.opendocument import OpenDocumentText
from odf import style, text
from odf.text import P
from odf.draw import Frame, Image
from odf.style import Style, GraphicProperties, TableColumnProperties, ParagraphProperties, TableCellProperties
from odf.table import Table, TableColumn, TableRow, TableCell

import io
from os.path import join, dirname
import tempfile
import time
import win32api
import win32print

def generate_qr(qr_input: str):
    qr_name = '' 
    with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmpfile:
        qr = qrcode.QRCode(
            version=1,
            error_correction=qrcode.constants.ERROR_CORRECT_L,
            box_size=10,
            border=4,
        )
        qr.add_data(qr_input)
        qr.make(fit=True)
        img = qr.make_image(fill_color="black", back_color="white")        
        img = img.convert('RGB')

        pixels = img.load()
        pixels[0, 0] = (255, 0, 0)

        img.save(tmpfile.name, dpi=(96, 96))

        qr_name = tmpfile.name
    return qr_name

def generate_bar(bar_input: str):
    bar_name = ''
    with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmpfile:
        Code128(bar_input, writer=ImageWriter(),).write(tmpfile, options={"write_text": False})

        img = PIL.Image.open(tmpfile)
        img = img.convert('RGB')

        pixels = img.load()
        pixels[0, 0] = (255, 0, 0)

        img.save(tmpfile.name, dpi=(96, 96)) 

        bar_name = tmpfile.name
    return bar_name


def main():
    outfp = io.BytesIO()

    textdoc = OpenDocumentText()

    p = P(text="QR code placard")
    textdoc.text.addElement(p)

    # Main table
    tablecontents = Style(name="Table Contents")
    tablecontents.addElement(ParagraphProperties(numberlines="true", linenumber="0"))
    textdoc.styles.addElement(tablecontents)

    widthshort = Style(name="Wshort", family="table-column")
    widthshort.addElement(TableColumnProperties(columnwidth="25%"))
    textdoc.automaticstyles.addElement(widthshort)

    widthwide = Style(name="Wwide", family="table-column")
    widthwide.addElement(TableColumnProperties(columnwidth="25%"))
    textdoc.automaticstyles.addElement(widthwide)

    table = Table()
    table.addElement(TableColumn(numbercolumnsrepeated=4,stylename=widthshort))
    table.addElement(TableColumn(numbercolumnsrepeated=3,stylename=widthwide))

    tr = TableRow()
    table.addElement(tr)
    tc = TableCell()
    tr.addElement(tc)
    tc.addElement(P(stylename=tablecontents,text='Shipment:'))
    tc = TableCell()
    tr.addElement(tc)
    tc.addElement(P(stylename=tablecontents,text=' 2'))
    tc = TableCell()
    tr.addElement(tc)
    tc.addElement(P(stylename=tablecontents,text='System: '))
    tc = TableCell()
    tr.addElement(tc)
    tc.addElement(P(stylename=tablecontents,text=' 3'))

    textdoc.text.addElement(table)

    # separate Images    
    order_number = '12345678'
    qr = generate_qr(order_number)
    p = P()
    textdoc.text.addElement(p)
    photoframe = Frame(width="310px", height="310px")
    print(f'QR: {qr}')
    href = textdoc.addPicture(qr)
    photoframe.addElement(Image(href=href))
    p.addElement(photoframe)

    bar = generate_bar(order_number)
    p = P()
    textdoc.text.addElement(p)
    photoframe = Frame(width="340px", height="120px")
    href = textdoc.addPicture(bar)
    photoframe.addElement(Image(href=href))
    p.addElement(photoframe)

    p = P(text='End of placard')
    textdoc.text.addElement(p)

    time.sleep(1)

    # save tempfile
    with tempfile.NamedTemporaryFile(delete=True) as tmpfile:
        textdoc.save(tmpfile.name, True) 
        print(f'Printing: {tmpfile.name}')    

if __name__ == '__main__':
    main()


Enter fullscreen mode Exit fullscreen mode

PNG file contents

I then tried to open the ODF file in Word, then right-click on the image of the QR code and use "save as picture" and then re-add this new image back into WordPad. When printing with the re-added image, the QR code was properly printed. The image was slightly less quality, and thus a little blurry pixelated due to compression artefacts. This later was a clue to actually solve the problem.

I was able to also add other PNG files, which also printed just fine. I concluded, the PNG image itself must be the problem, not the way the ODF document is built. Eventually I found a handy program: TweakPNG, specifically to inspect PNG files. I learnt that PNG files are made up of chunks.

http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html

I used the Python Image Library / Pillow module to convert the generated image into RGB. This changed its header:

  • from: 1 bit/sample grayscale
  • into: 8 bits/sample truecolor


img = PIL.Image.open(qr_name)
img = img.convert('RGB')


Enter fullscreen mode Exit fullscreen mode

I also tried a palettised conversion mode.



img = img.convert('P')


Enter fullscreen mode Exit fullscreen mode

Which added a palette, but this still printed out a black box.

I tried to copy the chunks of the PNG that did print, into the QR- and Bar code PNG images that Python generated. The TweakPNG tool and the PIL module both allowed to easily change these chunks.



metadata = PngInfo()
pnginfo.add(b'grAb', struct.pack('>II', 10, 49))


Enter fullscreen mode Exit fullscreen mode

PIL docs on this: https://pillow.readthedocs.io/en/3.1.x/PIL.html?highlight=PngInfo#PIL.PngImagePlugin.PngInfo

But... still no good. Trying to copy the chunks as closely as possible, WordPad still printed solid black boxes.


How 1 coloured pixel solved the problem

I mentioned how I was able to properly print the QR code I first opened in Word, then use "Save as picture" to create a new PNG image of it, which was slighly blurry because of compression artefacts. I noticed that the artefacts, were slightly grey.

I then tried an experiment. With MS Paint, I created a solid white image. On that image I drew several black (non-aliased) circles. I drew another image, also solid white background, but with coloured circles. I then added both these images manually to the ODF document and print it. The image with the black circles got printed as a solid black box, while the coloured circles got printed properly. The chunk contents did not matter at all, only the use of colour mattered.

Colour circle test
Testing to see how WordPad printing responds
Alt Text

WordPad (at least, the version I was working with in 2021) apparently cannot handle images that are consistent of only black and white pixels. If I added a single coloured pixel anywhere on the PNG, WordPad suddenly is able to process the image properly for printing.

WordPad version
About WordPad
Alt Text

Using the PIL module to modify the QR- and Bar code PNG images by adding 1 red pixel and then add the image to the ODT document, finally allowed proper printing.

I reverted back to the relatorio code and here is the final script:



import qrcode
import relatorio
from relatorio.templates.opendocument import Template
from os.path import join, dirname
import tempfile
import time
import win32api
import win32print

def generate_qr(qr_input: str):
    qr_name = '' 
    with tempfile.NamedTemporaryFile(delete=False, suffix='.png') as tmpfile:
        qr = qrcode.QRCode(
            version=1,
            error_correction=qrcode.constants.ERROR_CORRECT_L,
            box_size=10,
            border=4,
        )
        qr.add_data(qr_input)
        qr.make(fit=True)
        img = qr.make_image(fill_color="black", back_color="white")        
        img = img.convert('RGB')

        pixels = img.load()
        pixels[0, 0] = (255, 0, 0)

        img.save(tmpfile.name, dpi=(96, 96))

        qr_name = tmpfile.name
    return qr_name


def main():
    inv = {}
    inv['shipmentnumber'] = '6'
    inv['units'] = '3'
    inv['model'] = 'MyModel'
    inv['systemnumber'] = '4'
    inv['productline'] = 'TestLine'
    inv['page'] = '1'
    inv['pagetotal'] = '99'
    inv['ordernumber'] = '9341'
    inv['ordernumberqr'] = (open(generate_qr(inv['ordernumber']), 'rb'), 'image/png')

    basic = Template(source='', filepath='basic.odt')

    with open('placard.odt', 'wb') as f:
        f.write(basic.generate(o=inv).render().getvalue())

    time.sleep(2)
    filename = 'placard.odt'
    print(f'Printing: {filename}')
    # win32api.ShellExecute(0, 'print', filename, f'/d:"{win32print.GetDefaultPrinter()}"', '.', 0)

if __name__ == '__main__':
    main()


Enter fullscreen mode Exit fullscreen mode

Yes, I could colour the QR codes itself, but adding a red pixel to resolve this after several days and much wasted paper just felt very gratifying to me. Thank you for reading!

Final placard
Alt Text


"The enemy of art is the absense of limitations" - Orson Welles

đź’– đź’Ş đź™… đźš©
pa4kev
Kevin van der Vleuten

Posted on May 11, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related