613 lines
15 KiB
Go
613 lines
15 KiB
Go
package printer
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
"image/draw"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"gitea.futuresens.co.uk/futuresens/hardlink/internal/errorhandlers"
|
||
"github.com/alexbrainman/printer"
|
||
log "github.com/sirupsen/logrus"
|
||
imagedraw "golang.org/x/image/draw"
|
||
)
|
||
|
||
type (
|
||
CardholderReceipt struct {
|
||
XMLName xml.Name `xml:"ReceiptData"`
|
||
ReceiptEntries struct {
|
||
ReceiptEntry []ReceiptEntryXML `xml:"ReceiptEntry"`
|
||
} `xml:"ReceiptEntries"`
|
||
}
|
||
|
||
ReceiptEntryXML struct {
|
||
ReceiptEntryId string `xml:"ReceiptEntryId"`
|
||
Value string `xml:"Value"`
|
||
Label string `xml:"Label"`
|
||
ReceiptItemType string `xml:"ReceiptItemType"`
|
||
Priority string `xml:"Priority"`
|
||
}
|
||
|
||
RoomDetailsRec struct {
|
||
XMLName xml.Name `xml:"roomdetails"`
|
||
Name string `xml:"customername"`
|
||
Checkout string `xml:"checkoutdatetime"`
|
||
RoomID string `xml:"roomno"`
|
||
Voucher string `xml:"voucher"`
|
||
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
|
||
}
|
||
)
|
||
|
||
const (
|
||
ESC = 0x1B
|
||
GS = 0x1D
|
||
CENTER = 0x01
|
||
BOLD_ON = 0x01
|
||
BOLD_OFF = 0x00
|
||
LARGE_FONT = 0x11
|
||
WIDER_FONT = 0x10
|
||
NORMAL_FONT = 0x00
|
||
)
|
||
|
||
var (
|
||
Layout LayoutOptions
|
||
PrinterName string
|
||
)
|
||
|
||
func ParseCardholderReceipt(data []byte) ([]ReceiptEntryXML, error) {
|
||
var tr CardholderReceipt
|
||
if err := xml.Unmarshal(data, &tr); err != nil {
|
||
return nil, fmt.Errorf("XML unmarshal: %w", err)
|
||
}
|
||
return tr.ReceiptEntries.ReceiptEntry, nil
|
||
}
|
||
|
||
func readTicketLayout() LayoutOptions {
|
||
var layout LayoutOptions
|
||
|
||
executablePath, err := os.Executable()
|
||
if err != nil {
|
||
errorhandlers.FatalError(fmt.Errorf("failed to locate application executable: %v", err))
|
||
}
|
||
|
||
layoutName := filepath.Join(filepath.Dir(executablePath), "TicketLayout.xml")
|
||
data, err := os.ReadFile(layoutName)
|
||
if err != nil {
|
||
errorhandlers.FatalError(fmt.Errorf("failed to read %s: %v", layoutName, err))
|
||
}
|
||
|
||
if err := xml.Unmarshal(data, &layout); err != nil {
|
||
errorhandlers.FatalError(fmt.Errorf("failed to parse %s: %v", layoutName, err))
|
||
}
|
||
|
||
return layout
|
||
}
|
||
|
||
func BuildRoomTicket(details RoomDetailsRec) ([]byte, error) {
|
||
Layout = readTicketLayout()
|
||
|
||
var buf bytes.Buffer
|
||
|
||
// shortcuts
|
||
write := func(b []byte) { buf.Write(b) }
|
||
writeStr := func(s string) { buf.WriteString(s) }
|
||
|
||
// 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")
|
||
|
||
if details.Voucher != "" {
|
||
s := strings.Repeat("*", 44)
|
||
writeStr(fmt.Sprintf("%s\n\n%s\n\n%s\n\n", s, details.Voucher, s))
|
||
}
|
||
|
||
// 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 PrintReceipt(receipt string) {
|
||
|
||
if len(receipt) == 0 {
|
||
log.Warn("Empty cardholder receipt, skipping print")
|
||
return
|
||
}
|
||
|
||
if err := printCardholderReceipt(receipt); err != nil {
|
||
log.Errorf("PrintCardholderReceipt error: %v", err)
|
||
}
|
||
}
|
||
|
||
func PrintSaleReceipt(receipt string) {
|
||
if len(receipt) == 0 {
|
||
log.Warn("Empty sale receipt, skipping print")
|
||
return
|
||
}
|
||
var buf bytes.Buffer
|
||
buf.WriteString(receipt)
|
||
buf.WriteByte('\n')
|
||
buf.Write([]byte{ESC, 'd', 7})
|
||
buf.Write([]byte{GS, 'V', 1})
|
||
|
||
|
||
if err := SendToPrinter(buf.Bytes()); err != nil {
|
||
log.Errorf("SendToPrinter error: %v", err)
|
||
}
|
||
}
|
||
|
||
func printCardholderReceipt(cardholderReceipt string) error {
|
||
receiptEntries, err := ParseCardholderReceipt([]byte(cardholderReceipt))
|
||
if err != nil {
|
||
return fmt.Errorf("ParseCardholderReceipt: %w", err)
|
||
}
|
||
|
||
data, err := BuildCardholderReceipt(receiptEntries)
|
||
if err != nil {
|
||
return fmt.Errorf("BuildCardholderReceipt: %w", err)
|
||
}
|
||
|
||
// Send to the Windows Epson TM-T82II via the printer package
|
||
if err := SendToPrinter(data); err != nil {
|
||
return fmt.Errorf("SendToPrinter: %w", err)
|
||
}
|
||
log.Info("Cardholder receipt printed successfully")
|
||
return nil
|
||
}
|
||
|
||
// receiptColumns follows the existing 44-column normal-size text convention.
|
||
const receiptColumns = 44
|
||
|
||
func BuildCardholderReceipt(entries []ReceiptEntryXML) ([]byte, error) {
|
||
var buf bytes.Buffer
|
||
rendered := make([]bool, len(entries))
|
||
take := func(id string) string {
|
||
for i, entry := range entries {
|
||
if !rendered[i] && entry.ReceiptEntryId == id && entry.Value != "" {
|
||
rendered[i] = true
|
||
return receiptEntryText(entry)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Set and restore only text state; leave printer setup and transport alone.
|
||
normal := func() { buf.Write([]byte{ESC, 'a', 0, ESC, 'E', BOLD_OFF, GS, '!', NORMAL_FONT}) }
|
||
normal()
|
||
hasRows, newSection := false, false
|
||
beginLine := func(center bool) {
|
||
if hasRows && newSection {
|
||
buf.WriteString(strings.Repeat("-", receiptColumns) + "\n")
|
||
}
|
||
hasRows, newSection = true, false
|
||
if center {
|
||
buf.Write([]byte{ESC, 'a', CENTER})
|
||
}
|
||
}
|
||
text := func(value string, bold bool) {
|
||
if bold {
|
||
buf.Write([]byte{ESC, 'E', BOLD_ON})
|
||
}
|
||
buf.WriteString(value)
|
||
if bold {
|
||
buf.Write([]byte{ESC, 'E', BOLD_OFF})
|
||
}
|
||
}
|
||
line := func(value string, bold, center bool) {
|
||
if value == "" {
|
||
return
|
||
}
|
||
beginLine(center)
|
||
text(value, bold)
|
||
buf.WriteByte('\n')
|
||
normal()
|
||
}
|
||
pair := func(left, right string, boldRight bool) {
|
||
if gap, fits := receiptPairGap(left, right); fits {
|
||
beginLine(false)
|
||
text(left+gap, false)
|
||
text(right, boldRight)
|
||
buf.WriteByte('\n')
|
||
normal()
|
||
return
|
||
}
|
||
line(left, false, false)
|
||
line(right, boldRight, false)
|
||
}
|
||
|
||
line(take("Recipient"), true, true)
|
||
line(take("MerchantName"), false, true)
|
||
newSection = true
|
||
line(take("CardScheme"), false, false)
|
||
line(take("PanMasked"), false, false)
|
||
pair(take("Aid"), take("PanSequence"), false)
|
||
pair(take("TransactionSource"), take("TransactionType"), false)
|
||
newSection = true
|
||
// Align the provider amount without parsing or modifying its value.
|
||
for i, entry := range entries {
|
||
if entry.ReceiptEntryId == "TotalAmount" && entry.Value != "" {
|
||
rendered[i] = true
|
||
label := entry.Label
|
||
if label == "" {
|
||
label = "TOTAL"
|
||
}
|
||
label = strings.TrimSuffix(label, ":") + ":"
|
||
if gap, fits := receiptPairGap(label, entry.Value); fits {
|
||
line(label+gap+entry.Value, true, false)
|
||
} else {
|
||
line(receiptEntryText(entry), true, false)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
pair(take("CardholderVerification"), take("TransactionResult"), true)
|
||
newSection = true
|
||
line(take("AuthCode"), false, false)
|
||
line(take("AuthReference"), false, false)
|
||
line(take("TransactionSeqNumber"), false, false)
|
||
pair(take("MerchantIdMasked"), take("TerminalIdMasked"), false)
|
||
line(take("AuthDateTime"), false, false)
|
||
footer := take("RetentionMessage")
|
||
// Preserve every remaining occurrence, including unknown IDs, in provider order.
|
||
for i, entry := range entries {
|
||
if !rendered[i] && entry.Value != "" {
|
||
line(receiptEntryText(entry), false, false)
|
||
}
|
||
}
|
||
newSection = true
|
||
line(footer, false, true)
|
||
normal()
|
||
buf.Write([]byte{ESC, 'd', 7})
|
||
buf.Write([]byte{GS, 'V', 1})
|
||
return buf.Bytes(), nil
|
||
}
|
||
|
||
func receiptEntryText(entry ReceiptEntryXML) string {
|
||
label := entry.Label
|
||
if label == "" {
|
||
switch entry.ReceiptEntryId {
|
||
case "Recipient", "MerchantName", "CardScheme", "TransactionSource", "TransactionType",
|
||
"CardholderVerification", "TransactionResult", "AuthDateTime", "RetentionMessage":
|
||
return entry.Value
|
||
case "PanMasked":
|
||
label = "Card"
|
||
case "Aid":
|
||
label = "AID"
|
||
case "PanSequence":
|
||
label = "PAN Seq No"
|
||
case "TotalAmount":
|
||
label = "TOTAL"
|
||
case "AuthCode":
|
||
label = "Auth Code"
|
||
case "AuthReference":
|
||
label = "Ref"
|
||
case "TransactionSeqNumber":
|
||
label = "Transaction Seq. No"
|
||
case "MerchantIdMasked":
|
||
label = "MID"
|
||
case "TerminalIdMasked":
|
||
label = "TID"
|
||
default:
|
||
var readable strings.Builder
|
||
runes := []rune(entry.ReceiptEntryId)
|
||
for i, r := range runes {
|
||
if i > 0 && r >= 'A' && r <= 'Z' &&
|
||
((runes[i-1] >= 'a' && runes[i-1] <= 'z') ||
|
||
(i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z')) {
|
||
readable.WriteByte(' ')
|
||
}
|
||
if r == '_' || r == '-' {
|
||
r = ' '
|
||
}
|
||
readable.WriteRune(r)
|
||
}
|
||
label = strings.TrimSpace(readable.String())
|
||
}
|
||
}
|
||
if label == "" {
|
||
return entry.Value
|
||
}
|
||
return strings.TrimSuffix(label, ":") + ": " + entry.Value
|
||
}
|
||
|
||
func receiptPairGap(left, right string) (string, bool) {
|
||
if left == "" || right == "" || len(left)+2+len(right) > receiptColumns {
|
||
return "", false
|
||
}
|
||
// Only printable ASCII has a predictable single-column width here.
|
||
for _, r := range left + right {
|
||
if r < ' ' || r > '~' {
|
||
return "", false
|
||
}
|
||
}
|
||
return strings.Repeat(" ", receiptColumns-len(left)-len(right)), true
|
||
}
|
||
|
||
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
|
||
}
|