PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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.16, at includes/Templates/Thermal/Escpos_Thermal_Emitter.php

804 lines 23.1 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, $this->width );
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 * `GS ! n` puts the WIDTH magnification in bits 4-7 and the HEIGHT in bits 0-3, each as
440 * multiplier - 1 over 1x-8x: `n = (width - 1) << 4 | (height - 1)`. These were the wrong way
441 * round, so every non-square `<size>` printed transposed -- a heading asked to be double-wide
442 * came out double-high. Square sizes are bit-symmetric, which is why the 2x2 case everything
443 * uses looked right and hid it.
444 *
445 * @param int $width The width multiplier.
446 * @param int $height The height multiplier.
447 *
448 * @return int The GS ! parameter byte.
449 */
450 private function size_byte( int $width, int $height ): int {
451 return ( self::size_nibble( $width ) << 4 ) | self::size_nibble( $height );
452 }
453
454 /**
455 * One magnification nibble: multiplier - 1, bounded to the 1x-8x the command can express.
456 *
457 * Nothing bounds `<size>` on the way in, and a multiplier of 9 unbounded would carry into the
458 * neighbouring field and silently resize the other axis.
459 *
460 * @param int $multiplier The width or height multiplier.
461 *
462 * @return int The nibble value (0-7).
463 */
464 private static function size_nibble( int $multiplier ): int {
465 return max( 1, min( 8, $multiplier ) ) - 1;
466 }
467
468 /**
469 * Emit an alignment-wrapped block.
470 *
471 * @param array $node The align AST node.
472 *
473 * @return void
474 */
475 private function emit_align( array $node ): void {
476 $previous = $this->align;
477 $mode = isset( $node['mode'] ) ? $node['mode'] : 'left';
478 $this->raw( array( 0x1b, 0x61, $this->align_byte( $mode ) ) );
479 $this->align = $mode;
480
481 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
482
483 $this->raw( array( 0x1b, 0x61, $this->align_byte( $previous ) ) );
484 $this->align = $previous;
485 }
486
487 /**
488 * Map an alignment mode to its ESC a parameter byte.
489 *
490 * @param string $mode The alignment mode.
491 *
492 * @return int The ESC a parameter byte.
493 */
494 private function align_byte( string $mode ): int {
495 if ( 'center' === $mode ) {
496 return 0x01;
497 }
498 if ( 'right' === $mode ) {
499 return 0x02;
500 }
501
502 return 0x00;
503 }
504
505 /**
506 * Emit a row as one physical line followed by a newline.
507 *
508 * @param array $node The row AST node.
509 *
510 * @return void
511 */
512 private function emit_row( array $node ): void {
513 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
514 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
515
516 $line = '';
517 foreach ( $cols as $index => $col ) {
518 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
519 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
520 $text = Thermal_Text_Layout::truncate_display( $text, $width );
521 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
522 $align = isset( $col['align'] ) ? $col['align'] : 'left';
523 if ( 'right' === $align ) {
524 $line .= str_repeat( ' ', $pad ) . $text;
525 } else {
526 $line .= $text . str_repeat( ' ', $pad );
527 }
528 }
529
530 $this->raw_string( $line );
531 $this->newline();
532 }
533
534 /**
535 * Emit a horizontal rule line.
536 *
537 * @param array $node The line AST node.
538 *
539 * @return void
540 */
541 private function emit_line( array $node ): void {
542 $style = isset( $node['style'] ) ? $node['style'] : 'single';
543
544 if ( 'dotted' === $style ) {
545 $pattern = '. ';
546 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
547 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
548 } elseif ( 'double' === $style ) {
549 $text = str_repeat( '=', $this->columns );
550 } else {
551 // single and dashed both render as '-' across the width.
552 $text = str_repeat( '-', $this->columns );
553 }
554
555 $this->raw_string( $text );
556 $this->newline();
557 }
558
559 /**
560 * Emit a 1D barcode using the native `GS k` function-B command.
561 *
562 * The symbology selector and the data encoding both come from
563 * Barcode_Symbology (Epson ESC/POS Command Reference, `GS k`). An ESC/POS
564 * printer handed data its symbology cannot encode prints nothing and reports
565 * no error, so an unencodable value is printed as text instead — never as an
566 * error string, which a cashier would have to read off the receipt.
567 *
568 * @param array $node The barcode AST node.
569 *
570 * @return void
571 */
572 private function emit_barcode( array $node ): void {
573 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
574 if ( '' === trim( $value ) ) {
575 return;
576 }
577
578 // Before the validation branch, not after: GS k only executes at the
579 // beginning of a line, and the rescue below centres its text against the
580 // full paper width, so both outcomes need the line closed first.
581 $this->close_open_line();
582
583 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
584 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
585 $height = max( Thermal_Bounds::BARCODE_HEIGHT_MIN, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
586
587 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
588 $this->emit_centered_text( $value );
589
590 return;
591 }
592
593 $this->raw( array( 0x1d, 0x68, $height ) ); // GS h — barcode height.
594 $this->raw( array( 0x1d, 0x77, 0x02 ) ); // GS w — module width.
595 // GS H 2 — HRI below the bars, matching the preview, the PDF and the
596 // raster lane. With HRI off the merchant designs against a receipt that
597 // carries the order number and the printer hands over one that does not.
598 $this->raw( array( 0x1d, 0x48, 0x02 ) );
599
600 $data = Barcode_Symbology::escpos_payload( $type, $value );
601 // GS k m n d1..dn — function B, length-prefixed.
602 $this->raw( array( 0x1d, 0x6b, Barcode_Symbology::escpos_id( $type ), \strlen( $data ) ) );
603 $this->raw_string( $data );
604 }
605
606 /**
607 * Print a template `<image>` (in practice, the store logo).
608 *
609 * `GS v 0 m xL xH yL yH d1..dk` — the raster bit image, whose data layout is
610 * exactly what Thermal_Bitmap produces: row-major, MSB first, a set bit being
611 * a black dot. xL/xH count BYTES per row, not dots, which is why the bitmap
612 * pads its width to a whole byte.
613 *
614 * The image is centred unconditionally, ignoring any enclosing `<align>`.
615 * That is the contract the other three renderers already keep — the preview
616 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
617 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
618 * `<image>` — and inheriting the `ESC a` state instead would left-align the
619 * bare `<image>` the template editor inserts, which all three show centred.
620 *
621 * No trailing line feed: `GS v 0` leaves the printer "at the beginning of the
622 * line" (ESC/POS Command Reference), so one here would open a blank line the
623 * preview does not have.
624 *
625 * A src that resolves to nothing (a remote URL, a missing file) prints
626 * nothing rather than a stray line feed.
627 *
628 * @param array $node The image AST node.
629 *
630 * @return void
631 */
632 private function emit_image( array $node ): void {
633 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
634 if ( null === $bitmap ) {
635 return;
636 }
637
638 // GS v 0 only executes at the beginning of a line in standard mode.
639 $this->close_open_line();
640
641 $bytes_per_row = $bitmap->bytes_per_row();
642 $height = $bitmap->height();
643
644 $this->raw( array( 0x1b, 0x61, $this->align_byte( 'center' ) ) );
645 $this->raw(
646 array(
647 0x1d,
648 0x76,
649 0x30,
650 0x00,
651 $bytes_per_row & 0xff,
652 ( $bytes_per_row >> 8 ) & 0xff,
653 $height & 0xff,
654 ( $height >> 8 ) & 0xff,
655 )
656 );
657 $this->raw_string( $bitmap->raster() );
658 $this->raw( array( 0x1b, 0x61, $this->align_byte( $this->align ) ) );
659 }
660
661 /**
662 * Print a value as a centered plain-text line.
663 *
664 * Mirrors the rescue in Html_Thermal_Emitter::render_barcode_fallback(): when
665 * the symbol cannot be produced, the value itself is still readable.
666 *
667 * Control bytes are folded to spaces first. This is the one path that routes
668 * a barcode value into the text stream, and a barcode value is exactly where
669 * a stray tab, LF or CR turns up — Code 128 validation rejects them on the
670 * ESC/POS lane precisely because code set B cannot encode them, which sends
671 * them here. Emitted raw they would break the line the rescue is centering.
672 *
673 * @param string $value The value to print.
674 *
675 * @return void
676 */
677 private function emit_centered_text( string $value ): void {
678 $text = Thermal_Text_Layout::normalize_text( $this->strip_control_bytes( $value ) );
679 $pad = (int) floor( max( 0, $this->columns - Thermal_Text_Layout::display_width( $text ) ) / 2 );
680 if ( $pad > 0 ) {
681 $this->raw_string( str_repeat( ' ', $pad ) );
682 }
683 $this->raw_string( $text );
684 $this->newline();
685 }
686
687 /**
688 * Replace control bytes with spaces so they cannot reach the print stream.
689 *
690 * @param string $value The value to clean.
691 *
692 * @return string The value with control bytes folded to spaces.
693 */
694 private function strip_control_bytes( string $value ): string {
695 $cleaned = preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value );
696
697 return null === $cleaned ? $value : $cleaned;
698 }
699
700 /**
701 * Emit a model-2 QR code using native GS ( k commands.
702 *
703 * @param array $node The qrcode AST node.
704 *
705 * @return void
706 */
707 private function emit_qrcode( array $node ): void {
708 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
709 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
710 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( Thermal_Bounds::QRCODE_SIZE_MAX, $size ) );
711
712 // GS ( k only executes at the beginning of a line in standard mode.
713 $this->close_open_line();
714
715 // Select model 2.
716 $this->raw( array( 0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00 ) );
717 // Set module size.
718 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, $size ) );
719 // Set error correction level (M).
720 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31 ) );
721
722 // Store data.
723 $data = substr( $value, 0, 0xffff - 3 );
724 $payload = \strlen( $data ) + 3;
725 $p_l = $payload & 0xff;
726 $p_h = ( $payload >> 8 ) & 0xff;
727 $this->raw( array( 0x1d, 0x28, 0x6b, $p_l, $p_h, 0x31, 0x50, 0x30 ) );
728 $this->raw_string( $data );
729
730 // Print the stored symbol.
731 $this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30 ) );
732 }
733
734 /**
735 * Feed to the cutting position and emit a paper cut command.
736 *
737 * @param array $node The cut AST node.
738 *
739 * @return void
740 */
741 private function emit_cut( array $node ): void {
742 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
743 $this->raw( array( 0x1d, 0x56, 'full' === $cut_type ? 0x41 : 0x42, 0x00 ) );
744 }
745
746 /**
747 * Emit a paper feed of N lines.
748 *
749 * @param array $node The feed AST node.
750 *
751 * @return void
752 */
753 private function emit_feed( array $node ): void {
754 $lines = Thermal_Bounds::clamp_int(
755 isset( $node['lines'] ) ? $node['lines'] : null,
756 Thermal_Bounds::FEED_LINES_MIN,
757 Thermal_Bounds::FEED_LINES_MIN,
758 Thermal_Bounds::FEED_LINES_MAX
759 );
760 for ( $index = 0; $index < $lines; $index++ ) {
761 $this->raw( array( 0x0a ) );
762 }
763 $this->line_open = false;
764 }
765
766 /**
767 * Emit a single newline and restore scaled line spacing if active.
768 *
769 * @return void
770 */
771 private function newline(): void {
772 $this->raw( array( 0x0a ) );
773 $this->line_open = false;
774 if ( $this->active_scaled_spacing > 0 ) {
775 $this->raw( array( 0x1b, 0x32 ) );
776 $this->active_scaled_spacing = 0;
777 }
778 }
779
780 /**
781 * Append a list of ordinal bytes to the output buffer.
782 *
783 * @param array $bytes The ordinal bytes.
784 *
785 * @return void
786 */
787 private function raw( array $bytes ): void {
788 foreach ( $bytes as $byte ) {
789 $this->buffer .= \chr( $byte & 0xff );
790 }
791 }
792
793 /**
794 * Append a raw string to the output buffer.
795 *
796 * @param string $value The string to append.
797 *
798 * @return void
799 */
800 private function raw_string( string $value ): void {
801 $this->buffer .= $value;
802 }
803 }
804