JPG2PRN - Formatting JPGs for ESC/P Printers

Years ago I was testing an old IBM PC Compact Printer and I wanted to see if I could write a program to print old Compuserve RLE graphics on it. I already had a BASIC program that could render them on the screen, so adapting that code for a printer would not be difficult. With the printer specifications and some trial and error I was able to accomplish the task.

Fast forward nearly 10 years ... now I had this great idea to create an "8 bit photo booth" for a vintage computing event. I already have the perfect printer; I would just need a webcam, some code to format images for the printer, and something like a Raspberry Pi which would have enough power and software support to handle JPG images. This was also a great starter project for learning Go. Time flies when you are having fun. I finally have something I can present - a Go program that takes a JPG file and

I'm sharing the code here to spread the joy.

Print samples

Below are a few "before and after" samples that shows what the code can do. The scanned prints look a little faded because the thermal printer paper that I'm using does not have a high contrast ratio; your results with a fresh ink ribbon, an inkjet, or a laser printer will have much higher contrast.

Earlier versions of the code only did the grayscale conversion and the rendering for the printer, so you may notice some cropping differences between the original picture and the final output. All of the printed samples were printed either on an IBM 5181 PC Compact Printer or an IBM PC Convertible Printer, both of which are thermal papers using rolls of fax paper. Other printers are supported too, and the output will even print on my Brother laser printer if I send the PRN file to port 9100 on the printer.

(Some of the printed images won't look as good because of the resizing needed for the browser. Right click on them to see a larger image with less scaling artifacts.)



The code

Source code in Go: jpg2prn.go
Compiled executable and source code for Windows 10: jpg2prn.2021-07-28.zip


package main

/*

jpg2prn.go
Home page: www.brutman.com/jpg2go
Copyright 2019-2021 Michael B. Brutman (mbbrutman@gmail.com)


Instructions

Given an input JPG this program will do the following:

 - Rotate the image to maximize the print area (optional)
 - Scale the vertical dimension to account for non 1:1 aspect ratios
 - Grayscale, and then dither the image.  (Debug images are written to disk.)
 - Create a binary file with printer codes that can be dumped
   to the printer to produce the image.

Two printers are supported by name, and other printers are
supported using a "generic" setting.


Printer specifications:

IBM 5181:
 - 72 DPI horizontal, 560 dots max horizontal. (80 char cells, 7 dots per cell)
 - 72 DPI vertical, measured.
 - vertical scaling factor is 1.0 and can not be adjusted.

 The IBM 5181 Compact Printer is fairly limited and you can only send 480
 bytes of data at a time even though the printer can lay down 560 dots.


IBM Convertible Printer

 - 60 DPI horizontal in single density mode (480 dots horizontal)
 - 60 DPI vertical, measured.
 - vertical scaling factor is 1.0 and can not be adjusted.


Generic
 - 60 DPI horizontal in single density mode
 - 72 DPI vertical?
 - vertical scaling factor should be 1.2 except for the oddball printers
   like the IBM 5181 and IBM Convertible, which have 1:1 aspect ratios.
*/

import (
        "flag"
        "fmt"
        "image"
        "image/color"
        "image/jpeg"
        "os"
        "path/filepath"

        "github.com/disintegration/imaging"
)

func makeGrayscale(i image.Image) image.Image {

        b := i.Bounds()
        grayscale := image.NewGray16(i.Bounds())

        // Copying this image one pixel at a time seems slow, but the image/draw
        // library isn't much faster and we can't do our own easy deep copy
        // because the incoming image is NRGBA and the color conversion needs
        // to happen.  And a parallel version is overkill for the few milliseconds
        // this requires.

        for y := b.Min.Y; y < b.Max.Y; y++ {
                for x := b.Min.X; x < b.Max.X; x++ {
                        r := color.Gray16Model.Convert(i.At(x, y)).(color.Gray16)
                        grayscale.Set(x, y, r)
                }
        }

        return grayscale
}

// Part of Floyd Steinberg ...  given a map of weights for surrounding
// pixes this will return a function that sets a pixel and then diffuses
// the error to the neighboring pixels according to this map:
//
//   ....  ....   ....   .... ....
//   ....  ....    *     7/16 ....
//   ....  3/16   5/16   1/16 ....
//   ....  ....   ....   .... ....
//
// (The fraction is the amount of the error and where it is placed relative
// to the current pixel, represented by the '*'.  When scanning right to left
// adjust accordingly.
//
// See https://en.wikipedia.org/wiki/Floyd%E2%80%93Steinberg_dithering
// for details.

type Offset struct {
        x int
        y int
}

func setPixels(offsets [4]Offset) func(*image.Gray16, int, int) {
        inner := func(i *image.Gray16, x, y int) {
                o := i.At(x, y).(color.Gray16)
                var n color.Gray16
                if o.Y < 32*1024 {
                        n = color.Black
                } else {
                        n = color.White
                }
                i.Set(x, y, n)

                e := int32(o.Y) - int32(n.Y)

                w := [4]int32{7, 3, 5, 1}

                b := i.Bounds()

                for j := 0; j < 4; j++ {
                        var n1, n2 color.Gray16

                        if (x+offsets[j].x >= 0) && (x+offsets[j].x < b.Max.X) && (y+offsets[j].y < b.Max.Y) {
                                n1 = i.At(x+offsets[j].x, y+offsets[j].y).(color.Gray16)
                                t := (e*w[j])/16 + int32(n1.Y)
                                if t > 65535 {
                                        t = 65535
                                } else if t < 0 {
                                        t = 0
                                }
                                n2.Y = uint16(t)
                                i.Set(x+offsets[j].x, y+offsets[j].y, n2)
                        }
                }

        }
        return inner
}

// Floyd Steinberg.  This implementation does serpentine scanning to spread
// the diffusion error a little more nicely.

func floydSteinberg(i image.Image) image.Image {

        gi := image.NewGray16(i.Bounds())
        b := gi.Bounds()

        // First pass - copy the image
        for y := b.Min.Y; y < b.Max.Y; y++ {
                for x := b.Min.X; x < b.Max.X; x++ {
                        gi.Set(x, y, i.At(x, y))
                }
        }

        leftToRight := setPixels([4]Offset{{1, 0}, {-1, 1}, {0, 1}, {1, 1}})
        rightToLeft := setPixels([4]Offset{{-1, 0}, {1, 1}, {0, 1}, {-1, 1}})

        // Second pass - dither.
        for y := b.Min.Y; y < b.Max.Y; y++ {
                if y&1 == 0 {
                        for x := b.Min.X; x < b.Max.X; x++ {
                                leftToRight(gi, x, y)
                        }
                } else {
                        for x := b.Max.X - 1; x >= b.Min.X; x-- {
                                rightToLeft(gi, x, y)
                        }
                }
        }

        return gi

}

func toPrint(i image.Image, outputFile string, printerModel string, printerDotsPerLine int) {

        f, err := os.Create(outputFile)
        if err != nil {
                fmt.Println("Error opening"+outputFile+":", err)
                os.Exit(1)
        }

        ob := make([]byte, 0, 8192)

        // Set graphics command byte and line spacing.

        var graphicsCommand byte = 'K' // Single density (480 or 560 for the 5181 printer)
        if printerDotsPerLine == 960 {
                graphicsCommand = 'L' // Double density
        }

        switch printerModel {
        case "5181":
                ob = append(ob, 27, '0') // Escape Zero: 2.82mm line feed
        default:
                ob = append(ob, 27, '3', 24) // Epson FX-850 and others
        }

        b := i.Bounds()

        for y := b.Min.Y; y < b.Max.Y; y = y + 8 {
                for x := b.Min.X; x < b.Max.X; x++ {

                        // Send at most 128 bytes of bitmap data at a time.  It depends on
                        // the printer but some printers like the IBM 5181 are limited and
                        // can't receive a full line of bitmap data at a time.  Sending
                        // 128 bytes of bitmap data at a time is always safe.
                        //
                        // This is also a good time to see if we should flush the buffer.

                        if x%128 == 0 {

                                bitmapBytes := b.Max.X - x
                                if bitmapBytes > 128 {
                                        bitmapBytes = 128
                                }
                                ob = append(ob, 27, graphicsCommand, uint8(bitmapBytes), 0)

                                if len(ob) > 7*1024 {
                                        f.Write(ob)
                                        ob = make([]byte, 0, 8192)
                                }
                        }

                        var c byte = 0

                        // The printer takes 8 rows of pixels at a time, where each byte represents a column
                        // of 8 rows of pixels.  The image might not be a multiple of 8 rows so do the check
                        // to avoid array boundary errors.
                        //
                        // The top row of the byte is the most significant row, so turn it on by adding 128.
                        // the bottom row of the byte is the least significant row, so turn it on by adding 1.

                        limit := b.Max.Y - y
                        if limit > 8 {
                                limit = 8
                        }
                        for z, t := 0, 128; z < limit; z, t = z+1, t/2 {
                                if i.At(x, y+z) == color.Black {
                                        c = c + byte(t)
                                }
                        }

                        ob = append(ob, c)
                }

                // At the end of each line send a carriage return and a line feed.
                ob = append(ob, 13, 10)
        }

        f.Write(ob)
        f.Close()
}

func writeJpg(i image.Image, outputName string) {
        file, err := os.Create(outputName)
        if err != nil {
                fmt.Println("Error opening debug output file:", err)
                os.Exit(1)
        }

        myOpts := jpeg.Options{100}
        jpeg.Encode(file, i, &myOpts)
        file.Close()
}

func myUsage() {
        fmt.Printf("\nUsage: %s -infile <file.jpg> -outfile <newfile_base> -model <5140|5181|generic> [other options]\n\n",
                filepath.Base(os.Args[0]))
        fmt.Println("Convert a JPG to older Epson ESC printer codes.  The JPG will be scaled,")
        fmt.Println("rotated and dithered as needed.  A grayscale JPG and a dithered JPG are")
        fmt.Println("written and be used as a simple preview for the final output.\n")
        flag.PrintDefaults()
        os.Exit(1)
}

func main() {

        inputfile := flag.String("infile", "", "Input JPG filename")
        outputFile := flag.String("outfile_base", "", "Output filename base (do not specify an extension)")
        brightAdj := flag.Float64("brightness", 0, "Brightness adjust percentage: (-100 to 100)")
        contrastAdj := flag.Float64("contrast", 0, "Contrast adjust percentage: (-100 to 100)")
        landscape := flag.String("landscape", "", "Rotate printer output: yes, no, auto")
        printerModel := flag.String("model", "", "Printer model: 5181, 5140, or generic")
        horizontalDensity := flag.String("h_density", "single", "Horizontal density: single (480 dots) or double (960 dots)")
        verticalScale := flag.Float64("v_scale", 0, "Vertical scale: default is 1.2, only valid for generic printers")

        flag.Usage = myUsage
        flag.Parse()

        switch *printerModel {
        case "5181":
                if *horizontalDensity == "" {
                        *horizontalDensity = "single"
                }
                if *horizontalDensity != "single" {
                        fmt.Println("Error: The 5181 printer only supports single density.")
                        flag.Usage()
                }
                if *verticalScale != 0.0 {
                        fmt.Println("Error: Do not set v_scale for this printer.")
                        flag.Usage()
                }
                *verticalScale = 1.0
        case "5140":
                if *horizontalDensity == "" {
                        *horizontalDensity = "single"
                }
                if (*horizontalDensity != "single") && (*horizontalDensity != "double") {
                        fmt.Println("Error: Specify single or double density.")
                        flag.Usage()
                }
                if *verticalScale != 0.0 {
                        fmt.Println("Error: Do not set v_scale for this printer.")
                        flag.Usage()
                }
                *verticalScale = 1.0
        case "generic":
                if *horizontalDensity == "" {
                        *horizontalDensity = "single"
                }
                if (*horizontalDensity != "single") && (*horizontalDensity != "double") {
                        fmt.Println("Error: Specify single or double density.")
                        flag.Usage()
                }
                if *verticalScale == 0.0 {
                        *verticalScale = 1.2
                } else {
                        fmt.Println("Warning: you probably want v_scale=1.2 but we can try this anyway.")
                }
        default:
                fmt.Println("Error: model must be 5181, 5140 or generic.")
                flag.Usage()
        }

        if *landscape != "" && *landscape != "auto" && *landscape != "yes" && *landscape != "no" {
                fmt.Println("Error: Landscape (if provided) must be auto, yes or no.")
                flag.Usage()
        }

        var printerDotsPerLine int

        if *printerModel == "5181" {
                printerDotsPerLine = 560
        } else {
                if *horizontalDensity == "single" {
                        printerDotsPerLine = 480
                } else {
                        printerDotsPerLine = 960
                }
        }

        file, err := os.Open(*inputfile)
        if err != nil {
                fmt.Println("Error opening input file:", err)
                os.Exit(1)
        }

        i, err := jpeg.Decode(file)
        if err != nil {
                fmt.Println("Error decoding jpg:", err)
                os.Exit(1)
        }

        inputX, inputY := i.Bounds().Max.X, i.Bounds().Max.Y

        fmt.Println("Input image resolution:", inputX, inputY)

        // Suggest landscape if it will result in more printed area for the image.
        // This is great for printers using fanfold or roll paper, but won't work
        // as well for laser printers.
        //
        // Also allow for landscape to be forced on any image, even if it won't benefit.

        if inputX > inputY {
                if *landscape == "" {
                        fmt.Println("\nWarning: This picture is wider than it is tall.")
                        fmt.Println("You might want to print in landscape mode.  Use")
                        fmt.Println("the -landscape option to specify what you want to do.")
                        os.Exit(1)
                }
        }

        if (*landscape == "yes") || (*landscape == "auto" && (inputX > inputY)) {
                i = imaging.Rotate90(i)
                fmt.Println("Image rotated to landscape.")
        }

        // Scale the image to fit the printer.  Then scale the vertical axis to account
        // for non-square aspect ratios.  Lastly, if printing in horizontal double density
        // mode reduce the vertical axis by two to get the aspect ratio correct again.

        if i.Bounds().Max.Y > printerDotsPerLine {

                shrinkFactor := float64(printerDotsPerLine) / float64(i.Bounds().Max.X)

                newX, newY := printerDotsPerLine, int(float64(i.Bounds().Max.Y)*shrinkFactor)

                // The actual adjustment should be 1.2 (72 dpi vs 60 dpi) but I want to ensure
                // the image fits on one page so cheat a little bit.

                if *verticalScale != 1.0 {
                        fmt.Printf("Applying vertical scaling for verticalscale=%v.\n", *verticalScale)
                        newY = int(float64(newY) * *verticalScale)
                }

                if printerDotsPerLine == 960 {
                        fmt.Println("Adjusting for double density horizontal mode.")
                        newY = newY / 2
                }

                fmt.Printf("All adjustments made: Resizing to %v, %v\n", newX, newY)
                i = imaging.Resize(i, newX, newY, imaging.Lanczos)

                if (newY / 72) > 10 {
                        fmt.Printf("\nWarning: vertical height of %v might exceed the page boundary\n", newY)
                        fmt.Println("of a laser printer.  (Figure just over 10 inches at 72dpi.")
                }
        }

        // Output stage!

        fmt.Println()

        grayscale := makeGrayscale(i)
        grayscaleName := *outputFile + "_grayscale.jpg"
        writeJpg(grayscale, grayscaleName)
        fmt.Println(grayscaleName, "contains a grayscale debug image.")

        brightnessAdjusted := imaging.AdjustBrightness(grayscale, *brightAdj)
        contrastAdjusted := imaging.AdjustContrast(brightnessAdjusted, *contrastAdj)
        contrastAdjustedName := *outputFile + "_adjusted.jpg"
        writeJpg(contrastAdjusted, contrastAdjustedName)

        dithered := floydSteinberg(contrastAdjusted)
        ditheredName := *outputFile + "_dithered.jpg"
        writeJpg(dithered, ditheredName)
        fmt.Println(ditheredName, "contains a dithered debug image.")

        printerOutputName := *outputFile + ".prn"
        toPrint(dithered, printerOutputName, *printerModel, printerDotsPerLine)
        fmt.Println(printerOutputName, "contains the printer codes.  Send it to the printer as a binary file.")

}

Converting an image to grayscale is fairly easy, and I had fun implementing Floyd Steinberg. But rescaling an image is a pain, so I rely on an open source library to do that for me. And that library includes brightness and contrast adjustment routines, so I use those as well.

A note on printer resolution: printers are strange devices and they vary widely. Most ESC/P compatible printers should support printing at 60 DPI horizontally (single density) and 72 DPI vertically. The horizontal resolution might also be a mulitiple of 60, and those higher resolutions are called double, triple or quad density. This code allows you to print in single density mode (60x72 DPI) or double density mode (120x72 DPI) if your printer supports it. The code scales your image to preserve the aspect ratio on the printed page.

The IBM PC Compact Printer only supports 70 DPI horizontally and vertically, while the IBM Convertible Printer supports 60 or 120 DPI horizontally and just 60 DPI vertically. Those printers get special command line options so that the code will know to scale the image correctly for them.

Spot a bug or something I could be doing better? Let me know!

Running the code:

A Windows executable is provided as a download for convenience. This code should run under Linux too. (I'll get that tested.)

Basically, provide an input JPG file, a name that will be used as the base for a few output files, and then options for adjusting the image or specifying the printer.

Pictures that are wide can take advantage of the continuous nature of printers by rotating the image and printing in landscape mode, allowing them to create a bigger image. The -landscape option allows you to let the code decide or force landscape mode (or not).

The -v_scale option should not be needed, unless you have a printer with an oddball aspect ratio. If you have one of those (like the IBM printers mentioned above) you can use this option to provide a vertical scaling factor to make the image look normal on the printed page.

jpg2prn.exe -h

Usage: jpg2prn.exe -infile <file.jpg> -outfile <newfile_base> -model <5140|5181|generic> [other options]

Convert a JPG to older Epson ESC printer codes.  The JPG will be scaled,
rotated and dithered as needed.  A grayscale JPG and a dithered JPG are
written and be used as a simple preview for the final output.

  -brightness float
        Brightness adjust percentage: (-100 to 100)
  -contrast float
        Contrast adjust percentage: (-100 to 100)
  -h_density string
        Horizontal density: single (480 dots) or double (960 dots) (default "single")
  -infile string
        Input JPG filename
  -landscape string
        Rotate printer output: yes, no, auto
  -model string
        Printer model: 5181, 5140, or generic
  -outfile_base string
        Output filename base (do not specify an extension)
  -v_scale float
        Vertical scale: default is 1.2, only valid for generic printers

A few files will be created. The output file with the PRN extension is a binary file that will have ESC/P printer codes in it. Print this file by "dumping" it in binary mode straight to your printer. Under DOS you can use the copy command with the /b option to do this. ("copy /b image.jpg lpt1:") If you are printing to a network printer that understands ESC/P send the file to port 9100 on the printer.

The other output files are JPG files for debugging and preview purposes. Remember, when specifying the output file, only specify the name and not an extension. The generated files will be created using that name as a base. As of this writing the debug files include the grayscale JPG and the dithered JPG. You can use the dithered JPG as a preview of the output before printing.


Created July 28th, 2021
(C)opyright Michael B. Brutman, mbbrutman at gmail.com