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 }