PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.11
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.11
1.10.25 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 All 169 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.11, at includes/Templates/Thermal/Escpos_Thermal_Emitter.php

784 lines 22.2 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 /**
42 * Render options.
43 *
44 * @var array
45 */
46 private $options = array();
47
48 /**
49 * Accumulated output bytes.
50 *
51 * @var string
52 */
53 private $buffer = '';
54
55 /**
56 * The paper width in character columns.
57 *
58 * @var int
59 */
60 private $columns = 48;
61
62 /**
63 * The current alignment mode (left|center|right).
64 *
65 * @var string
66 */
67 private $align = 'left';
68
69 /**
70 * Whether bold is currently active.
71 *
72 * @var bool
73 */
74 private $bold = false;
75
76 /**
77 * Whether underline is currently active.
78 *
79 * @var bool
80 */
81 private $underline = false;
82
83 /**
84 * Whether invert is currently active.
85 *
86 * @var bool
87 */
88 private $invert = false;
89
90 /**
91 * The current text width multiplier.
92 *
93 * @var int
94 */
95 private $width = 1;
96
97 /**
98 * The current text height multiplier.
99 *
100 * @var int
101 */
102 private $height = 1;
103
104 /**
105 * The active scaled line-spacing height, or 0 when none is active.
106 *
107 * @var int
108 */
109 private $active_scaled_spacing = 0;
110
111 /**
112 * Whether unterminated text is sitting in the printer's line buffer.
113 *
114 * `GS v 0`, `GS k` and `GS ( k` are only executed at the beginning of a line
115 * in standard mode; issued mid-line the printer discards them and reports
116 * nothing. Bare text in a template (`<receipt>Total<image/></receipt>`) parses
117 * to a `raw-text` node, which prints without a terminator, so the emitter has
118 * to know whether a line is open before it sends one of those commands.
119 *
120 * @var bool
121 */
122 private $line_open = false;
123
124 /**
125 * Constructor.
126 *
127 * @param array $options Render options.
128 */
129 public function __construct( array $options = array() ) {
130 $this->options = $options;
131 }
132
133 /**
134 * Emit raw ESC/POS bytes from a thermal AST.
135 *
136 * @param array $ast The thermal AST root (a receipt node).
137 *
138 * @return string The raw ESC/POS bytes.
139 */
140 public function emit( array $ast ): string {
141 $this->buffer = '';
142 $this->align = 'left';
143 $this->bold = false;
144 $this->underline = false;
145 $this->invert = false;
146 $this->width = 1;
147 $this->height = 1;
148 $this->active_scaled_spacing = 0;
149 $this->line_open = false;
150
151 $this->columns = isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48;
152
153 // ESC @ — initialize the printer (once, at the very start).
154 $this->raw( array( 0x1b, 0x40 ) );
155
156 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
157 $this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );
158
159 return $this->buffer;
160 }
161
162 /**
163 * Walk a list of AST nodes.
164 *
165 * @param array $nodes The AST nodes.
166 *
167 * @return void
168 */
169 private function walk_nodes( array $nodes ): void {
170 foreach ( $nodes as $node ) {
171 if ( \is_array( $node ) ) {
172 $this->walk_node( $node );
173 }
174 }
175 }
176
177 /**
178 * Insert an auto drawer node before the first trailing cut when enabled.
179 *
180 * @param array $nodes AST nodes.
181 *
182 * @return array
183 */
184 private function nodes_with_auto_drawer( array $nodes ): array {
185 if ( empty( $this->options['auto_open_drawer'] ) || $this->nodes_contain_drawer( $nodes ) ) {
186 return $nodes;
187 }
188
189 $drawer = array(
190 'type' => 'drawer',
191 'connector' => \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( (string) ( $this->options['drawer_connector'] ?? 'pin2' ) ),
192 );
193
194 for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
195 $type = isset( $nodes[ $i ]['type'] ) ? (string) $nodes[ $i ]['type'] : '';
196 if ( 'cut' === $type ) {
197 array_splice( $nodes, $i, 0, array( $drawer ) );
198 return $nodes;
199 }
200 if ( in_array( $type, array( 'feed' ), true ) ) {
201 continue;
202 }
203 break;
204 }
205
206 $nodes[] = $drawer;
207 return $nodes;
208 }
209
210 /**
211 * Whether a node list contains an explicit drawer node.
212 *
213 * @param array $nodes AST nodes.
214 *
215 * @return bool
216 */
217 private function nodes_contain_drawer( array $nodes ): bool {
218 foreach ( $nodes as $node ) {
219 if ( ! is_array( $node ) ) {
220 continue;
221 }
222 if ( 'drawer' === ( $node['type'] ?? '' ) ) {
223 return true;
224 }
225 if ( ! empty( $node['children'] ) && is_array( $node['children'] ) && $this->nodes_contain_drawer( $node['children'] ) ) {
226 return true;
227 }
228 }
229
230 return false;
231 }
232
233 /**
234 * Emit ESC/POS drawer pulse bytes.
235 *
236 * @param string $connector Drawer connector.
237 */
238 private function emit_drawer_pulse( string $connector ): void {
239 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
240 $pin = 'pin5' === $connector ? 0x01 : 0x00;
241
242 $this->raw( array( 0x1b, 0x70, $pin, 0x19, 0xfa ) );
243 }
244
245 /**
246 * Walk a single AST node.
247 *
248 * @param array $node The AST node.
249 *
250 * @return void
251 */
252 private function walk_node( array $node ): void {
253 $type = isset( $node['type'] ) ? $node['type'] : '';
254
255 switch ( $type ) {
256 case 'raw-text':
257 $this->emit_inline_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
258 break;
259 case 'text':
260 $this->emit_text_line( isset( $node['children'] ) ? $node['children'] : array() );
261 break;
262 case 'bold':
263 $this->emit_bold( $node );
264 break;
265 case 'underline':
266 $this->emit_underline( $node );
267 break;
268 case 'invert':
269 $this->emit_invert( $node );
270 break;
271 case 'size':
272 $this->emit_size( $node );
273 break;
274 case 'align':
275 $this->emit_align( $node );
276 break;
277 case 'row':
278 $this->emit_row( $node );
279 break;
280 case 'line':
281 $this->emit_line( $node );
282 break;
283 case 'barcode':
284 $this->emit_barcode( $node );
285 break;
286 case 'qrcode':
287 $this->emit_qrcode( $node );
288 break;
289 case 'image':
290 $this->emit_image( $node );
291 break;
292 case 'cut':
293 $this->emit_cut( $node );
294 break;
295 case 'feed':
296 $this->emit_feed( $node );
297 break;
298 case 'drawer':
299 $this->emit_drawer_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
300 break;
301 case 'receipt':
302 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
303 break;
304 }
305 }
306
307 /**
308 * Emit inline (styled) text bytes for the current line.
309 *
310 * @param string $value The raw text value.
311 *
312 * @return void
313 */
314 private function emit_inline_text( string $value ): void {
315 $text = Thermal_Text_Layout::normalize_text( $value );
316 if ( '' === $text ) {
317 return;
318 }
319
320 $this->raw_string( $text );
321
322 // The parser preserves a text node verbatim, newlines included, so this
323 // may have ended the line itself — `<receipt>Total\n<image/></receipt>`
324 // leaves the printer at column zero. Reading the state off the bytes just
325 // written keeps close_open_line() from spending a second line feed there.
326 $this->line_open = "\n" !== substr( $text, -1 );
327 }
328
329 /**
330 * Close an open line so a beginning-of-line command can execute.
331 *
332 * @return void
333 */
334 private function close_open_line(): void {
335 if ( $this->line_open ) {
336 $this->newline();
337 }
338 }
339
340 /**
341 * Emit a single printed text line (the children, padding, then a newline).
342 *
343 * @param array $children The child nodes of the text node.
344 *
345 * @return void
346 */
347 private function emit_text_line( array $children ): void {
348 if ( 'left' !== $this->align ) {
349 $plain = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( $children ) );
350 $pad = Thermal_Text_Layout::alignment_padding( $this->align, Thermal_Text_Layout::display_width( $plain ), $this->columns );
351 if ( $pad > 0 ) {
352 $this->raw_string( str_repeat( ' ', $pad ) );
353 }
354 }
355 $this->walk_nodes( $children );
356 $this->newline();
357 }
358
359 /**
360 * Emit a bold-wrapped block.
361 *
362 * @param array $node The bold AST node.
363 *
364 * @return void
365 */
366 private function emit_bold( array $node ): void {
367 $previous = $this->bold;
368 $this->raw( array( 0x1b, 0x45, 0x01 ) );
369 $this->bold = true;
370 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
371 $this->raw( array( 0x1b, 0x45, $previous ? 0x01 : 0x00 ) );
372 $this->bold = $previous;
373 }
374
375 /**
376 * Emit an underline-wrapped block.
377 *
378 * @param array $node The underline AST node.
379 *
380 * @return void
381 */
382 private function emit_underline( array $node ): void {
383 $previous = $this->underline;
384 $this->raw( array( 0x1b, 0x2d, 0x01 ) );
385 $this->underline = true;
386 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
387 $this->raw( array( 0x1b, 0x2d, $previous ? 0x01 : 0x00 ) );
388 $this->underline = $previous;
389 }
390
391 /**
392 * Emit an invert-wrapped block.
393 *
394 * @param array $node The invert AST node.
395 *
396 * @return void
397 */
398 private function emit_invert( array $node ): void {
399 $previous = $this->invert;
400 $this->raw( array( 0x1d, 0x42, 0x01 ) );
401 $this->invert = true;
402 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
403 $this->raw( array( 0x1d, 0x42, $previous ? 0x01 : 0x00 ) );
404 $this->invert = $previous;
405 }
406
407 /**
408 * Emit a size-wrapped block, including scaled line spacing.
409 *
410 * @param array $node The size AST node.
411 *
412 * @return void
413 */
414 private function emit_size( array $node ): void {
415 $previous_width = $this->width;
416 $previous_height = $this->height;
417 $width = Thermal_Bounds::clamp_int( isset( $node['width'] ) ? $node['width'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
418 $height = Thermal_Bounds::clamp_int( isset( $node['height'] ) ? $node['height'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
419
420 if ( $height > 1 ) {
421 $this->active_scaled_spacing = max( $this->active_scaled_spacing, $height );
422 $this->raw( array( 0x1b, 0x33, min( 255, $height * 30 ) ) );
423 }
424
425 $this->raw( array( 0x1d, 0x21, $this->size_byte( $width, $height ) ) );
426 $this->width = $width;
427 $this->height = $height;
428
429 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
430
431 $this->raw( array( 0x1d, 0x21, $this->size_byte( $previous_width, $previous_height ) ) );
432 $this->width = $previous_width;
433 $this->height = $previous_height;
434 }
435
436 /**
437 * Compute the GS ! size byte for a width/height multiplier.
438 *
439 * @param int $width The width multiplier.
440 * @param int $height The height multiplier.
441 *
442 * @return int The GS ! parameter byte.
443 */
444 private function size_byte( int $width, int $height ): int {
445 return ( ( $width - 1 ) & 0x0f ) | ( ( ( $height - 1 ) & 0x0f ) << 4 );
446 }
447
448 /**
449 * Emit an alignment-wrapped block.
450 *
451 * @param array $node The align AST node.
452 *
453 * @return void
454 */
455 private function emit_align( array $node ): void {
456 $previous = $this->align;
457 $mode = isset( $node['mode'] ) ? $node['mode'] : 'left';
458 $this->raw( array( 0x1b, 0x61, $this->align_byte( $mode ) ) );
459 $this->align = $mode;
460
461 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
462
463 $this->raw( array( 0x1b, 0x61, $this->align_byte( $previous ) ) );
464 $this->align = $previous;
465 }
466
467 /**
468 * Map an alignment mode to its ESC a parameter byte.
469 *
470 * @param string $mode The alignment mode.
471 *
472 * @return int The ESC a parameter byte.
473 */
474 private function align_byte( string $mode ): int {
475 if ( 'center' === $mode ) {
476 return 0x01;
477 }
478 if ( 'right' === $mode ) {
479 return 0x02;
480 }
481
482 return 0x00;
483 }
484
485 /**
486 * Emit a row as one physical line followed by a newline.
487 *
488 * @param array $node The row AST node.
489 *
490 * @return void
491 */
492 private function emit_row( array $node ): void {
493 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
494 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
495
496 $line = '';
497 foreach ( $cols as $index => $col ) {
498 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
499 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
500 $text = Thermal_Text_Layout::truncate_display( $text, $width );
501 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
502 $align = isset( $col['align'] ) ? $col['align'] : 'left';
503 if ( 'right' === $align ) {
504 $line .= str_repeat( ' ', $pad ) . $text;
505 } else {
506 $line .= $text . str_repeat( ' ', $pad );
507 }
508 }
509
510 $this->raw_string( $line );
511 $this->newline();
512 }
513
514 /**
515 * Emit a horizontal rule line.
516 *
517 * @param array $node The line AST node.
518 *
519 * @return void
520 */
521 private function emit_line( array $node ): void {
522 $style = isset( $node['style'] ) ? $node['style'] : 'single';
523
524 if ( 'dotted' === $style ) {
525 $pattern = '. ';
526 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
527 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
528 } elseif ( 'double' === $style ) {
529 $text = str_repeat( '=', $this->columns );
530 } else {
531 // single and dashed both render as '-' across the width.
532 $text = str_repeat( '-', $this->columns );
533 }
534
535 $this->raw_string( $text );
536 $this->newline();
537 }
538
539 /**
540 * Emit a 1D barcode using the native `GS k` function-B command.
541 *
542 * The symbology selector and the data encoding both come from
543 * Barcode_Symbology (Epson ESC/POS Command Reference, `GS k`). An ESC/POS
544 * printer handed data its symbology cannot encode prints nothing and reports
545 * no error, so an unencodable value is printed as text instead — never as an
546 * error string, which a cashier would have to read off the receipt.
547 *
548 * @param array $node The barcode AST node.
549 *
550 * @return void
551 */
552 private function emit_barcode( array $node ): void {
553 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
554 if ( '' === trim( $value ) ) {
555 return;
556 }
557
558 // Before the validation branch, not after: GS k only executes at the
559 // beginning of a line, and the rescue below centres its text against the
560 // full paper width, so both outcomes need the line closed first.
561 $this->close_open_line();
562
563 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
564 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
565 $height = max( Thermal_Bounds::BARCODE_HEIGHT_MIN, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
566
567 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
568 $this->emit_centered_text( $value );
569
570 return;
571 }
572
573 $this->raw( array( 0x1d, 0x68, $height ) ); // GS h — barcode height.
574 $this->raw( array( 0x1d, 0x77, 0x02 ) ); // GS w — module width.
575 // GS H 2 — HRI below the bars, matching the preview, the PDF and the
576 // raster lane. With HRI off the merchant designs against a receipt that
577 // carries the order number and the printer hands over one that does not.
578 $this->raw( array( 0x1d, 0x48, 0x02 ) );
579
580 $data = Barcode_Symbology::escpos_payload( $type, $value );
581 // GS k m n d1..dn — function B, length-prefixed.
582 $this->raw( array( 0x1d, 0x6b, Barcode_Symbology::escpos_id( $type ), \strlen( $data ) ) );
583 $this->raw_string( $data );
584 }
585
586 /**
587 * Print a template `<image>` (in practice, the store logo).
588 *
589 * `GS v 0 m xL xH yL yH d1..dk` — the raster bit image, whose data layout is
590 * exactly what Thermal_Bitmap produces: row-major, MSB first, a set bit being
591 * a black dot. xL/xH count BYTES per row, not dots, which is why the bitmap
592 * pads its width to a whole byte.
593 *
594 * The image is centred unconditionally, ignoring any enclosing `<align>`.
595 * That is the contract the other three renderers already keep — the preview
596 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
597 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
598 * `<image>` — and inheriting the `ESC a` state instead would left-align the
599 * bare `<image>` the template editor inserts, which all three show centred.
600 *
601 * No trailing line feed: `GS v 0` leaves the printer "at the beginning of the
602 * line" (ESC/POS Command Reference), so one here would open a blank line the
603 * preview does not have.
604 *
605 * A src that resolves to nothing (a remote URL, a missing file) prints
606 * nothing rather than a stray line feed.
607 *
608 * @param array $node The image AST node.
609 *
610 * @return void
611 */
612 private function emit_image( array $node ): void {
613 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
614 if ( null === $bitmap ) {
615 return;
616 }
617
618 // GS v 0 only executes at the beginning of a line in standard mode.
619 $this->close_open_line();
620
621 $bytes_per_row = $bitmap->bytes_per_row();
622 $height = $bitmap->height();
623
624 $this->raw( array( 0x1b, 0x61, $this->align_byte( 'center' ) ) );
625 $this->raw(
626 array(
627 0x1d,
628 0x76,
629 0x30,
630 0x00,
631 $bytes_per_row & 0xff,
632 ( $bytes_per_row >> 8 ) & 0xff,
633 $height & 0xff,
634 ( $height >> 8 ) & 0xff,
635 )
636 );
637 $this->raw_string( $bitmap->raster() );
638 $this->raw( array( 0x1b, 0x61, $this->align_byte( $this->align ) ) );
639 }
640
641 /**
642 * Print a value as a centered plain-text line.
643 *
644 * Mirrors the rescue in Html_Thermal_Emitter::render_barcode_fallback(): when
645 * the symbol cannot be produced, the value itself is still readable.
646 *
647 * Control bytes are folded to spaces first. This is the one path that routes
648 * a barcode value into the text stream, and a barcode value is exactly where
649 * a stray tab, LF or CR turns up — Code 128 validation rejects them on the
650 * ESC/POS lane precisely because code set B cannot encode them, which sends
651 * them here. Emitted raw they would break the line the rescue is centering.
652 *
653 * @param string $value The value to print.
654 *
655 * @return void
656 */
657 private function emit_centered_text( string $value ): void {
658 $text = Thermal_Text_Layout::normalize_text( $this->strip_control_bytes( $value ) );
659 $pad = (int) floor( max( 0, $this->columns - Thermal_Text_Layout::display_width( $text ) ) / 2 );
660 if ( $pad > 0 ) {
661 $this->raw_string( str_repeat( ' ', $pad ) );
662 }
663 $this->raw_string( $text );
664 $this->newline();
665 }
666
667 /**
668 * Replace control bytes with spaces so they cannot reach the print stream.
669 *
670 * @param string $value The value to clean.
671 *
672 * @return string The value with control bytes folded to spaces.
673 */
674 private function strip_control_bytes( string $value ): string {
675 $cleaned = preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value );
676
677 return null === $cleaned ? $value : $cleaned;
678 }
679
680 /**
681 * Emit a model-2 QR code using native GS ( k commands.
682 *
683 * @param array $node The qrcode AST node.
684 *
685 * @return void
686 */
687 private function emit_qrcode( array $node ): void {
688 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
689 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
690 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( Thermal_Bounds::QRCODE_SIZE_MAX, $size ) );
691
692 // GS ( k only executes at the beginning of a line in standard mode.
693 $this->close_open_line();
694
695 // Select model 2.
696 $this->raw( array( 0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00 ) );
697 // Set module size.
698 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, $size ) );
699 // Set error correction level (M).
700 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31 ) );
701
702 // Store data.
703 $data = substr( $value, 0, 0xffff - 3 );
704 $payload = \strlen( $data ) + 3;
705 $p_l = $payload & 0xff;
706 $p_h = ( $payload >> 8 ) & 0xff;
707 $this->raw( array( 0x1d, 0x28, 0x6b, $p_l, $p_h, 0x31, 0x50, 0x30 ) );
708 $this->raw_string( $data );
709
710 // Print the stored symbol.
711 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30 ) );
712 }
713
714 /**
715 * Feed to the cutting position and emit a paper cut command.
716 *
717 * @param array $node The cut AST node.
718 *
719 * @return void
720 */
721 private function emit_cut( array $node ): void {
722 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
723 $this->raw( array( 0x1d, 0x56, 'full' === $cut_type ? 0x41 : 0x42, 0x00 ) );
724 }
725
726 /**
727 * Emit a paper feed of N lines.
728 *
729 * @param array $node The feed AST node.
730 *
731 * @return void
732 */
733 private function emit_feed( array $node ): void {
734 $lines = Thermal_Bounds::clamp_int(
735 isset( $node['lines'] ) ? $node['lines'] : null,
736 Thermal_Bounds::FEED_LINES_MIN,
737 Thermal_Bounds::FEED_LINES_MIN,
738 Thermal_Bounds::FEED_LINES_MAX
739 );
740 for ( $index = 0; $index < $lines; $index++ ) {
741 $this->raw( array( 0x0a ) );
742 }
743 $this->line_open = false;
744 }
745
746 /**
747 * Emit a single newline and restore scaled line spacing if active.
748 *
749 * @return void
750 */
751 private function newline(): void {
752 $this->raw( array( 0x0a ) );
753 $this->line_open = false;
754 if ( $this->active_scaled_spacing > 0 ) {
755 $this->raw( array( 0x1b, 0x32 ) );
756 $this->active_scaled_spacing = 0;
757 }
758 }
759
760 /**
761 * Append a list of ordinal bytes to the output buffer.
762 *
763 * @param array $bytes The ordinal bytes.
764 *
765 * @return void
766 */
767 private function raw( array $bytes ): void {
768 foreach ( $bytes as $byte ) {
769 $this->buffer .= \chr( $byte & 0xff );
770 }
771 }
772
773 /**
774 * Append a raw string to the output buffer.
775 *
776 * @param string $value The string to append.
777 *
778 * @return void
779 */
780 private function raw_string( string $value ): void {
781 $this->buffer .= $value;
782 }
783 }
784