diff --git a/packages/printer/src/BasePrinter.ts b/packages/printer/src/BasePrinter.ts index 3a82845..af52760 100644 --- a/packages/printer/src/BasePrinter.ts +++ b/packages/printer/src/BasePrinter.ts @@ -186,6 +186,14 @@ export abstract class BasePrinter implements Printer { return this; } + line(width: number, character: string = '-'): this { + for (let i = 0; i < width; i++) { + this.text(character); + } + + return this; + } + raw(data: Uint8Array): this { this.cmds.push({ name: 'raw', diff --git a/packages/printer/src/Printer.ts b/packages/printer/src/Printer.ts index 59b95a0..76b4802 100644 --- a/packages/printer/src/Printer.ts +++ b/packages/printer/src/Printer.ts @@ -86,6 +86,7 @@ export interface Printer { setAlign(align: Align): this; invert(enabled: boolean): this; text(data: string): this; + line(width: number, character?: string): this; raw(data: Uint8Array): this; newLine(): this; cut(partial?: boolean): this; @@ -97,4 +98,10 @@ export interface Printer { getData(): Uint8Array; clear(): this; debug(): this; + + /** + * In the Row component, the align prop of each text element should be set according to its position under the Zebra, but in ESC/POS it must always use left alignment. + * To handle this difference, this method is added to convert the position values accordingly. + */ + convertTextAlignInRow?(align: Align): Align; } diff --git a/packages/printer/src/ZebraPrinter.ts b/packages/printer/src/ZebraPrinter.ts new file mode 100644 index 0000000..fd01709 --- /dev/null +++ b/packages/printer/src/ZebraPrinter.ts @@ -0,0 +1,309 @@ +import type { Image, ImageToRasterOptions } from '@react-thermal-printer/image'; +import type { CharacterSet } from './CharacterSet.js'; +import type { + Align, + BarcodeOptions, + BarcodeType, + CashDrawerPin, + Printer, + QRCodeOptions, + TextFont, + TextSize, + TextUnderline, +} from './Printer.js'; + +const DEFAULT_DPI = 203; +const DEFAULT_WIDTH_DOTS = DEFAULT_DPI * 2; // 2" wide label (406 dots @203dpi) +const DEFAULT_LINE_HEIGHT_RATE_PER_DPI = 0.15; +const BASE_CHAR_SIZE_DOTS = 20; + +interface Cmd { + name: string; + args?: unknown[]; + data: string; +} + +export interface ZebraPrinterOptions { + /** + * Printer resolution in dots per inch. + * @default 203 + */ + dpi?: number; + /** + * Usable printable width in dots. + * @default 406 (~2" @203dpi) + */ + widthDots?: number; +} + +export class ZebraPrinter implements Printer { + private readonly dpi: number; + private readonly coordinate: Coordinate; + + private font: TextFont = 'A'; + private bold: boolean = false; + private align: Align = 'left'; + private charWidth: number = this.baseCharSize(); + private charHeight: number = this.baseCharSize(); + + private cmds: Cmd[] = []; + private labelOpen = false; + + public constructor({ widthDots, dpi }: ZebraPrinterOptions = {}) { + this.dpi = dpi ?? DEFAULT_DPI; + this.coordinate = new Coordinate(this.dpi, widthDots ?? DEFAULT_WIDTH_DOTS); + } + + setCharacterSet(set: CharacterSet): this { + const ci = (() => { + switch (set) { + case 'pc437_usa': + return '0'; + case 'japan': + return '12'; + default: + return '28'; + } + })(); + const commands = [`^CI${ci}`, `^CFJ,50`].join('\n'); + + this.append('setCharacterSet', commands, [set]); + return this; + } + + setTextFont(font: TextFont): this { + this.font = font; + return this; + } + + setTextBold(bold: boolean): this { + this.bold = bold; + return this; + } + + setTextSize(width: TextSize, height: TextSize): this { + this.charWidth = Math.floor(this.baseCharSize() * width); + this.charHeight = Math.floor(this.baseCharSize() * height); + this.coordinate.setLineHeight(this.charHeight * (1 + DEFAULT_LINE_HEIGHT_RATE_PER_DPI)); + + return this; + } + + setTextUnderline(_underline: TextUnderline): this { + throw new Error('ZebraPrinter#setTextUnderline is not implemented yet.'); + } + + setTextNormal(): this { + this.bold = false; + this.font = 'A'; + this.charWidth = this.baseCharSize(); + this.charHeight = this.baseCharSize(); + this.coordinate.resetLineHeight(); + + return this; + } + + setAlign(align: Align): this { + this.align = align; + return this; + } + + invert(_enabled: boolean): this { + // TODO: implement barcode method + + // resetPrinter utils calls this method for cleanup, but zebra printers do not support + // label inversion in the same way, so treat this as a no-op instead of throwing an error. + return this; + } + + text(data: string): this { + const x = this.coordinate.getX(); + const y = this.coordinate.getY(); + const font = (() => { + switch (this.font) { + case 'A': + return '6'; + case 'B': + return '5'; + case 'C': + return '4'; + case 'D': + return '3'; + case 'E': + return '2'; + case 'special-A': + return '1'; + case 'special-B': + return '0'; + } + })(); + + const fontCmd = `^A${font},${this.charHeight},${this.charWidth}`; + const positionCmd = `^FB${this.coordinate.getUsableWidth()},1,0,${convertAlign(this.align)},0`; + const commands = [[fontCmd, `^FO${x},${y}`, positionCmd, `^FD${data}^FS`].join('')]; + + if (this.bold) { + commands.push([fontCmd, `^FO${x + 1},${y}`, positionCmd, `^FD${data}^FS`].join('')); + } + + this.append('text', commands.join('\n'), [data]); + return this; + } + + line(width: number, character: string = '-'): this { + const count = Math.floor(width / this.baseCharSize()) * 3; + this.text(character.repeat(count)); + return this; + } + + raw(data: Uint8Array): this { + const decoder = new TextDecoder('ascii', { fatal: false }); + this.append('raw', decoder.decode(data), [data]); + return this; + } + + newLine(): this { + this.coordinate.newLine(); + return this; + } + + cut(_partial?: boolean): this { + throw new Error('ZPL-based printers do not provide cut method.'); + } + + image(_image: Image, _options?: ImageToRasterOptions): this { + // TODO: implement image method + throw new Error('ZebraPrinter#image is not implemented yet.'); + } + + qrcode(_data: string, _options?: QRCodeOptions): this { + // TODO: implement qrcode method + throw new Error('ZebraPrinter#qrcode is not implemented yet.'); + } + + barcode(_data: string, _type: BarcodeType, _options?: BarcodeOptions): this { + // TODO: implement barcode method + throw new Error('ZebraPrinter#barcode is not implemented yet.'); + } + + cashdraw(_pin: CashDrawerPin): this { + throw new Error('ZPL-based printers do not provide cashdraw method.'); + } + + initialize(): this { + if (!this.cmds.some(cmd => cmd.name === 'labelEnd')) { + this.append('labelEnd', '^XZ'); + } + + this.labelOpen = false; + return this; + } + + getData(): Uint8Array { + if (!this.cmds.some(cmd => cmd.name === 'labelEnd') && this.labelOpen) { + this.append('labelEnd', '^XZ'); + this.labelOpen = false; + } + + const payload = this.cmds.map(cmd => cmd.data).join('\n'); + return new TextEncoder().encode(payload); + } + + clear(): this { + this.cmds = []; + this.labelOpen = false; + + this.font = 'A'; + this.bold = false; + this.align = 'left'; + + this.coordinate.reset(); + + return this; + } + + debug(): this { + console.debug(this.cmds); + return this; + } + + convertTextAlignInRow(align: Align): Align { + return align; + } + + private append(name: string, command: string, args?: unknown[]) { + if (!this.labelOpen) { + this.cmds.push({ name: 'labelStart', data: '^XA' }); + this.labelOpen = true; + } + + this.cmds.push({ name, args, data: command }); + } + + private baseCharSize(): number { + const dpiScale = this.dpi / DEFAULT_DPI; + return Math.floor(BASE_CHAR_SIZE_DOTS * dpiScale); + } +} + +class Coordinate { + private x: number; + private y: number; + private dpi: number; + private dotWidth: number; + private lineHeight: number; + private home: { x: number; y: number }; + + public constructor(dpi: number, dotWidth: number) { + this.dpi = dpi; + this.dotWidth = dotWidth; + this.lineHeight = Math.floor(dpi * DEFAULT_LINE_HEIGHT_RATE_PER_DPI); + + const padding = Math.floor(dotWidth * 0.05); + this.home = { x: padding, y: padding }; + this.x = this.home.x; + this.y = this.home.y; + } + + getX() { + return this.x; + } + + getY() { + return this.y; + } + + getUsableWidth() { + return this.dotWidth - this.home.x * 2; + } + + newLine(lineHeight?: number) { + this.x = this.home.x; + this.y += lineHeight ?? this.lineHeight; + } + + setLineHeight(lineHeight: number) { + this.lineHeight = lineHeight; + } + + resetLineHeight() { + this.lineHeight = Math.floor(this.dpi * DEFAULT_LINE_HEIGHT_RATE_PER_DPI); + } + + reset() { + this.x = this.home.x; + this.y = this.home.y; + this.resetLineHeight(); + } +} + +function convertAlign(align: Align) { + switch (align) { + case 'center': + return 'C'; + case 'right': + return 'R'; + case 'left': + return 'L'; + } +} diff --git a/packages/printer/src/index.ts b/packages/printer/src/index.ts index 8000722..b4eaf0b 100644 --- a/packages/printer/src/index.ts +++ b/packages/printer/src/index.ts @@ -2,12 +2,14 @@ import type { CharacterSet } from './CharacterSet.js'; import { EpsonPrinter } from './EpsonPrinter.js'; import type { Printer } from './Printer.js'; import { StarPrinter } from './StarPrinter.js'; +import { ZebraPrinter } from './ZebraPrinter.js'; -export type PrinterType = 'epson' | 'star'; +export type PrinterType = 'epson' | 'star' | 'zebra'; export type PrinterEncoder = (text: string, characterSet: CharacterSet) => Uint8Array; export interface PrinterOptions { type: PrinterType; + width?: number; characterSet?: CharacterSet; /** * Encoder for text @@ -22,6 +24,8 @@ export function getPrinter({ type, ...options }: PrinterOptions): Printer { return new EpsonPrinter(options); case 'star': return new StarPrinter(options); + case 'zebra': + return new ZebraPrinter({ widthDots: options.width }); } } @@ -33,3 +37,4 @@ export { EpsonPrinter } from './EpsonPrinter.js'; export { decode, encode } from './iconv.js'; export * from './Printer.js'; export { StarPrinter } from './StarPrinter.js'; +export { ZebraPrinter } from './ZebraPrinter.js'; diff --git a/packages/react-thermal-printer/src/components/Line.tsx b/packages/react-thermal-printer/src/components/Line.tsx index 1ed1a00..1c1f6ff 100644 --- a/packages/react-thermal-printer/src/components/Line.tsx +++ b/packages/react-thermal-printer/src/components/Line.tsx @@ -19,8 +19,6 @@ export const Line: Printable = ({ character, className, ...props }: Props Line.print = (elem, { printer, width }) => { const { character = '-' } = elem.props; - for (let i = 0; i < width; i++) { - printer.text(character); - } + printer.line(width, character); printer.newLine(); }; diff --git a/packages/react-thermal-printer/src/components/Row.tsx b/packages/react-thermal-printer/src/components/Row.tsx index 165c8d9..db7cbed 100644 --- a/packages/react-thermal-printer/src/components/Row.tsx +++ b/packages/react-thermal-printer/src/components/Row.tsx @@ -1,4 +1,4 @@ -import type { Printer } from '@react-thermal-printer/printer'; +import type { Align, Printer } from '@react-thermal-printer/printer'; import { type ComponentProps, cloneElement, type ReactElement } from 'react'; import type { ExtendHTMLProps } from '../types/HTMLProps.js'; import type { Printable } from '../types/Printable.js'; @@ -89,7 +89,7 @@ Row.print = (elem, context) => { const rightLine = rightLines[i]; if (leftLine != null) { - Text.print(lineText(leftElem, leftLine), context); + Text.print(lineText(leftElem, leftLine, printer.convertTextAlignInRow?.('left')), context); resetPrinter(printer); } else { space(printer, leftLineWidth); @@ -98,7 +98,7 @@ Row.print = (elem, context) => { if (centerElem != null) { space(printer, gap); if (centerLine != null) { - Text.print(lineText(centerElem, centerLine), context); + Text.print(lineText(centerElem, centerLine, printer.convertTextAlignInRow?.('center')), context); resetPrinter(printer); } else { space(printer, centerLineWidth); @@ -107,7 +107,7 @@ Row.print = (elem, context) => { space(printer, gap); if (rightLine != null) { - Text.print(lineText(rightElem, rightLine), context); + Text.print(lineText(rightElem, rightLine, printer.convertTextAlignInRow?.('right')), context); resetPrinter(printer); } else { space(printer, rightLineWidth); @@ -121,10 +121,6 @@ function space(printer: Printer, length: number) { printer.text(' '.repeat(safeLength)); } -function lineText(textElem: ReactElement>, text: string) { - return cloneElement(textElem, { - align: 'left', // align cannot be affect inside the - inline: true, - children: text, - }); +function lineText(textElem: ReactElement>, text: string, align: Align = 'left') { + return cloneElement(textElem, { align, inline: true, children: text }); } diff --git a/packages/react-thermal-printer/src/render.ts b/packages/react-thermal-printer/src/render.ts index cbe5723..e5664b3 100644 --- a/packages/react-thermal-printer/src/render.ts +++ b/packages/react-thermal-printer/src/render.ts @@ -37,7 +37,7 @@ export interface RenderOptions { */ export async function render(elem: ReactElement, options?: RenderOptions): Promise { const { type, characterSet, width = 48, encoder, initialize = true, debug = false } = elem.props; - const printer = getPrinter({ type, characterSet, encoder }); + const printer = getPrinter({ type, width, characterSet, encoder }); if (characterSet != null) { printer.setCharacterSet(characterSet);