From a268144ca7d61330cae7f7135633d7c057b5e4a8 Mon Sep 17 00:00:00 2001 From: santosh Date: Wed, 26 Aug 2026 18:08:41 +0530 Subject: [PATCH 1/3] print area fix --- .../com/boatticket/config/ConfigManager.java | 45 ++ .../util/ThermalPrinterManager.java | 451 +++++++++--------- src/main/tenants/honnavar/Config.properties | 7 + src/main/tenants/kasarkod/Config.properties | 6 + 4 files changed, 289 insertions(+), 220 deletions(-) diff --git a/src/main/java/com/boatticket/config/ConfigManager.java b/src/main/java/com/boatticket/config/ConfigManager.java index 398f264..23a8580 100644 --- a/src/main/java/com/boatticket/config/ConfigManager.java +++ b/src/main/java/com/boatticket/config/ConfigManager.java @@ -29,6 +29,14 @@ */ public class ConfigManager { + // TVSE RP4230 at 203 dpi: an 80 mm roll with a 10 mm horizontal offset leaves 70 mm (560 dots) + // for ticket content. Normal ESC/POS font A fits 35 characters in that width. + private static final int DEFAULT_PRINTER_LEFT_MARGIN_DOTS = 80; + private static final int DEFAULT_PRINTER_WIDTH_DOTS = 560; + private static final int DEFAULT_PRINTER_CHARACTERS_PER_LINE = 35; + private static final int LEGACY_PRINTER_WIDTH_DOTS = 832; + private static final int LEGACY_PRINTER_CHARACTERS_PER_LINE = 52; + private static final String TENANT_ID = bundledTenantId(); private static final String APP_DIR = System.getProperty("user.home") @@ -180,6 +188,27 @@ public static String getPrinterName() { return value == null ? "" : value.trim(); } + /** Printable width, in dots, used when sending raw ESC/POS tickets. */ + public static int getPrinterWidthDots() { + if (usesLegacy108MmPrinterProfile()) { + return DEFAULT_PRINTER_WIDTH_DOTS; + } + return getInt("printer.width.dots", DEFAULT_PRINTER_WIDTH_DOTS); + } + + /** Left margin, in dots, used when sending raw ESC/POS tickets. */ + public static int getPrinterLeftMarginDots() { + return getInt("printer.left.margin.dots", DEFAULT_PRINTER_LEFT_MARGIN_DOTS); + } + + /** Characters per line for the printer's normal font. */ + public static int getPrinterCharactersPerLine() { + if (usesLegacy108MmPrinterProfile()) { + return DEFAULT_PRINTER_CHARACTERS_PER_LINE; + } + return getInt("printer.characters.per.line", DEFAULT_PRINTER_CHARACTERS_PER_LINE); + } + // ------------------------------------------------------------------ // Shared plumbing // ------------------------------------------------------------------ @@ -214,6 +243,17 @@ private static int getInt(String key, int fallback) { } } + /** + * Existing installations keep their external configuration when the app is updated. Recognize + * only the old stock 108 mm values as a pair, so those installations automatically use the new 80 + * mm profile without overriding deliberately customized settings. + */ + private static boolean usesLegacy108MmPrinterProfile() { + return LEGACY_PRINTER_WIDTH_DOTS == getInt("printer.width.dots", DEFAULT_PRINTER_WIDTH_DOTS) + && LEGACY_PRINTER_CHARACTERS_PER_LINE + == getInt("printer.characters.per.line", DEFAULT_PRINTER_CHARACTERS_PER_LINE); + } + private static String getString(String key, String fallback) { String value = getProperties().getProperty(key); return value == null || value.isBlank() ? fallback : value.trim(); @@ -302,6 +342,11 @@ private static void writeHardcodedDefaults(Path configFilePath) throws IOExcepti String.valueOf(DEFAULT_UPDATE_CONNECTION_TIMEOUT_SECONDS)); defaults.setProperty("dev.mode", String.valueOf(DEFAULT_DEV_MODE)); defaults.setProperty("printer.name", ""); + defaults.setProperty( + "printer.left.margin.dots", String.valueOf(DEFAULT_PRINTER_LEFT_MARGIN_DOTS)); + defaults.setProperty("printer.width.dots", String.valueOf(DEFAULT_PRINTER_WIDTH_DOTS)); + defaults.setProperty( + "printer.characters.per.line", String.valueOf(DEFAULT_PRINTER_CHARACTERS_PER_LINE)); try (OutputStream outputStream = new FileOutputStream(configFilePath.toFile())) { defaults.store(outputStream, "Boat Booking System - application configuration"); diff --git a/src/main/java/com/boatticket/util/ThermalPrinterManager.java b/src/main/java/com/boatticket/util/ThermalPrinterManager.java index 504b002..ddb5fbf 100644 --- a/src/main/java/com/boatticket/util/ThermalPrinterManager.java +++ b/src/main/java/com/boatticket/util/ThermalPrinterManager.java @@ -2,314 +2,325 @@ import com.boatticket.config.ConfigManager; import com.boatticket.model.Ticket; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.Locale; import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.print.*; +import javax.imageio.ImageIO; +import javax.print.Doc; +import javax.print.DocFlavor; +import javax.print.DocPrintJob; +import javax.print.PrintException; +import javax.print.PrintService; +import javax.print.PrintServiceLookup; +import javax.print.SimpleDoc; import javax.print.attribute.HashPrintRequestAttributeSet; import javax.print.attribute.PrintRequestAttributeSet; import javax.print.attribute.standard.Copies; +import javax.print.attribute.standard.JobName; +/** Generates and sends full-width raw ESC/POS tickets to a thermal printer. */ public class ThermalPrinterManager { - - private static final int LINE_WIDTH = 32; // 80mm thermal printer ~32 chars - private static final String SEPARATOR = "================================"; - - // Printer names that are virtual "print to file" destinations, not - // real thermal/receipt printers. If the resolved printer's name - // contains any of these (case-insensitive), we refuse to print to - // it rather than silently generating a PDF/XPS/etc. + private static final int RP4230_PRINTABLE_WIDTH_DOTS = 832; + private static final int RP4230_DPI = 203; + private static final int ROLL_WIDTH_DOTS = 640; + private static final int NORMAL_FONT_CHARACTER_WIDTH_DOTS = 16; + // The 80 mm roll has a 10 mm (80-dot) unprintable area on the left, leaving 70 mm (560 dots) + // for content. Raw ESC/POS bypasses the Windows driver setting, so the printer margin is set + // explicitly below. + private static final int CONTENT_CHARACTERS_PER_LINE = 52; + private static final int LOGO_HEIGHT_DOTS = RP4230_DPI * 10 / 25; + private static final String LOGO_RESOURCE = "/com/boatticket/images/karnataka-tourism-logo.png"; private static final String[] VIRTUAL_PRINTER_KEYWORDS = { "microsoft print to pdf", "microsoft xps document writer", "onenote", "send to onenote", "fax" }; - /** - * Resolves which printer to send the ticket to. - * - *

- If {@code printer.name} is set in Config.properties, that exact printer (matched - * case-insensitively) is used. This is the recommended setup for a deployed ticket counter, so - * printing never depends on whatever Windows happens to have set as its default printer. - - * Otherwise, falls back to the system default printer — but refuses to use it if it's a known - * virtual/PDF-style printer (e.g. "Microsoft Print to PDF"), since that indicates the Windows - * default printer is misconfigured for this app rather than the app doing anything wrong. - */ public static PrintService resolvePrinter() throws PrintException { PrintService[] allPrinters = PrintServiceLookup.lookupPrintServices(null, null); String configuredName = ConfigManager.getPrinterName(); - if (!configuredName.isEmpty()) { for (PrintService printer : allPrinters) { - if (printer.getName().equalsIgnoreCase(configuredName)) { - return printer; - } + if (printer.getName().equalsIgnoreCase(configuredName)) return printer; } throw new PrintException( "Configured printer \"" + configuredName - + "\" (printer.name in " - + "Config.properties) was not found. Available printers: " + + "\" was not found. Available printers: " + listPrinterNames(allPrinters)); } - PrintService defaultPrinter = PrintServiceLookup.lookupDefaultPrintService(); - if (defaultPrinter == null) { - throw new PrintException("No default printer found on the system"); - } - + if (defaultPrinter == null) throw new PrintException("No default printer found on the system"); if (isVirtualPrinter(defaultPrinter.getName())) { throw new PrintException( "The Windows default printer is \"" + defaultPrinter.getName() - + "\", which creates a file instead of printing to a real device. " - + "Either set that machine's default printer to the actual thermal " - + "printer, or set printer.name= in Config.properties. " - + "Available printers: " + + "\", which creates a file instead of printing to a real device. Available printers: " + listPrinterNames(allPrinters)); } - return defaultPrinter; } /** - * @deprecated use {@link #resolvePrinter()}, which also honours printer.name from - * Config.properties and rejects virtual/PDF printers. Kept only in case anything external - * still calls it directly. + * @deprecated Use {@link #resolvePrinter()}. */ @Deprecated public static PrintService getDefaultPrinter() throws PrintException { return resolvePrinter(); } - private static boolean isVirtualPrinter(String printerName) { - if (printerName == null) { - return false; - } - String lower = printerName.toLowerCase(Locale.ROOT); - for (String keyword : VIRTUAL_PRINTER_KEYWORDS) { - if (lower.contains(keyword)) { - return true; - } - } - return false; - } - - private static String listPrinterNames(PrintService[] printers) { - if (printers == null || printers.length == 0) { - return "(none found)"; - } - return Stream.of(printers).map(PrintService::getName).collect(Collectors.joining(", ")); - } - - /** Print ticket to the resolved printer (see {@link #resolvePrinter()}). */ + /** Sends a raw ESC/POS ticket, including a centered 10 mm logo and full cut. */ public static String printTicket(Ticket ticket) throws PrintException, IOException { PrintService selectedPrinter = resolvePrinter(); - - String content = formatTicketContent(ticket); - byte[] bytes = content.getBytes("UTF-8"); - - // Try different flavors to find one supported by the printer - DocFlavor flavor = null; - DocFlavor[] tryFlavors = { - DocFlavor.BYTE_ARRAY.TEXT_PLAIN_UTF_8, - DocFlavor.BYTE_ARRAY.AUTOSENSE, - DocFlavor.INPUT_STREAM.AUTOSENSE - }; - - for (DocFlavor tryFlavor : tryFlavors) { - if (selectedPrinter.isDocFlavorSupported(tryFlavor)) { - flavor = tryFlavor; - break; - } - } - - if (flavor == null) { - // Default to BYTE_ARRAY.AUTOSENSE if no supported flavor found - flavor = DocFlavor.BYTE_ARRAY.AUTOSENSE; + DocFlavor flavor = DocFlavor.BYTE_ARRAY.AUTOSENSE; + if (!selectedPrinter.isDocFlavorSupported(flavor)) { + throw new PrintException( + "Printer \"" + + selectedPrinter.getName() + + "\" does not accept raw ESC/POS data. Install/select its ESC/POS-compatible driver."); } - - Doc doc = new SimpleDoc(bytes, flavor, null); + Doc doc = new SimpleDoc(formatTicketBytes(ticket), flavor, null); DocPrintJob job = selectedPrinter.createPrintJob(); - PrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet(); attrs.add(new Copies(1)); - + attrs.add(new JobName("Boat ticket " + ticket.getTicketId(), Locale.ROOT)); job.print(doc, attrs); return selectedPrinter.getName(); } - /** Print ticket to console for preview/debugging */ + /** Prints the readable ticket text to the console; image/control bytes are excluded. */ public static void printTicketToConsole(Ticket ticket) { - String content = formatTicketContent(ticket); System.out.println("\n" + "=".repeat(40)); System.out.println("THERMAL PRINTER PREVIEW"); System.out.println("=".repeat(40)); - System.out.println(content); + System.out.println(formatTicketContent(ticket)); System.out.println("=".repeat(40)); } - /** Format ticket content for 80mm thermal printer (colorless, optimized layout) */ - private static String formatTicketContent(Ticket ticket) { - StringBuilder sb = new StringBuilder(); + private static byte[] formatTicketBytes(Ticket ticket) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int printableWidth = configuredPrintableWidth(); + int leftMargin = configuredLeftMargin(); + output.write(new byte[] {0x1B, 0x40}); // ESC @: initialize + output.write(new byte[] {0x1D, 0x4C, (byte) leftMargin, (byte) (leftMargin >> 8)}); // GS L + output.write( + new byte[] {0x1D, 0x57, (byte) printableWidth, (byte) (printableWidth >> 8)}); // GS W + writeCenteredLogo(output, printableWidth); + // Position zero is now the configured GS L left margin, so all text starts after the 10 mm + // unprintable area. + output.write(new byte[] {0x1B, 0x24, 0x00, 0x00}); // ESC $ 0: left margin + output.write(new byte[] {0x1B, 0x61, 0x00}); // ESC a 0: left justification + output.write(formatTicketContent(ticket).getBytes(StandardCharsets.US_ASCII)); + output.write(new byte[] {0x1D, 0x56, 0x42, 0x00}); // GS V B 0: feed then full cut + return output.toByteArray(); + } - // Header - sb.append(centerText(ConfigManager.getReceiptHeader())).append("\n"); - sb.append(centerText(ConfigManager.getReceiptLocation() + ", RECEIPT")).append("\n"); - sb.append(SEPARATOR).append("\n"); - sb.append("\n"); + private static void writeCenteredLogo(ByteArrayOutputStream output, int printableWidth) + throws IOException { + BufferedImage logo = loadAndScaleLogo(printableWidth); + int bytesPerRow = (logo.getWidth() + 7) / 8; + int leftPadding = Math.max(0, (printableWidth - bytesPerRow * 8) / 2); + output.write(new byte[] {0x1B, 0x24, (byte) leftPadding, (byte) (leftPadding >> 8)}); // ESC $ + output.write( + new byte[] { + 0x1D, + 0x76, + 0x30, + 0x00, + (byte) bytesPerRow, + (byte) (bytesPerRow >> 8), + (byte) logo.getHeight(), + (byte) (logo.getHeight() >> 8) + }); // GS v 0 + for (int y = 0; y < logo.getHeight(); y++) { + for (int byteIndex = 0; byteIndex < bytesPerRow; byteIndex++) { + int bits = 0; + for (int bit = 0; bit < 8; bit++) { + int x = byteIndex * 8 + bit; + if (x < logo.getWidth() && isDark(logo.getRGB(x, y))) bits |= 0x80 >> bit; + } + output.write(bits); + } + } + output.write('\n'); + } - // Ticket ID & Date - // sb.append("*** TICKET ID: ").append(ticket.getTicketId()).append(" ***\n"); - sb.append(formatLine("DATE & TIME:", formatDateTime(ticket.getFormattedBookingTime()))) - .append("\n"); - sb.append("\n"); + private static BufferedImage loadAndScaleLogo(int printableWidth) throws IOException { + try (InputStream input = ThermalPrinterManager.class.getResourceAsStream(LOGO_RESOURCE)) { + if (input == null) + throw new IOException("Bundled Karnataka Tourism logo is missing: " + LOGO_RESOURCE); + BufferedImage source = ImageIO.read(input); + if (source == null) throw new IOException("Bundled Karnataka Tourism logo could not be read"); + BufferedImage cropped = cropWhiteMargin(source); + int targetWidth = + Math.min(printableWidth, cropped.getWidth() * LOGO_HEIGHT_DOTS / cropped.getHeight()); + BufferedImage scaled = + new BufferedImage(targetWidth, LOGO_HEIGHT_DOTS, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = scaled.createGraphics(); + graphics.drawImage( + cropped.getScaledInstance(targetWidth, LOGO_HEIGHT_DOTS, Image.SCALE_SMOOTH), 0, 0, null); + graphics.dispose(); + return scaled; + } + } + + private static BufferedImage cropWhiteMargin(BufferedImage source) { + int minX = source.getWidth(), minY = source.getHeight(), maxX = -1, maxY = -1; + for (int y = 0; y < source.getHeight(); y++) { + for (int x = 0; x < source.getWidth(); x++) { + if (isDark(source.getRGB(x, y))) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + return maxX < minX ? source : source.getSubimage(minX, minY, maxX - minX + 1, maxY - minY + 1); + } - // Customer Details - // sb.append("CUSTOMER DETAILS").append("\n"); - // sb.append(SEPARATOR).append("\n"); - // sb.append(formatLine("Name:", truncate(ticket.getCustomerName(), LINE_WIDTH - - // 6))).append("\n"); - // sb.append(formatLine("Contact:", ticket.getContactNumber())).append("\n"); - sb.append(formatLine("Persons:", String.valueOf(ticket.getNumberOfPeople()))).append("\n"); - sb.append("\n"); + private static boolean isDark(int rgb) { + int red = (rgb >> 16) & 0xFF, green = (rgb >> 8) & 0xFF, blue = rgb & 0xFF; + return red * 299 + green * 587 + blue * 114 < 220_000; + } - // Driver & Boat Details + private static String formatTicketContent(Ticket ticket) { + int lineWidth = configuredCharactersPerLine(); + StringBuilder sb = new StringBuilder(); + sb.append(bold(centerText(ConfigManager.getReceiptHeader(), lineWidth))).append("\n"); + sb.append(bold(centerText(ConfigManager.getReceiptLocation() + ", RECEIPT", lineWidth))) + .append("\n"); + sb.append(rule(lineWidth)); + sb.append( + formatLine(" DATE & TIME:", formatDateTime(ticket.getFormattedBookingTime()), lineWidth)) + .append("\n\n"); + sb.append(formatLine(" Persons:", String.valueOf(ticket.getNumberOfPeople()), lineWidth)) + .append("\n\n"); if (ticket.getBoatOwner() != null) { - sb.append("DRIVER & BOAT DETAILS").append("\n"); - sb.append(SEPARATOR).append("\n"); - sb.append( - formatLine( - "Driver:", truncate(ticket.getBoatOwner().getDriverName(), LINE_WIDTH - 8))) + sb.append(bold(" DRIVER & BOAT DETAILS")).append("\n").append(rule(lineWidth)); + sb.append(formatLine(" Driver:", ticket.getBoatOwner().getDriverName(), lineWidth)) + .append("\n\n"); + sb.append(formatLine(" Driver Number:", ticket.getBoatOwner().getContact(), lineWidth)) + .append("\n"); + sb.append(formatLine(" Boat HR:", ticket.getBoatOwner().getBoatHRNumber(), lineWidth)) .append("\n"); - sb.append(formatLine("Driver Number:", ticket.getBoatOwner().getContact())).append("\n"); - sb.append(formatLine("Boat HR:", ticket.getBoatOwner().getBoatHRNumber())).append("\n"); - sb.append(formatLine("Capacity:", ticket.getBoatOwner().getCapacity() + " persons")) + sb.append( + formatLine(" Capacity:", ticket.getBoatOwner().getCapacity() + " persons", lineWidth)) .append("\n"); - sb.append("\n"); } - - // Ride Details - sb.append("RIDE DETAILS").append("\n"); - sb.append(SEPARATOR).append("\n"); - sb.append(formatLine("Duration:", "** " + ticket.getRideDurationInHour() + " HOUR(S) **")) + sb.append(bold(" RIDE DETAILS")).append("\n").append(rule(lineWidth)); + sb.append( + formatLineWithBoldValue( + " Duration:", ticket.getRideDurationInHour() + " HOUR(S)", lineWidth)) .append("\n"); - - if (ticket.isLifeJacketRequired()) { - sb.append(formatLine("Life Jackets:", String.valueOf(ticket.getLifeJacketCount()))) + if (ticket.isLifeJacketRequired()) + sb.append(formatLine(" Life Jackets:", String.valueOf(ticket.getLifeJacketCount()), lineWidth)) .append("\n"); - } - if (ticket.isParkingRequired()) { - String vehicleValue = "** " + ticket.getVehicleTypeDisplay(); - if (ticket.getVehicleNumber() != null && !ticket.getVehicleNumber().isBlank()) { - vehicleValue += " | " + ticket.getVehicleNumber().toUpperCase(); - } - vehicleValue += " **"; - sb.append(formatLine("Vehicle Parking:", vehicleValue)).append("\n"); + String vehicle = ticket.getVehicleTypeDisplay(); + if (ticket.getVehicleNumber() != null && !ticket.getVehicleNumber().isBlank()) + vehicle += " | " + ticket.getVehicleNumber().toUpperCase(Locale.ROOT); + sb.append(formatLineWithBoldValue(" Vehicle Parking:", vehicle, lineWidth)).append("\n"); } - sb.append("\n"); - - // Fee Breakdown - sb.append("FEE BREAKDOWN").append("\n"); - sb.append(SEPARATOR).append("\n"); - sb.append(formatLine("Boat Ride Fee:", "** Rs " + ticket.getBoatRideFee() + " **")) + sb.append("\n").append(bold(" FEE BREAKDOWN")).append("\n").append(rule(lineWidth)); + sb.append(formatLineWithBoldValue(" Boat Ride Fee:", "Rs " + ticket.getBoatRideFee(), lineWidth)) .append("\n"); - - if (ticket.isLifeJacketRequired()) { - sb.append(formatLine("Life Jacket Fee:", "Rs " + ticket.getLifeJacketFee())).append("\n"); - } - - if (ticket.isParkingRequired()) { - sb.append(formatLine("Parking Fee:", "** Rs " + ticket.getParkingFee() + " **")).append("\n"); - } - - sb.append("\n"); - // sb.append(centerText("*** AMOUNT TO DRIVER ***")).append("\n"); - // sb.append(centerText("*** Rs " + ticket.getAmountToDriver() + " ***")).append("\n"); - // sb.append(centerText("(Paid directly to driver)")).append("\n"); - // sb.append("\n"); - - // Counter Payable + if (ticket.isLifeJacketRequired()) + sb.append(formatLine(" Life Jacket Fee:", "Rs " + ticket.getLifeJacketFee(), lineWidth)) + .append("\n"); + if (ticket.isParkingRequired()) + sb.append(formatLineWithBoldValue(" Parking Fee:", "Rs " + ticket.getParkingFee(), lineWidth)) + .append("\n"); if (ticket.getCounterPayable() > 0) { - sb.append(centerText("===== COUNTER PAYMENT =====")).append("\n"); - sb.append(centerText("*** PAYABLE AT COUNTER ***")).append("\n"); - sb.append(centerText("*** Rs " + ticket.getCounterPayable() + " ***")).append("\n"); - sb.append(centerText("(PARKING FEE ONLY)")).append("\n"); - sb.append("\n"); + sb.append("\n").append(bold(centerText(" COUNTER PAYMENT", lineWidth))).append("\n"); + sb.append(bold(centerText(" PAYABLE AT COUNTER", lineWidth))).append("\n"); + sb.append(bold(centerText(" Rs " + ticket.getCounterPayable(), lineWidth))).append("\n"); + sb.append(centerText(" (PARKING FEE ONLY)", lineWidth)).append("\n"); } + return sb.append("\n") + .append(rule(lineWidth)) + .append(bold(centerText(" Thank You!", lineWidth))) + .append("\n") + .append(bold(centerText(" Safe & Happy Journey", lineWidth))) + .append("\n") + .toString(); + } - // Footer - sb.append(SEPARATOR).append("\n"); - sb.append(centerText("Thank You!")).append("\n"); - sb.append(centerText("Safe & Happy Journey")).append("\n"); - sb.append("\n\n"); + /** ESC/POS underline mode makes one continuous rule, unlike a row of '=' glyphs. */ + private static String rule(int width) { + return "\u001B-\u0001" + " ".repeat(width) + "\u001B-\u0000\n"; + } - // Multiple newlines to advance paper and cut - for (int i = 0; i < 3; i++) { - sb.append("\n"); - } + private static String bold(String text) { + return "\u001BE\u0001" + text + "\u001BE\u0000"; + } - return sb.toString(); + private static String formatLine(String label, String value, int width) { + String safeValue = value == null ? "" : value; + int gap = width - label.length() - safeValue.length(); + return gap < 1 + ? label + "\n" + rightAlign(safeValue, width) + : label + " ".repeat(gap) + safeValue; } - /** - * Formats a "label + value" line so the value's right edge lands on the same column every time, - * no matter how long the label is -- e.g.: - * - *

-   *   Driver:                Ravi Kumar
-   *   Driver Number:         9900011001
-   *   Boat HR:                    HR-001
-   * 
- * - * If label + value can't fit on one line within {@link #LINE_WIDTH}, the value drops to its own - * line, still right-aligned to the same column, instead of overflowing/wrapping unpredictably. - */ - private static String formatLine(String label, String value) { - if (value == null) { - value = ""; - } + private static String formatLineWithBoldValue(String label, String value, int width) { + String safeValue = value == null ? "" : value; + int gap = width - label.length() - safeValue.length(); + return gap < 1 + ? label + "\n" + " ".repeat(Math.max(0, width - safeValue.length())) + bold(safeValue) + : label + " ".repeat(gap) + bold(safeValue); + } - int gap = LINE_WIDTH - label.length() - value.length(); + private static String rightAlign(String text, int width) { + return text.length() >= width ? text : " ".repeat(width - text.length()) + text; + } - if (gap < 1) { - return label + "\n" + rightAlign(value, LINE_WIDTH); - } + private static String centerText(String text, int width) { + return text.length() >= width + ? text.substring(0, width) + : " ".repeat((width - text.length()) / 2) + text; + } - return label + " ".repeat(gap) + value; + private static String formatDateTime(String time) { + return time == null || time.length() < 19 + ? time + : time.substring(0, 10).replace("-", "/") + " " + time.substring(12, 17); } - /** Right-aligns text within the line width (pads with leading spaces). */ - private static String rightAlign(String text, int width) { - if (text.length() >= width) { - return text; - } - return " ".repeat(width - text.length()) + text; + private static int configuredPrintableWidth() { + int width = ConfigManager.getPrinterWidthDots(); + int availableWidth = ROLL_WIDTH_DOTS - configuredLeftMargin(); + if (width <= 0) return availableWidth; + return Math.min(width, availableWidth); } - /** Center text within the line width */ - private static String centerText(String text) { - if (text.length() >= LINE_WIDTH) { - return text.substring(0, LINE_WIDTH); - } - int padding = (LINE_WIDTH - text.length()) / 2; - return " ".repeat(Math.max(0, padding)) + text; + private static int configuredLeftMargin() { + int margin = ConfigManager.getPrinterLeftMarginDots(); + return margin >= 0 && margin < ROLL_WIDTH_DOTS ? margin : 0; } - /** Truncate text to fit within specified width */ - private static String truncate(String text, int maxLength) { - if (text == null) return ""; - if (text.length() <= maxLength) return text; - return text.substring(0, maxLength - 3) + "..."; + private static int configuredCharactersPerLine() { + int width = ConfigManager.getPrinterCharactersPerLine(); + int maximumCharacters = configuredPrintableWidth() / NORMAL_FONT_CHARACTER_WIDTH_DOTS; + if (width <= 0) return maximumCharacters; + return Math.min(width, Math.min(CONTENT_CHARACTERS_PER_LINE, maximumCharacters)); } - /** Format date time string for thermal printer (condensed) */ - private static String formatDateTime(String formattedTime) { - // Input format: "dd-MM-yyyy HH:mm:ss" - // Output: "dd/MM HH:mm" - if (formattedTime == null || formattedTime.length() < 19) return formattedTime; - String date = formattedTime.substring(0, 10).replace("-", "/"); - String time = formattedTime.substring(12, 17); // HH:mm - return date + " " + time; + private static boolean isVirtualPrinter(String name) { + if (name == null) return false; + String lower = name.toLowerCase(Locale.ROOT); + for (String keyword : VIRTUAL_PRINTER_KEYWORDS) if (lower.contains(keyword)) return true; + return false; + } + + private static String listPrinterNames(PrintService[] printers) { + return printers == null || printers.length == 0 + ? "(none found)" + : Stream.of(printers).map(PrintService::getName).collect(Collectors.joining(", ")); } } diff --git a/src/main/tenants/honnavar/Config.properties b/src/main/tenants/honnavar/Config.properties index 3e82da4..6f93c1d 100644 --- a/src/main/tenants/honnavar/Config.properties +++ b/src/main/tenants/honnavar/Config.properties @@ -68,3 +68,10 @@ dev.mode=False # virtual/PDF printer (e.g. "Microsoft Print to PDF") rather than # silently generating a file. printer.name= +# TVSE RP4230, 80 mm paper, 203 dpi, with a 10 mm horizontal offset. +# Usable content width = 80 mm - 10 mm = 70 mm = 560 dots. These ESC/POS +# values keep the ticket and logo within that printable area. Raw ESC/POS +# bypasses the Windows setting, so the 10 mm left margin is sent explicitly. +printer.left.margin.dots=200 +printer.width.dots=560 +printer.characters.per.line=35 diff --git a/src/main/tenants/kasarkod/Config.properties b/src/main/tenants/kasarkod/Config.properties index c3a9d88..fac9095 100644 --- a/src/main/tenants/kasarkod/Config.properties +++ b/src/main/tenants/kasarkod/Config.properties @@ -31,6 +31,12 @@ dev.mode=False # PRINTER # ---------------------------------------------------------------------- printer.name= +# TVSE RP4230, 80 mm paper, 203 dpi, with a 10 mm horizontal offset. +# Usable content width = 80 mm - 10 mm = 70 mm = 560 dots. Raw ESC/POS +# bypasses the Windows setting, so the 10 mm left margin is sent explicitly. +printer.left.margin.dots=80 +printer.width.dots=560 +printer.characters.per.line=35 # ---------------------------------------------------------------------- # UPDATE NETWORK TIMEOUT From ed663c1c0b43cb75ceb0512f1c96200349dee11d Mon Sep 17 00:00:00 2001 From: santosh Date: Wed, 26 Aug 2026 18:10:43 +0530 Subject: [PATCH 2/3] spotless --- .../util/ThermalPrinterManager.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/boatticket/util/ThermalPrinterManager.java b/src/main/java/com/boatticket/util/ThermalPrinterManager.java index ddb5fbf..057e28b 100644 --- a/src/main/java/com/boatticket/util/ThermalPrinterManager.java +++ b/src/main/java/com/boatticket/util/ThermalPrinterManager.java @@ -197,7 +197,8 @@ private static String formatTicketContent(Ticket ticket) { .append("\n"); sb.append(rule(lineWidth)); sb.append( - formatLine(" DATE & TIME:", formatDateTime(ticket.getFormattedBookingTime()), lineWidth)) + formatLine( + " DATE & TIME:", formatDateTime(ticket.getFormattedBookingTime()), lineWidth)) .append("\n\n"); sb.append(formatLine(" Persons:", String.valueOf(ticket.getNumberOfPeople()), lineWidth)) .append("\n\n"); @@ -210,7 +211,8 @@ private static String formatTicketContent(Ticket ticket) { sb.append(formatLine(" Boat HR:", ticket.getBoatOwner().getBoatHRNumber(), lineWidth)) .append("\n"); sb.append( - formatLine(" Capacity:", ticket.getBoatOwner().getCapacity() + " persons", lineWidth)) + formatLine( + " Capacity:", ticket.getBoatOwner().getCapacity() + " persons", lineWidth)) .append("\n"); } sb.append(bold(" RIDE DETAILS")).append("\n").append(rule(lineWidth)); @@ -219,27 +221,35 @@ private static String formatTicketContent(Ticket ticket) { " Duration:", ticket.getRideDurationInHour() + " HOUR(S)", lineWidth)) .append("\n"); if (ticket.isLifeJacketRequired()) - sb.append(formatLine(" Life Jackets:", String.valueOf(ticket.getLifeJacketCount()), lineWidth)) + sb.append( + formatLine( + " Life Jackets:", String.valueOf(ticket.getLifeJacketCount()), lineWidth)) .append("\n"); if (ticket.isParkingRequired()) { String vehicle = ticket.getVehicleTypeDisplay(); if (ticket.getVehicleNumber() != null && !ticket.getVehicleNumber().isBlank()) vehicle += " | " + ticket.getVehicleNumber().toUpperCase(Locale.ROOT); - sb.append(formatLineWithBoldValue(" Vehicle Parking:", vehicle, lineWidth)).append("\n"); + sb.append(formatLineWithBoldValue(" Vehicle Parking:", vehicle, lineWidth)) + .append("\n"); } sb.append("\n").append(bold(" FEE BREAKDOWN")).append("\n").append(rule(lineWidth)); - sb.append(formatLineWithBoldValue(" Boat Ride Fee:", "Rs " + ticket.getBoatRideFee(), lineWidth)) + sb.append( + formatLineWithBoldValue( + " Boat Ride Fee:", "Rs " + ticket.getBoatRideFee(), lineWidth)) .append("\n"); if (ticket.isLifeJacketRequired()) sb.append(formatLine(" Life Jacket Fee:", "Rs " + ticket.getLifeJacketFee(), lineWidth)) .append("\n"); if (ticket.isParkingRequired()) - sb.append(formatLineWithBoldValue(" Parking Fee:", "Rs " + ticket.getParkingFee(), lineWidth)) + sb.append( + formatLineWithBoldValue( + " Parking Fee:", "Rs " + ticket.getParkingFee(), lineWidth)) .append("\n"); if (ticket.getCounterPayable() > 0) { sb.append("\n").append(bold(centerText(" COUNTER PAYMENT", lineWidth))).append("\n"); sb.append(bold(centerText(" PAYABLE AT COUNTER", lineWidth))).append("\n"); - sb.append(bold(centerText(" Rs " + ticket.getCounterPayable(), lineWidth))).append("\n"); + sb.append(bold(centerText(" Rs " + ticket.getCounterPayable(), lineWidth))) + .append("\n"); sb.append(centerText(" (PARKING FEE ONLY)", lineWidth)).append("\n"); } return sb.append("\n") From 20210e7f7ba40a0152da6b32c9a9e3e2d6293d1c Mon Sep 17 00:00:00 2001 From: santosh v bhat Date: Wed, 26 Aug 2026 18:12:17 +0530 Subject: [PATCH 3/3] Remove win-console option from publish-release.yaml --- .github/workflows/publish-release.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-release.yaml b/.github/workflows/publish-release.yaml index 0af6002..1f7dd05 100644 --- a/.github/workflows/publish-release.yaml +++ b/.github/workflows/publish-release.yaml @@ -166,7 +166,6 @@ jobs: --icon src/main/resources/BoatBookingSystem.ico ` --dest dist ` --win-dir-chooser ` - --win-console ` --win-menu ` --win-menu-group "Boat Booking System - ${{ steps.release.outputs.tenantDisplayName }}" ` --win-shortcut `