344 lines
8.0 KiB
Go
344 lines
8.0 KiB
Go
package printer
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"image"
|
||
|
||
"image/color"
|
||
"image/draw"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"os"
|
||
"path/filepath"
|
||
|
||
"github.com/alexbrainman/printer"
|
||
log "github.com/sirupsen/logrus"
|
||
imagedraw "golang.org/x/image/draw"
|
||
)
|
||
|
||
type (
|
||
RoomDetailsRec struct {
|
||
XMLName xml.Name `xml:"roomdetails"`
|
||
Name string `xml:"customername"`
|
||
Checkout string `xml:"checkoutdatetime"`
|
||
RoomID string `xml:"roomno"`
|
||
Map string `xml:"roommap"`
|
||
Directions string `xml:"roomdirections"`
|
||
}
|
||
|
||
LayoutOptions struct {
|
||
XMLName xml.Name `xml:"LayoutOptions"`
|
||
LogoPath string `xml:"LogoPath"`
|
||
TopMostText string `xml:"TopMostText"`
|
||
BeforeRoomNumberText string `xml:"BeforeRoomNumberText"`
|
||
BeforeDirectionsText string `xml:"BeforeDirectionsText"`
|
||
HotelSpecificDetails string `xml:"HotelSpecificDetails"`
|
||
CheckOutTimeText string `xml:"CheckOutTimeText"`
|
||
RoomMapFolderPath string `xml:"RoomMapFolderPath"`
|
||
}
|
||
|
||
RoomTicket struct {
|
||
Logo string
|
||
TopMostText string
|
||
Name string
|
||
BeforeRoomNumberText string
|
||
RoomID string
|
||
BeforeDirectionsText string
|
||
Directions string
|
||
HotelSpecificDetails string
|
||
Map string
|
||
CheckOutTimeText string
|
||
Checkout string
|
||
}
|
||
)
|
||
|
||
var (
|
||
Layout LayoutOptions
|
||
PrinterName string
|
||
)
|
||
|
||
func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
|
||
// shortcuts
|
||
write := func(b []byte) { buf.Write(b) }
|
||
writeStr := func(s string) { buf.WriteString(s) }
|
||
|
||
const (
|
||
ESC = 0x1B
|
||
GS = 0x1D
|
||
CENTER = 0x01
|
||
BOLD_ON = 0x01
|
||
BOLD_OFF = 0x00
|
||
LARGE_FONT = 0x11
|
||
WIDER_FONT = 0x10
|
||
NORMAL_FONT = 0x00
|
||
)
|
||
|
||
// 0) Hotel logo at top
|
||
logoBytes, err := printLogo(Layout.LogoPath)
|
||
if err != nil {
|
||
log.Printf("map render: %s", err.Error())
|
||
}
|
||
|
||
write(logoBytes)
|
||
writeStr("\n\n")
|
||
|
||
// 1) TopMostText
|
||
write([]byte{GS, '!', NORMAL_FONT})
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(Layout.TopMostText + "\n\n")
|
||
|
||
// 2) Guest name
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(details.Name + "\n\n")
|
||
|
||
// 3) "Your room number is"
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(Layout.BeforeRoomNumberText + "\n\n")
|
||
|
||
// 4) RoomID in bold
|
||
write([]byte{ESC, 'a', CENTER})
|
||
write([]byte{GS, '!', WIDER_FONT})
|
||
writeStr(details.RoomID + "\n")
|
||
write([]byte{GS, '!', NORMAL_FONT})
|
||
writeStr("\n")
|
||
|
||
// 5) Directions label
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(Layout.BeforeDirectionsText + "\n")
|
||
|
||
// 6) Directions text
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(details.Directions + "\n\n")
|
||
|
||
// 7) Hotel-specific details
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(Layout.HotelSpecificDetails + "\n\n")
|
||
|
||
// 8) Room map image
|
||
mapPath := filepath.Join(Layout.RoomMapFolderPath, details.Map)
|
||
mapBytes, err := printMap(mapPath)
|
||
if err != nil {
|
||
log.Printf("map render: %s", err.Error())
|
||
}
|
||
write(mapBytes)
|
||
writeStr("\n\n")
|
||
|
||
// 9) CheckOutTimeText label in bold
|
||
write([]byte{ESC, 'a', CENTER})
|
||
write([]byte{GS, '!', WIDER_FONT})
|
||
writeStr(Layout.CheckOutTimeText + "\n")
|
||
|
||
// 10) Actual Checkout value in bold
|
||
write([]byte{ESC, 'a', CENTER})
|
||
writeStr(details.Checkout + "\n\n\n")
|
||
write([]byte{GS, '!', NORMAL_FONT})
|
||
|
||
// 11) Final feed + cut
|
||
write([]byte{ESC, 'd', 3})
|
||
write([]byte{GS, 'V', 1})
|
||
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func printLogo(path string) ([]byte, error) {
|
||
const maxLogoWidth = 384
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open logo %q: %w", path, err)
|
||
}
|
||
defer f.Close()
|
||
srcImg, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode logo %q: %w", path, err)
|
||
}
|
||
|
||
// 2) Composite over white
|
||
bounds := srcImg.Bounds()
|
||
whiteBg := image.NewRGBA(bounds)
|
||
draw.Draw(whiteBg, bounds, &image.Uniform{C: color.White}, image.Point{}, draw.Src)
|
||
draw.Draw(whiteBg, bounds, srcImg, bounds.Min, draw.Over)
|
||
|
||
// 3) Scale if too wide
|
||
w, h := bounds.Dx(), bounds.Dy()
|
||
if w > maxLogoWidth {
|
||
ratio := float64(maxLogoWidth) / float64(w)
|
||
newW := maxLogoWidth
|
||
newH := int(float64(h) * ratio)
|
||
|
||
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||
imagedraw.NearestNeighbor.Scale(dst, dst.Bounds(), whiteBg, bounds, imagedraw.Over, nil)
|
||
whiteBg = dst
|
||
w, h = newW, newH
|
||
}
|
||
|
||
// 4) Dither (Floyd–Steinberg)
|
||
gray := make([][]float32, h)
|
||
for y := 0; y < h; y++ {
|
||
gray[y] = make([]float32, w)
|
||
for x := 0; x < w; x++ {
|
||
r, g, b, _ := whiteBg.At(x, y).RGBA()
|
||
l := 0.299*float32(r)/65535 +
|
||
0.587*float32(g)/65535 +
|
||
0.114*float32(b)/65535
|
||
gray[y][x] = l
|
||
}
|
||
}
|
||
|
||
bin := make([][]bool, h)
|
||
for y := 0; y < h; y++ {
|
||
bin[y] = make([]bool, w)
|
||
for x := 0; x < w; x++ {
|
||
old := gray[y][x]
|
||
newV := float32(0.0)
|
||
if old > 0.5 {
|
||
newV = 1.0
|
||
}
|
||
bin[y][x] = (newV == 0.0)
|
||
err := old - newV
|
||
if x+1 < w {
|
||
gray[y][x+1] += err * 7 / 16
|
||
}
|
||
if y+1 < h {
|
||
if x > 0 {
|
||
gray[y+1][x-1] += err * 3 / 16
|
||
}
|
||
gray[y+1][x] += err * 5 / 16
|
||
if x+1 < w {
|
||
gray[y+1][x+1] += err * 1 / 16
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 5) Pack bits
|
||
widthBytes := (w + 7) / 8
|
||
var bitmap []byte
|
||
for y := 0; y < h; y++ {
|
||
for xb := 0; xb < widthBytes; xb++ {
|
||
var b byte
|
||
for bit := 0; bit < 8; bit++ {
|
||
px := xb*8 + bit
|
||
if px < w && bin[y][px] {
|
||
b |= 1 << (7 - bit)
|
||
}
|
||
}
|
||
bitmap = append(bitmap, b)
|
||
}
|
||
}
|
||
|
||
// 6) GS v 0 header
|
||
xL := byte(widthBytes & 0xFF)
|
||
xH := byte(widthBytes >> 8)
|
||
yL := byte(h & 0xFF)
|
||
yH := byte(h >> 8)
|
||
buf := bytes.NewBuffer(nil)
|
||
buf.Write([]byte{0x1D, 0x76, 0x30, 0x00, xL, xH, yL, yH})
|
||
buf.Write(bitmap)
|
||
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func printMap(path string) ([]byte, error) {
|
||
// 1) Open and decode the source image
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("open image %q: %w", path, err)
|
||
}
|
||
defer f.Close()
|
||
|
||
srcImg, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("decode image %q: %w", path, err)
|
||
}
|
||
|
||
// 2) Composite over white background to handle transparency
|
||
bounds := srcImg.Bounds()
|
||
whiteBg := image.NewRGBA(bounds)
|
||
draw.Draw(whiteBg, bounds, &image.Uniform{C: color.White}, image.Point{}, draw.Src)
|
||
draw.Draw(whiteBg, bounds, srcImg, bounds.Min, draw.Over)
|
||
|
||
// 3) Build monochrome bitmap
|
||
w, h := bounds.Dx(), bounds.Dy()
|
||
widthBytes := (w + 7) / 8
|
||
var bitmap []byte
|
||
for y := 0; y < h; y++ {
|
||
for xb := 0; xb < widthBytes; xb++ {
|
||
var b byte
|
||
for bit := 0; bit < 8; bit++ {
|
||
x := xb*8 + bit
|
||
if x >= w {
|
||
continue
|
||
}
|
||
gray := color.GrayModel.Convert(whiteBg.At(bounds.Min.X+x, bounds.Min.Y+y)).(color.Gray)
|
||
if gray.Y < 128 {
|
||
b |= 1 << (7 - bit)
|
||
}
|
||
}
|
||
bitmap = append(bitmap, b)
|
||
}
|
||
}
|
||
|
||
// 4) Prefix with ESC/POS raster image header (GS v 0)
|
||
xL := byte(widthBytes & 0xFF)
|
||
xH := byte((widthBytes >> 8) & 0xFF)
|
||
yL := byte(h & 0xFF)
|
||
yH := byte((h >> 8) & 0xFF)
|
||
|
||
cmd := bytes.NewBuffer(nil)
|
||
cmd.Write([]byte{0x1D, 0x76, 0x30, 0x00, xL, xH, yL, yH})
|
||
cmd.Write(bitmap)
|
||
|
||
return cmd.Bytes(), nil
|
||
}
|
||
|
||
func SendToPrinter(data []byte) error {
|
||
// Open the printer by its Windows name
|
||
h, err := printer.Open(PrinterName)
|
||
if err != nil {
|
||
return fmt.Errorf("printer.Open(%q): %w", PrinterName, err)
|
||
}
|
||
defer h.Close()
|
||
|
||
// Start a new RAW document
|
||
if err := h.StartDocument("ReceiptPrintJob", "RAW"); err != nil {
|
||
return fmt.Errorf("StartDocument RAW: %w", err)
|
||
}
|
||
defer h.EndDocument()
|
||
|
||
// Start the page
|
||
if err := h.StartPage(); err != nil {
|
||
return fmt.Errorf("StartPage: %w", err)
|
||
}
|
||
|
||
// Write the main receipt data
|
||
n, err := h.Write(data)
|
||
if err != nil {
|
||
return fmt.Errorf("Write data: %w", err)
|
||
}
|
||
if n < len(data) {
|
||
return fmt.Errorf("partial write: %d of %d bytes", n, len(data))
|
||
}
|
||
|
||
// Feed a few lines and issue a partial cut
|
||
// ESC d 3 (feeds 3 lines) -> 0x1B 0x64 0x03
|
||
// GS V 66 0 (partial cut) -> 0x1D 0x56 0x42 0x00
|
||
// feedAndCut := []byte{
|
||
// 0x1B, 0x64, 0x03, // Feed 3 lines
|
||
// 0x1D, 0x56, 0x42, 0x00, // Partial cut
|
||
// }
|
||
// if _, err := h.Write(feedAndCut); err != nil {
|
||
// return fmt.Errorf("Write feed+cut command: %w", err)
|
||
// }
|
||
|
||
// End the page
|
||
if err := h.EndPage(); err != nil {
|
||
return fmt.Errorf("EndPage: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|