613 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}