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 ` 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..057e28b 100644 --- a/src/main/java/com/boatticket/util/ThermalPrinterManager.java +++ b/src/main/java/com/boatticket/util/ThermalPrinterManager.java @@ -2,314 +2,335 @@ 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=
- * 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