PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.21
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.21
1.10.24 1.10.23 1.10.22 1.10.21 1.10.20 1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 All 168 releases
woocommerce-pos / includes / Templates / Thermal / Escpos_Thermal_Emitter.php

Escpos_Thermal_Emitter.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.21, at includes/Templates/Thermal/Escpos_Thermal_Emitter.php

658 lines 18.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ESC/POS Thermal Emitter Class.
4 *
5 * Emits raw ESC/POS command bytes from a thermal AST (produced by
6 * Thermal_Markup_Parser). This is the ONLY ESC/POS emitter in the project —
7 * there is no JavaScript counterpart to keep in step with, so this class is the
8 * reference for what a template actually prints. Parity with the npm escpos
9 * encoder is defined as matching command sequences and visual text layout, NOT
10 * byte-identity.
11 *
12 * This is the template-driven counterpart to
13 * `WCPOS\WooCommercePOS\Templates\Adapters\Escpos_Output_Adapter`, which emits a
14 * fixed, non-template layout from canonical receipt data.
15 *
16 * Deliberate deviations from the npm escpos encoder:
17 * - Double rules (`<line style="double"/>`) are emitted as ASCII `=` repeated
18 * across the paper width instead of the CP437 box-drawing byte 0xCD. This
19 * keeps the output codepage-independent so it renders correctly regardless of
20 * the printer's active character table.
21 * - Images (`<image>`) are thresholded to 1-bit dots by Thermal_Bitmap and sent
22 * as a `GS v 0` raster bit image.
23 * - CP932 / Japanese kanji-mode byte sequences are out of scope; text is
24 * emitted as plain UTF-8.
25 *
26 * @author Paul Kilmurray <[email protected]>
27 *
28 * @see http://wcpos.com
29 * @package WCPOS\WooCommercePOS
30 */
31
32 namespace WCPOS\WooCommercePOS\Templates\Thermal;
33
34 use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;
35
36 /**
37 * Escpos_Thermal_Emitter class.
38 */
39 class Escpos_Thermal_Emitter {
40
41 use Thermal_Emitter_Support;
42
43 /**
44 * Render options.
45 *
46 * @var array
47 */
48 private $options = array();
49
50 /**
51 * Accumulated output bytes.
52 *
53 * @var string
54 */
55 private $buffer = '';
56
57 /**
58 * Per-job text metrics and applied size stack.
59 *
60 * @var Thermal_Text_Layout
61 */
62 private $layout;
63
64 /**
65 * The current alignment mode (left|center|right).
66 *
67 * @var string
68 */
69 private $align = 'left';
70
71 /**
72 * Whether bold is currently active.
73 *
74 * @var bool
75 */
76 private $bold = false;
77
78 /**
79 * Whether underline is currently active.
80 *
81 * @var bool
82 */
83 private $underline = false;
84
85 /**
86 * Whether invert is currently active.
87 *
88 * @var bool
89 */
90 private $invert = false;
91
92 /**
93 * The active scaled line-spacing height, or 0 when none is active.
94 *
95 * @var int
96 */
97 private $active_scaled_spacing = 0;
98
99 /**
100 * Whether unterminated text is sitting in the printer's line buffer.
101 *
102 * `GS v 0`, `GS k` and `GS ( k` are only executed at the beginning of a line
103 * in standard mode; issued mid-line the printer discards them and reports
104 * nothing. Bare text in a template (`<receipt>Total<image/></receipt>`) parses
105 * to a `raw-text` node, which prints without a terminator, so the emitter has
106 * to know whether a line is open before it sends one of those commands.
107 *
108 * @var bool
109 */
110 private $line_open = false;
111
112 /**
113 * Constructor.
114 *
115 * @param array $options Render options.
116 */
117 public function __construct( array $options = array() ) {
118 $this->options = $options;
119 }
120
121 /**
122 * Emit raw ESC/POS bytes from a thermal AST.
123 *
124 * @param array $ast The thermal AST root (a receipt node).
125 *
126 * @return string The raw ESC/POS bytes.
127 */
128 public function emit( array $ast ): string {
129 $this->buffer = '';
130 $this->align = 'left';
131 $this->bold = false;
132 $this->underline = false;
133 $this->invert = false;
134 $this->active_scaled_spacing = 0;
135 $this->line_open = false;
136
137 $this->layout = new Thermal_Text_Layout( isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
138
139 // ESC @ — initialize the printer (once, at the very start).
140 $this->raw( array( 0x1b, 0x40 ) );
141
142 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
143 $this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );
144
145 return $this->buffer;
146 }
147
148 /**
149 * Emit ESC/POS drawer pulse bytes.
150 *
151 * @param string $connector Drawer connector.
152 */
153 private function emit_drawer_pulse( string $connector ): void {
154 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
155 $pin = 'pin5' === $connector ? 0x01 : 0x00;
156
157 $this->raw( array( 0x1b, 0x70, $pin, 0x19, 0xfa ) );
158 }
159
160 /**
161 * Walk a single AST node.
162 *
163 * @param array $node The AST node.
164 *
165 * @return void
166 */
167 private function walk_node( array $node ): void {
168 $type = isset( $node['type'] ) ? $node['type'] : '';
169
170 switch ( $type ) {
171 case 'raw-text':
172 $this->emit_inline_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
173 break;
174 case 'text':
175 $this->emit_text_line( isset( $node['children'] ) ? $node['children'] : array() );
176 break;
177 case 'bold':
178 $this->emit_bold( $node );
179 break;
180 case 'underline':
181 $this->emit_underline( $node );
182 break;
183 case 'invert':
184 $this->emit_invert( $node );
185 break;
186 case 'size':
187 $this->emit_size( $node );
188 break;
189 case 'align':
190 $this->emit_align( $node );
191 break;
192 case 'row':
193 $this->emit_row( $node );
194 break;
195 case 'line':
196 $this->emit_line( $node );
197 break;
198 case 'barcode':
199 $this->emit_barcode( $node );
200 break;
201 case 'qrcode':
202 $this->emit_qrcode( $node );
203 break;
204 case 'image':
205 $this->emit_image( $node );
206 break;
207 case 'cut':
208 $this->emit_cut( $node );
209 break;
210 case 'feed':
211 $this->emit_feed( $node );
212 break;
213 case 'drawer':
214 $this->emit_drawer_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
215 break;
216 case 'receipt':
217 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
218 break;
219 }
220 }
221
222 /**
223 * Emit inline (styled) text bytes for the current line.
224 *
225 * @param string $value The raw text value.
226 *
227 * @return void
228 */
229 private function emit_inline_text( string $value ): void {
230 $text = Thermal_Text_Layout::normalize_text( $value );
231 if ( '' === $text ) {
232 return;
233 }
234
235 $this->raw_string( $text );
236
237 // The parser preserves a text node verbatim, newlines included, so this
238 // may have ended the line itself — `<receipt>Total\n<image/></receipt>`
239 // leaves the printer at column zero. Reading the state off the bytes just
240 // written keeps close_open_line() from spending a second line feed there.
241 $this->line_open = "\n" !== substr( $text, -1 );
242 }
243
244 /**
245 * Close an open line so a beginning-of-line command can execute.
246 *
247 * @return void
248 */
249 private function close_open_line(): void {
250 if ( $this->line_open ) {
251 $this->newline();
252 }
253 }
254
255 /**
256 * Emit a single printed text line (the children, padding, then a newline).
257 *
258 * @param array $children The child nodes of the text node.
259 *
260 * @return void
261 */
262 private function emit_text_line( array $children ): void {
263 if ( 'left' !== $this->align ) {
264 $plain = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( $children ) );
265 $pad = $this->layout->measure_padding( $this->align, $plain );
266 if ( $pad > 0 ) {
267 $this->raw_string( str_repeat( ' ', $pad ) );
268 }
269 }
270 $this->walk_nodes( $children );
271 $this->newline();
272 }
273
274 /**
275 * Emit a bold-wrapped block.
276 *
277 * @param array $node The bold AST node.
278 *
279 * @return void
280 */
281 private function emit_bold( array $node ): void {
282 $previous = $this->bold;
283 $this->raw( array( 0x1b, 0x45, 0x01 ) );
284 $this->bold = true;
285 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
286 $this->raw( array( 0x1b, 0x45, $previous ? 0x01 : 0x00 ) );
287 $this->bold = $previous;
288 }
289
290 /**
291 * Emit an underline-wrapped block.
292 *
293 * @param array $node The underline AST node.
294 *
295 * @return void
296 */
297 private function emit_underline( array $node ): void {
298 $previous = $this->underline;
299 $this->raw( array( 0x1b, 0x2d, 0x01 ) );
300 $this->underline = true;
301 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
302 $this->raw( array( 0x1b, 0x2d, $previous ? 0x01 : 0x00 ) );
303 $this->underline = $previous;
304 }
305
306 /**
307 * Emit an invert-wrapped block.
308 *
309 * @param array $node The invert AST node.
310 *
311 * @return void
312 */
313 private function emit_invert( array $node ): void {
314 $previous = $this->invert;
315 $this->raw( array( 0x1d, 0x42, 0x01 ) );
316 $this->invert = true;
317 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
318 $this->raw( array( 0x1d, 0x42, $previous ? 0x01 : 0x00 ) );
319 $this->invert = $previous;
320 }
321
322 /**
323 * Emit a size-wrapped block, including scaled line spacing.
324 *
325 * @param array $node The size AST node.
326 *
327 * @return void
328 */
329 private function emit_size( array $node ): void {
330 $this->layout->enter_size(
331 is_numeric( $node['width'] ?? null ) ? (int) $node['width'] : 1,
332 is_numeric( $node['height'] ?? null ) ? (int) $node['height'] : 1
333 );
334 $height = $this->layout->applied_scale()['height'];
335 if ( $height > 1 ) {
336 $this->active_scaled_spacing = max( $this->active_scaled_spacing, $height );
337 $this->raw( array( 0x1b, 0x33, min( 255, $height * 30 ) ) );
338 }
339
340 $this->raw( array( 0x1d, 0x21, $this->size_byte() ) );
341 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
342 $this->layout->leave_size();
343 $this->raw( array( 0x1d, 0x21, $this->size_byte() ) );
344 }
345
346 /**
347 * Encode the applied scale as GS ! (width in the high nibble).
348 *
349 * GS ! accepts 0-7 per nibble (1-8x); Thermal_Bounds::SIZE_MULTIPLIER_MAX
350 * keeps both applied axes within that command range.
351 *
352 * @return int The GS ! parameter byte.
353 */
354 private function size_byte(): int {
355 $scale = $this->layout->applied_scale();
356
357 return ( ( $scale['width'] - 1 ) << 4 ) | ( $scale['height'] - 1 );
358 }
359
360 /**
361 * Emit an alignment-wrapped block.
362 *
363 * @param array $node The align AST node.
364 *
365 * @return void
366 */
367 private function emit_align( array $node ): void {
368 $previous = $this->align;
369 $mode = isset( $node['mode'] ) ? $node['mode'] : 'left';
370 $this->raw( array( 0x1b, 0x61, $this->align_byte( $mode ) ) );
371 $this->align = $mode;
372
373 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
374
375 $this->raw( array( 0x1b, 0x61, $this->align_byte( $previous ) ) );
376 $this->align = $previous;
377 }
378
379 /**
380 * Map an alignment mode to its ESC a parameter byte.
381 *
382 * @param string $mode The alignment mode.
383 *
384 * @return int The ESC a parameter byte.
385 */
386 private function align_byte( string $mode ): int {
387 if ( 'center' === $mode ) {
388 return 0x01;
389 }
390 if ( 'right' === $mode ) {
391 return 0x02;
392 }
393
394 return 0x00;
395 }
396
397 /**
398 * Emit a row as one physical line followed by a newline.
399 *
400 * @param array $node The row AST node.
401 *
402 * @return void
403 */
404 private function emit_row( array $node ): void {
405 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
406 $widths = $this->layout->measure_row_widths( $cols );
407
408 $line = '';
409 foreach ( $cols as $index => $col ) {
410 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
411 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
412 $text = Thermal_Text_Layout::truncate_display( $text, $width );
413 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
414 $align = isset( $col['align'] ) ? $col['align'] : 'left';
415 if ( 'right' === $align ) {
416 $line .= str_repeat( ' ', $pad ) . $text;
417 } else {
418 $line .= $text . str_repeat( ' ', $pad );
419 }
420 }
421
422 $this->raw_string( $line );
423 $this->newline();
424 }
425
426 /**
427 * Emit a horizontal rule line.
428 *
429 * @param array $node The line AST node.
430 *
431 * @return void
432 */
433 private function emit_line( array $node ): void {
434 $style = isset( $node['style'] ) ? $node['style'] : 'single';
435
436 if ( 'dotted' === $style ) {
437 $pattern = '. ';
438 $repeat = (int) ceil( $this->layout->columns() / \strlen( $pattern ) );
439 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->layout->columns() );
440 } elseif ( 'double' === $style ) {
441 $text = str_repeat( '=', $this->layout->columns() );
442 } else {
443 // single and dashed both render as '-' across the width.
444 $text = str_repeat( '-', $this->layout->columns() );
445 }
446
447 $this->raw_string( $text );
448 $this->newline();
449 }
450
451 /**
452 * Emit a 1D barcode using the native `GS k` function-B command.
453 *
454 * The symbology selector and the data encoding both come from
455 * Barcode_Symbology (Epson ESC/POS Command Reference, `GS k`). An ESC/POS
456 * printer handed data its symbology cannot encode prints nothing and reports
457 * no error, so an unencodable value is printed as text instead — never as an
458 * error string, which a cashier would have to read off the receipt.
459 *
460 * @param array $node The barcode AST node.
461 *
462 * @return void
463 */
464 private function emit_barcode( array $node ): void {
465 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
466 if ( '' === trim( $value ) ) {
467 return;
468 }
469
470 // Before the validation branch, not after: GS k only executes at the
471 // beginning of a line, and the rescue below centres its text against the
472 // full paper width, so both outcomes need the line closed first.
473 $this->close_open_line();
474
475 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
476 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
477 $height = max( Thermal_Bounds::BARCODE_HEIGHT_MIN, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
478
479 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
480 $this->raw_string( $this->centered_text( $value, $this->layout->columns() ) );
481 $this->newline();
482
483 return;
484 }
485
486 $this->raw( array( 0x1d, 0x68, $height ) ); // GS h — barcode height.
487 $this->raw( array( 0x1d, 0x77, 0x02 ) ); // GS w — module width.
488 // GS H 2 — HRI below the bars, matching the preview, the PDF and the
489 // raster lane. With HRI off the merchant designs against a receipt that
490 // carries the order number and the printer hands over one that does not.
491 $this->raw( array( 0x1d, 0x48, 0x02 ) );
492
493 $data = Barcode_Symbology::escpos_payload( $type, $value );
494 // GS k m n d1..dn — function B, length-prefixed.
495 $this->raw( array( 0x1d, 0x6b, Barcode_Symbology::escpos_id( $type ), \strlen( $data ) ) );
496 $this->raw_string( $data );
497 }
498
499 /**
500 * Print a template `<image>` (in practice, the store logo).
501 *
502 * `GS v 0 m xL xH yL yH d1..dk` — the raster bit image, whose data layout is
503 * exactly what Thermal_Bitmap produces: row-major, MSB first, a set bit being
504 * a black dot. xL/xH count BYTES per row, not dots, which is why the bitmap
505 * pads its width to a whole byte.
506 *
507 * The image is centred unconditionally, ignoring any enclosing `<align>`.
508 * That is the contract the other three renderers already keep — the preview
509 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
510 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
511 * `<image>` — and inheriting the `ESC a` state instead would left-align the
512 * bare `<image>` the template editor inserts, which all three show centred.
513 *
514 * No trailing line feed: `GS v 0` leaves the printer "at the beginning of the
515 * line" (ESC/POS Command Reference), so one here would open a blank line the
516 * preview does not have.
517 *
518 * A src that resolves to nothing (a remote URL, a missing file) prints
519 * nothing rather than a stray line feed.
520 *
521 * @param array $node The image AST node.
522 *
523 * @return void
524 */
525 private function emit_image( array $node ): void {
526 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->layout->columns() ) );
527 if ( null === $bitmap ) {
528 return;
529 }
530
531 // GS v 0 only executes at the beginning of a line in standard mode.
532 $this->close_open_line();
533
534 $bytes_per_row = $bitmap->bytes_per_row();
535 $height = $bitmap->height();
536
537 $this->raw( array( 0x1b, 0x61, $this->align_byte( 'center' ) ) );
538 $this->raw(
539 array(
540 0x1d,
541 0x76,
542 0x30,
543 0x00,
544 $bytes_per_row & 0xff,
545 ( $bytes_per_row >> 8 ) & 0xff,
546 $height & 0xff,
547 ( $height >> 8 ) & 0xff,
548 )
549 );
550 $this->raw_string( $bitmap->raster() );
551 $this->raw( array( 0x1b, 0x61, $this->align_byte( $this->align ) ) );
552 }
553
554 /**
555 * Emit a model-2 QR code using native GS ( k commands.
556 *
557 * @param array $node The qrcode AST node.
558 *
559 * @return void
560 */
561 private function emit_qrcode( array $node ): void {
562 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
563 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
564 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( Thermal_Bounds::QRCODE_SIZE_MAX, $size ) );
565
566 // GS ( k only executes at the beginning of a line in standard mode.
567 $this->close_open_line();
568
569 // Select model 2.
570 $this->raw( array( 0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00 ) );
571 // Set module size.
572 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, $size ) );
573 // Set error correction level (M).
574 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31 ) );
575
576 // Store data.
577 $data = substr( $value, 0, 0xffff - 3 );
578 $payload = \strlen( $data ) + 3;
579 $p_l = $payload & 0xff;
580 $p_h = ( $payload >> 8 ) & 0xff;
581 $this->raw( array( 0x1d, 0x28, 0x6b, $p_l, $p_h, 0x31, 0x50, 0x30 ) );
582 $this->raw_string( $data );
583
584 // Print the stored symbol.
585 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30 ) );
586 }
587
588 /**
589 * Feed to the cutting position and emit a paper cut command.
590 *
591 * @param array $node The cut AST node.
592 *
593 * @return void
594 */
595 private function emit_cut( array $node ): void {
596 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
597 $this->raw( array( 0x1d, 0x56, 'full' === $cut_type ? 0x41 : 0x42, 0x00 ) );
598 }
599
600 /**
601 * Emit a paper feed of N lines.
602 *
603 * @param array $node The feed AST node.
604 *
605 * @return void
606 */
607 private function emit_feed( array $node ): void {
608 $lines = Thermal_Bounds::clamp_int(
609 isset( $node['lines'] ) ? $node['lines'] : null,
610 Thermal_Bounds::FEED_LINES_MIN,
611 Thermal_Bounds::FEED_LINES_MIN,
612 Thermal_Bounds::FEED_LINES_MAX
613 );
614 for ( $index = 0; $index < $lines; $index++ ) {
615 $this->raw( array( 0x0a ) );
616 }
617 $this->line_open = false;
618 }
619
620 /**
621 * Emit a single newline and restore scaled line spacing if active.
622 *
623 * @return void
624 */
625 private function newline(): void {
626 $this->raw( array( 0x0a ) );
627 $this->line_open = false;
628 if ( $this->active_scaled_spacing > 0 ) {
629 $this->raw( array( 0x1b, 0x32 ) );
630 $this->active_scaled_spacing = 0;
631 }
632 }
633
634 /**
635 * Append a list of ordinal bytes to the output buffer.
636 *
637 * @param array $bytes The ordinal bytes.
638 *
639 * @return void
640 */
641 private function raw( array $bytes ): void {
642 foreach ( $bytes as $byte ) {
643 $this->buffer .= \chr( $byte & 0xff );
644 }
645 }
646
647 /**
648 * Append a raw string to the output buffer.
649 *
650 * @param string $value The string to append.
651 *
652 * @return void
653 */
654 private function raw_string( string $value ): void {
655 $this->buffer .= $value;
656 }
657 }
658