From 66d731efcb3c2f997439739bacacfce8deb394ee Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Sat, 7 Nov 2026 06:14:49 -0500 Subject: [PATCH 1/5] feat(print): add 76mm impact printer support --- docs/architecture/printing.md | 41 +- docs/guides/testing-printers.md | 22 +- .../settings/PrintersSettingsTab.tsx | 104 ++- frontend/src/hooks/usePrinter.ts | 1 + frontend/src/lib/i18n/messages/ar.json | 15 +- frontend/src/lib/i18n/messages/bn.json | 15 +- frontend/src/lib/i18n/messages/de.json | 15 +- frontend/src/lib/i18n/messages/en.json | 15 +- frontend/src/lib/i18n/messages/es.json | 15 +- frontend/src/lib/i18n/messages/fa.json | 15 +- frontend/src/lib/i18n/messages/fil.json | 15 +- frontend/src/lib/i18n/messages/fr.json | 15 +- frontend/src/lib/i18n/messages/ha.json | 15 +- frontend/src/lib/i18n/messages/hi.json | 15 +- frontend/src/lib/i18n/messages/id.json | 15 +- frontend/src/lib/i18n/messages/it.json | 15 +- frontend/src/lib/i18n/messages/ja.json | 15 +- frontend/src/lib/i18n/messages/jv.json | 15 +- frontend/src/lib/i18n/messages/ko.json | 15 +- frontend/src/lib/i18n/messages/mr.json | 15 +- frontend/src/lib/i18n/messages/ne.json | 15 +- frontend/src/lib/i18n/messages/nl.json | 15 +- frontend/src/lib/i18n/messages/pt.json | 15 +- frontend/src/lib/i18n/messages/ru.json | 15 +- frontend/src/lib/i18n/messages/sq.json | 15 +- frontend/src/lib/i18n/messages/sw.json | 15 +- frontend/src/lib/i18n/messages/th.json | 15 +- frontend/src/lib/i18n/messages/tr.json | 15 +- frontend/src/lib/i18n/messages/ur.json | 15 +- frontend/src/lib/i18n/messages/vi.json | 15 +- frontend/src/lib/i18n/messages/zh-tw.json | 15 +- frontend/src/lib/i18n/messages/zh.json | 15 +- frontend/src/lib/printer/receipt-encoder.ts | 12 +- frontend/src/types/electron.d.ts | 2 + main/db.ts | 10 + main/printers/document-classic.ts | 6 +- main/printers/document-compact.ts | 6 +- main/printers/profiles.ts | 69 +- main/printers/thermal.ts | 250 ++++--- main/routes/printers.ts | 35 +- package.json | 3 +- shared/print/thermal-capabilities.ts | 33 + tests/impact-printer-support.test.ts | 693 ++++++++++++++++++ tests/raster-printing.test.ts | 6 +- tests/receipt-column-oracle.test.ts | 1 + tests/upgrade-path.test.ts | 61 ++ 46 files changed, 1615 insertions(+), 160 deletions(-) create mode 100644 tests/impact-printer-support.test.ts diff --git a/docs/architecture/printing.md b/docs/architecture/printing.md index 2c090664d..8aa6b7a66 100644 --- a/docs/architecture/printing.md +++ b/docs/architecture/printing.md @@ -160,7 +160,7 @@ four whitespace-separated tokens read as a sentence. | WebUSB | `frontend/src/lib/printer/PrinterService.ts` | Encoder output, sent over the browser's WebUSB API. | | Delivery slip | `frontend/src/lib/printer/delivery-slip-encoder.ts` and `delivery-slip-web-print.ts` | A `DeliverySlipDocument`'s contact facts, rendered as bytes or HTML. | | Tax bill | `frontend/src/lib/printer/tax-bill-encoder.ts` | A raw `Bill` and `Tenant`, not a document. | -| Payment ticket slip | `printPaymentTicket` in `main/printers/thermal.ts` | Order metadata, item lines, balance due, and ESC/POS QR code (`FLOCAFE-TICKET:`) for multi-counter ordering. | +| Payment ticket slip | `printPaymentTicket` in `main/printers/thermal.ts` | Order metadata, item lines, balance due, centered `FLOCAFE-TICKET:` text, and (when `raster.enabled` is true) an ESC/POS QR code for multi-counter ordering. | The tax bill encoder and payment ticket slip generator are exceptions to "renderers consume documents": the tax bill encoder reads `Bill` directly for per-component tax detail, while @@ -190,10 +190,12 @@ cropped content to at most 576x240 dots (384x240 for 58 mm), normalize grayscale 384- and 576-dot printers. `receipt_logo` stores an adaptively compressed data URL (`<= 200 KB` target, `250 KB` cap) while `receipt_logo_raster` stores only the packed variants (`originalDataUrl` is left empty in new payloads, with `parseReceiptLogoData()` accepting both empty and populated -values). Browser bills center the image above the business header. The classic and compact ESC/POS -renderers and frontend receipt encoders select a raster variant for the paper width and emit it with -`GS v 0` via `{RAW_BYTES:...}`, which the mixed-mode text rasterizer skips so non-Latin headers -never overwrite the logo bitmap. Kitchen tickets do not use the bill logo setting. +values). Browser bills center the image above the business header. When `rasterCapabilityEnabled()` +is true, the classic and compact ESC/POS renderers and frontend receipt encoders select a raster +variant for the paper width and emit it with `GS v 0` via `{RAW_BYTES:...}`, which the mixed-mode +text rasterizer skips so non-Latin headers never overwrite the logo bitmap. Profiles with +`raster.enabled: false` (such as `escpos-impact-76`) omit `{RAW_BYTES:...}` logo bitmaps. Kitchen +tickets do not use the bill logo setting. ## Transports @@ -212,23 +214,34 @@ so it must not embed device names or user data. ## Printer profiles -`main/printers/profiles.ts` ships four profiles: +`main/printers/profiles.ts` ships five profiles: | Id | Make and model | Paper | Font A columns | | --- | --- | --- | --- | | `xprinter-xp-v320m-v330m` | Xprinter XP-V320M / XP-V330M | 72 mm, `cols-48` | 48 | +| `escpos-impact-76` | Epson / Bixolon / Star 76mm ESC/POS Impact (TM-U220 / SRP-275) | 64 mm, `cols-32` | 32 | | `epson-tm-series` | Epson TM Series ESC/POS | `cols-48` | 48 | | `generic-escpos-80` | Generic ESC/POS 80 mm | `cols-42` | 42 | | `generic-escpos-58` | Generic ESC/POS 58 mm | `cols-32` | 32 | -All four declare `raster.enabled: true` with modes `mixed` and `whole-receipt`, at 576 dots of -width except the 58 mm profile, which declares 384. - -`resolvePrinterProfile()` resolves in three steps: an explicit `profile_id` if it names a known -profile, then a substring match against each profile's make, model, and aliases, then the paper -width, which selects `generic-escpos-58` for a width starting `58mm` and `generic-escpos-80` -otherwise. A name that matches nothing does not produce "generic for everything": it produces one -of the two generic profiles, chosen by paper width. +The four thermal profiles declare `raster.enabled: true` with modes `mixed` and `whole-receipt`, at +576 dots of width except the 58 mm profile, which declares 384. `escpos-impact-76` targets 76 mm +ribbon impact printers (`TM-U220`, `SRP-275`, `XP-76`) with `raster.enabled: false` (`widthDots: 0`, +`modes: []`), so bitmap logos (`GS v 0`), `GS ( k` QR codes, and text-to-bitmap raster bands are +never sent to an impact head. + +`resolvePrinterProfile()` resolves in three steps: an explicit `profile_id` persisted on `printers` +(exposed as `configured_profile_id` alongside the resolved `profile_id` on `/api/printers` +responses) if it names a known profile, then a substring match against each profile's make, model, +and aliases, then the paper width, which selects `generic-escpos-58` for a width starting `58mm` and +`generic-escpos-80` otherwise. A name that matches nothing does not produce "generic for +everything": it produces one of the two generic profiles, chosen by paper width. OS printer +detection (`detectConnectedPrinters()`) also annotates each queue with +`compatibility: 'escpos_thermal' | 'escpos_impact' | 'driver_only'` via +`classifyDetectedPrinterCompatibility()`, keeping driver-only queues (such as Star `TSP100` / +`TSP143` / `SP700` / `mC-Print`, Epson `EcoTank` / `WorkForce` / `Stylus` inkjets, label printers, +and PDF queues) visible in Settings with a browser-print switch instead of misclassifying them as +raw ESC/POS thermal printers. `capabilitiesForPrinter()` then caps the profile's raster width to the configured paper width when that width is narrower than the profile's hardware, so a 42-column configuration on 80 mm hardware diff --git a/docs/guides/testing-printers.md b/docs/guides/testing-printers.md index 3ceddbd0d..7f656a93d 100644 --- a/docs/guides/testing-printers.md +++ b/docs/guides/testing-printers.md @@ -21,22 +21,24 @@ npm run test:raster ## Which profile applies to a printer -`main/printers/profiles.ts` ships four profiles: `xprinter-xp-v320m-v330m`, `epson-tm-series`, -`generic-escpos-80`, and `generic-escpos-58`. A printer with no matching profile is not -unsupported; it runs on one of the two generic profiles, chosen by paper width. +`main/printers/profiles.ts` ships five profiles: `xprinter-xp-v320m-v330m`, `escpos-impact-76`, +`epson-tm-series`, `generic-escpos-80`, and `generic-escpos-58`. A printer with no matching profile +is not unsupported; it runs on one of the two generic profiles, chosen by paper width, or on an +explicit `profile_id` selected in **Settings -> Printers**. To determine which profile a printer actually resolves to: 1. In FloCafe, open **Settings -> Printers**, add the printer, and save it. -2. Read the resolved `profile_id` from the stored printer row, or call the profile listing endpoint - that the printer settings page uses. +2. Read the resolved `profile_id` (and optional `configured_profile_id`) from the stored printer + row, or call the profile listing endpoint that the printer settings page uses. 3. If it resolved to a generic profile but you expected a specific one, the name did not match any - alias. `matchSupportedPrinterProfile()` substring-matches the lower-cased make, model, and alias - list, so the stored name has to contain one of the profile's tokens. Recording the name string - you entered is part of the report. + alias and no explicit profile was selected. `matchSupportedPrinterProfile()` substring-matches + the lower-cased make, model, and alias list, so an auto-detected name has to contain one of the + profile's tokens. Recording the name string you entered is part of the report. -Every profile declares raster capability. The 58 mm profile declares 384 dots of width; the other -three declare 576. +The four thermal profiles declare raster capability (`384` dots on `generic-escpos-58`, `576` dots +on the three 80 mm profiles). `escpos-impact-76` targets 76 mm impact ribbon printers (`cols-32`) +and declares `raster.enabled: false`. ## Test 1: chunked network printing diff --git a/frontend/src/components/settings/PrintersSettingsTab.tsx b/frontend/src/components/settings/PrintersSettingsTab.tsx index 3ea6d4941..1dd3cc5b0 100644 --- a/frontend/src/components/settings/PrintersSettingsTab.tsx +++ b/frontend/src/components/settings/PrintersSettingsTab.tsx @@ -47,6 +47,7 @@ export type HwPrinter = { paper_width: string; text_size?: PrinterTextSize; is_default: number; + configured_profile_id?: string | null; profile_id?: string; profile_name?: string; }; @@ -63,6 +64,7 @@ export type DetectedPrinter = { port?: number; paperWidth?: string; profileId?: string; + compatibility?: 'escpos_thermal' | 'escpos_impact' | 'driver_only'; }; export type PrinterForm = { @@ -72,6 +74,7 @@ export type PrinterForm = { port: string; paper_width: string; text_size: PrinterTextSize; + profile_id: string; }; export type PrintingForm = { @@ -132,6 +135,7 @@ const emptyPrinterForm: PrinterForm = { port: '9100', paper_width: 'cols-42', text_size: 'normal', + profile_id: '', }; export interface PrintersSettingsTabProps { @@ -177,6 +181,7 @@ export function PrintersSettingsTab({ const { refreshHardwarePrinter } = usePrinterStore(); const [printerForm, setPrinterForm] = useState(emptyPrinterForm); + const [paperWidthTouched, setPaperWidthTouched] = useState(false); const [showPrinterForm, setShowPrinterForm] = useState(false); const [editingPrinterId, setEditingPrinterId] = useState(null); const [savingPrinter, setSavingPrinter] = useState(false); @@ -250,6 +255,7 @@ export function PrintersSettingsTab({ name: string; connection_type: 'network' | 'usb'; paper_width: string; + profile_id?: string; ip_address?: string; port?: number; } = { @@ -257,6 +263,9 @@ export function PrintersSettingsTab({ connection_type: p.connectionType === 'network' ? 'network' : 'usb', paper_width: normalizePrinterWidthValue(p.paperWidth), }; + if (p.profileId === 'escpos-impact-76') { + payload.profile_id = p.profileId; + } if (p.connectionType === 'network') { payload.ip_address = p.ipAddress || ''; payload.port = p.port || 9100; @@ -274,6 +283,7 @@ export function PrintersSettingsTab({ const openAddPrinter = () => { setPrinterForm(emptyPrinterForm); + setPaperWidthTouched(false); setEditingPrinterId(null); setShowPrinterForm(true); }; @@ -286,7 +296,9 @@ export function PrintersSettingsTab({ port: String(p.port || 9100), paper_width: normalizePrinterWidthValue(p.paper_width), text_size: p.text_size ?? 'normal', + profile_id: p.configured_profile_id || '', }); + setPaperWidthTouched(false); setEditingPrinterId(p.id); setShowPrinterForm(true); }; @@ -305,6 +317,7 @@ export function PrintersSettingsTab({ port: printerForm.connection_type === 'network' ? Number(printerForm.port) : undefined, paper_width: printerForm.paper_width, text_size: printerForm.text_size, + profile_id: printerForm.profile_id || null, }; if (editingPrinterId) { await api.put(`/printers/${editingPrinterId}`, payload); @@ -435,6 +448,8 @@ export function PrintersSettingsTab({ (h) => h.name.toLowerCase() === p.name.toLowerCase(), ); const isAdding = addingDetectedName === p.name; + const isDriverOnly = p.compatibility === 'driver_only'; + const isImpact = p.compatibility === 'escpos_impact'; const dotColor = p.status === 'idle' ? 'bg-green-500' @@ -460,7 +475,7 @@ export function PrintersSettingsTab({ )}
-
+
{p.name} @@ -468,6 +483,17 @@ export function PrintersSettingsTab({ {statusLabel} + {isImpact && ( + + {t('printerCompatibilityImpact')} + + )} + {isDriverOnly && ( + + + {t('printerCompatibilityDriverOnly')} + + )}

{p.make !== 'Unknown' ? `${p.make} ${p.model}` : p.model} @@ -482,11 +508,35 @@ export function PrintersSettingsTab({ ) : ( '' )} - {p.paperWidth ? ` · ${printWidthLabel(p.paperWidth)}` : ''} - {p.profileId ? ` · ${t('printerSupportedProfile')}` : ''} + {!isDriverOnly && p.paperWidth + ? ` · ${printWidthLabel(p.paperWidth)}` + : ''} + {!isDriverOnly && p.profileId + ? ` · ${t('printerSupportedProfile')}` + : ''}

+ {isDriverOnly && ( +

+ {t('printerDriverOnlyWarning')} +

+ )}
- {alreadyAdded ? ( + {isDriverOnly ? ( + + ) : alreadyAdded ? ( {' '} {t('printerAdded')} @@ -692,13 +742,57 @@ export function PrintersSettingsTab({
)} +
+ + + {printerForm.profile_id === 'escpos-impact-76' && ( +

+ {t('printerProfileImpactHint')} +

+ )} +
+