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 / Starprnt_Thermal_Emitter.php

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

791 lines 23.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * StarPRNT Thermal Emitter Class.
4 *
5 * Emits native StarPRNT command bytes from a thermal AST (produced by
6 * Thermal_Markup_Parser) for Star CloudPRNT printers served as
7 * `application/vnd.star.starprnt`. StarPRNT-native printers (the whole TSP100
8 * line, mC-Print in StarPRNT mode) cannot decode ESC/POS, so Star jobs must be
9 * emitted in this command set.
10 *
11 * The command bytes follow Star's own MIT-licensed reference implementation
12 * (star-cloudprnt-for-woocommerce, `printer_star_prnt.inc.php`) and were
13 * cross-checked against the StarPRNT language module of
14 * NielsLeenheer/ReceiptPrinterEncoder. Text layout (alignment padding, rows,
15 * rules, width handling) mirrors Escpos_Thermal_Emitter so the printed layout
16 * matches across providers.
17 *
18 * Deliberate deviations / notes:
19 * - No initialize command is emitted. CloudPRNT jobs must not reset the
20 * device mid-session; jobs open with the UTF-8 encoding select sequence
21 * instead so non-ASCII text decodes correctly.
22 * - Cut uses the feed-then-cut variants (ESC d 2/3) like Star's reference
23 * plugin, so the last lines clear the cutter before cutting.
24 * - Star printers adjust the line feed pitch for magnified text themselves,
25 * so there is no scaled line-spacing handling.
26 * - Images (`<image>`) are thresholded to 1-bit dots by Thermal_Bitmap and sent
27 * as `ESC X` column graphics, Star having no row-major raster command to
28 * match ESC/POS `GS v 0`.
29 *
30 * @author Paul Kilmurray <[email protected]>
31 *
32 * @see http://wcpos.com
33 * @package WCPOS\WooCommercePOS
34 */
35
36 namespace WCPOS\WooCommercePOS\Templates\Thermal;
37
38 use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;
39
40 /**
41 * Starprnt_Thermal_Emitter class.
42 */
43 class Starprnt_Thermal_Emitter {
44
45 /**
46 * Render options.
47 *
48 * @var array
49 */
50 private $options = array();
51
52 /**
53 * Accumulated output bytes.
54 *
55 * @var string
56 */
57 private $buffer = '';
58
59 /**
60 * The paper width in character columns.
61 *
62 * @var int
63 */
64 private $columns = 48;
65
66 /**
67 * The current alignment mode (left|center|right).
68 *
69 * @var string
70 */
71 private $align = 'left';
72
73 /**
74 * Whether bold is currently active.
75 *
76 * @var bool
77 */
78 private $bold = false;
79
80 /**
81 * Whether underline is currently active.
82 *
83 * @var bool
84 */
85 private $underline = false;
86
87 /**
88 * Whether invert is currently active.
89 *
90 * @var bool
91 */
92 private $invert = false;
93
94 /**
95 * The current text width multiplier.
96 *
97 * @var int
98 */
99 private $width = 1;
100
101 /**
102 * The current text height multiplier.
103 *
104 * @var int
105 */
106 private $height = 1;
107
108 /**
109 * Whether unterminated text is sitting in the printer's line buffer.
110 *
111 * Star's graphics and barcode commands, like their ESC/POS counterparts, are
112 * line-oriented: `ESC X` starts a raster band and `ESC b` a barcode, and both
113 * expect to begin at the start of a line. Bare text in a template
114 * (`<receipt>Total<image/></receipt>`) parses to a `raw-text` node, which
115 * prints without a terminator, so the emitter tracks whether a line is open.
116 *
117 * @var bool
118 */
119 private $line_open = false;
120
121 /**
122 * Constructor.
123 *
124 * @param array $options Render options.
125 */
126 public function __construct( array $options = array() ) {
127 $this->options = $options;
128 }
129
130 /**
131 * Emit native StarPRNT bytes from a thermal AST.
132 *
133 * @param array $ast The thermal AST root (a receipt node).
134 *
135 * @return string The raw StarPRNT bytes.
136 */
137 public function emit( array $ast ): string {
138 $this->buffer = '';
139 $this->align = 'left';
140 $this->bold = false;
141 $this->underline = false;
142 $this->invert = false;
143 $this->width = 1;
144 $this->height = 1;
145 $this->line_open = false;
146
147 $this->columns = isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48;
148
149 // ESC GS ) U — select UTF-8 encoding, then the companion font/width
150 // setting, per Star's reference implementation. No initialize command:
151 // CloudPRNT jobs must not reset the printer.
152 $this->raw( array( 0x1b, 0x1d, 0x29, 0x55, 0x02, 0x00, 0x30, 0x01 ) );
153 $this->raw( array( 0x1b, 0x1d, 0x29, 0x55, 0x02, 0x00, 0x40, 0x00 ) );
154
155 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
156 $this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );
157
158 return $this->buffer;
159 }
160
161 /**
162 * Walk a list of AST nodes.
163 *
164 * @param array $nodes The AST nodes.
165 *
166 * @return void
167 */
168 private function walk_nodes( array $nodes ): void {
169 foreach ( $nodes as $node ) {
170 if ( \is_array( $node ) ) {
171 $this->walk_node( $node );
172 }
173 }
174 }
175
176 /**
177 * Insert an auto drawer node before the first trailing cut when enabled.
178 *
179 * @param array $nodes AST nodes.
180 *
181 * @return array
182 */
183 private function nodes_with_auto_drawer( array $nodes ): array {
184 if ( empty( $this->options['auto_open_drawer'] ) || $this->nodes_contain_drawer( $nodes ) ) {
185 return $nodes;
186 }
187
188 $drawer = array(
189 'type' => 'drawer',
190 'connector' => \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( (string) ( $this->options['drawer_connector'] ?? 'pin2' ) ),
191 );
192
193 for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
194 $type = isset( $nodes[ $i ]['type'] ) ? (string) $nodes[ $i ]['type'] : '';
195 if ( 'cut' === $type ) {
196 array_splice( $nodes, $i, 0, array( $drawer ) );
197 return $nodes;
198 }
199 if ( in_array( $type, array( 'feed' ), true ) ) {
200 continue;
201 }
202 break;
203 }
204
205 $nodes[] = $drawer;
206 return $nodes;
207 }
208
209 /**
210 * Whether a node list contains an explicit drawer node.
211 *
212 * @param array $nodes AST nodes.
213 *
214 * @return bool
215 */
216 private function nodes_contain_drawer( array $nodes ): bool {
217 foreach ( $nodes as $node ) {
218 if ( ! is_array( $node ) ) {
219 continue;
220 }
221 if ( 'drawer' === ( $node['type'] ?? '' ) ) {
222 return true;
223 }
224 if ( ! empty( $node['children'] ) && is_array( $node['children'] ) && $this->nodes_contain_drawer( $node['children'] ) ) {
225 return true;
226 }
227 }
228
229 return false;
230 }
231
232 /**
233 * Emit a StarPRNT drawer pulse.
234 *
235 * ESC BEL sets the pulse width (on/off in 10ms units), then the trigger
236 * byte fires the peripheral: 0x07 for device 1 (pin2), 0x1A for device 2
237 * (pin5).
238 *
239 * @param string $connector Drawer connector.
240 */
241 private function emit_drawer_pulse( string $connector ): void {
242 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
243 $trigger = 'pin5' === $connector ? 0x1a : 0x07;
244
245 $this->raw( array( 0x1b, 0x07, 0x0a, 0x0a, $trigger ) );
246 }
247
248 /**
249 * Walk a single AST node.
250 *
251 * @param array $node The AST node.
252 *
253 * @return void
254 */
255 private function walk_node( array $node ): void {
256 $type = isset( $node['type'] ) ? $node['type'] : '';
257
258 switch ( $type ) {
259 case 'raw-text':
260 $this->emit_inline_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
261 break;
262 case 'text':
263 $this->emit_text_line( isset( $node['children'] ) ? $node['children'] : array() );
264 break;
265 case 'bold':
266 $this->emit_bold( $node );
267 break;
268 case 'underline':
269 $this->emit_underline( $node );
270 break;
271 case 'invert':
272 $this->emit_invert( $node );
273 break;
274 case 'size':
275 $this->emit_size( $node );
276 break;
277 case 'align':
278 $this->emit_align( $node );
279 break;
280 case 'row':
281 $this->emit_row( $node );
282 break;
283 case 'line':
284 $this->emit_line( $node );
285 break;
286 case 'barcode':
287 $this->emit_barcode( $node );
288 break;
289 case 'qrcode':
290 $this->emit_qrcode( $node );
291 break;
292 case 'image':
293 $this->emit_image( $node );
294 break;
295 case 'cut':
296 $this->emit_cut( $node );
297 break;
298 case 'feed':
299 $this->emit_feed( $node );
300 break;
301 case 'drawer':
302 $this->emit_drawer_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
303 break;
304 case 'receipt':
305 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
306 break;
307 }
308 }
309
310 /**
311 * Emit inline (styled) text bytes for the current line.
312 *
313 * @param string $value The raw text value.
314 *
315 * @return void
316 */
317 private function emit_inline_text( string $value ): void {
318 $text = Thermal_Text_Layout::normalize_text( $value );
319 if ( '' === $text ) {
320 return;
321 }
322
323 $this->raw_string( $text );
324
325 // The parser preserves a text node verbatim, newlines included, so this
326 // may have ended the line itself — `<receipt>Total\n<image/></receipt>`
327 // leaves the printer at column zero. Reading the state off the bytes just
328 // written keeps close_open_line() from spending a second line feed there.
329 $this->line_open = "\n" !== substr( $text, -1 );
330 }
331
332 /**
333 * Close an open line so a line-oriented command can start cleanly.
334 *
335 * @return void
336 */
337 private function close_open_line(): void {
338 if ( $this->line_open ) {
339 $this->newline();
340 }
341 }
342
343 /**
344 * Emit a single printed text line (the children, padding, then a newline).
345 *
346 * @param array $children The child nodes of the text node.
347 *
348 * @return void
349 */
350 private function emit_text_line( array $children ): void {
351 if ( 'left' !== $this->align ) {
352 $plain = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( $children ) );
353 $pad = Thermal_Text_Layout::alignment_padding( $this->align, Thermal_Text_Layout::display_width( $plain ), $this->columns );
354 if ( $pad > 0 ) {
355 $this->raw_string( str_repeat( ' ', $pad ) );
356 }
357 }
358 $this->walk_nodes( $children );
359 $this->newline();
360 }
361
362 /**
363 * Emit a bold-wrapped block using ESC E / ESC F.
364 *
365 * @param array $node The bold AST node.
366 *
367 * @return void
368 */
369 private function emit_bold( array $node ): void {
370 $previous = $this->bold;
371 $this->raw( array( 0x1b, 0x45 ) );
372 $this->bold = true;
373 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
374 $this->raw( $previous ? array( 0x1b, 0x45 ) : array( 0x1b, 0x46 ) );
375 $this->bold = $previous;
376 }
377
378 /**
379 * Emit an underline-wrapped block using ESC - n.
380 *
381 * @param array $node The underline AST node.
382 *
383 * @return void
384 */
385 private function emit_underline( array $node ): void {
386 $previous = $this->underline;
387 $this->raw( array( 0x1b, 0x2d, 0x01 ) );
388 $this->underline = true;
389 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
390 $this->raw( array( 0x1b, 0x2d, $previous ? 0x01 : 0x00 ) );
391 $this->underline = $previous;
392 }
393
394 /**
395 * Emit an invert-wrapped block using ESC 4 / ESC 5.
396 *
397 * @param array $node The invert AST node.
398 *
399 * @return void
400 */
401 private function emit_invert( array $node ): void {
402 $previous = $this->invert;
403 $this->raw( array( 0x1b, 0x34 ) );
404 $this->invert = true;
405 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
406 $this->raw( $previous ? array( 0x1b, 0x34 ) : array( 0x1b, 0x35 ) );
407 $this->invert = $previous;
408 }
409
410 /**
411 * Emit a size-wrapped block using ESC i (height, width).
412 *
413 * @param array $node The size AST node.
414 *
415 * @return void
416 */
417 private function emit_size( array $node ): void {
418 $previous_width = $this->width;
419 $previous_height = $this->height;
420 $width = Thermal_Bounds::clamp_int( isset( $node['width'] ) ? $node['width'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
421 $height = Thermal_Bounds::clamp_int( isset( $node['height'] ) ? $node['height'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
422
423 $this->raw( array( 0x1b, 0x69, $this->magnification_byte( $height ), $this->magnification_byte( $width ) ) );
424 $this->width = $width;
425 $this->height = $height;
426
427 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
428
429 $this->raw( array( 0x1b, 0x69, $this->magnification_byte( $previous_height ), $this->magnification_byte( $previous_width ) ) );
430 $this->width = $previous_width;
431 $this->height = $previous_height;
432 }
433
434 /**
435 * Compute the ESC i magnification byte for a multiplier (0-based, max 6x).
436 *
437 * @param int $multiplier The width or height multiplier.
438 *
439 * @return int The ESC i parameter byte.
440 */
441 private function magnification_byte( int $multiplier ): int {
442 return max( 0, min( 5, $multiplier - 1 ) );
443 }
444
445 /**
446 * Emit an alignment-wrapped block using ESC GS a.
447 *
448 * @param array $node The align AST node.
449 *
450 * @return void
451 */
452 private function emit_align( array $node ): void {
453 $previous = $this->align;
454 $mode = isset( $node['mode'] ) ? $node['mode'] : 'left';
455 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $mode ) ) );
456 $this->align = $mode;
457
458 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
459
460 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $previous ) ) );
461 $this->align = $previous;
462 }
463
464 /**
465 * Map an alignment mode to its ESC GS a parameter byte.
466 *
467 * @param string $mode The alignment mode.
468 *
469 * @return int The ESC GS a parameter byte.
470 */
471 private function align_byte( string $mode ): int {
472 if ( 'center' === $mode ) {
473 return 0x01;
474 }
475 if ( 'right' === $mode ) {
476 return 0x02;
477 }
478
479 return 0x00;
480 }
481
482 /**
483 * Emit a row as one physical line followed by a newline.
484 *
485 * @param array $node The row AST node.
486 *
487 * @return void
488 */
489 private function emit_row( array $node ): void {
490 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
491 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
492
493 $line = '';
494 foreach ( $cols as $index => $col ) {
495 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
496 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
497 $text = Thermal_Text_Layout::truncate_display( $text, $width );
498 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
499 $align = isset( $col['align'] ) ? $col['align'] : 'left';
500 if ( 'right' === $align ) {
501 $line .= str_repeat( ' ', $pad ) . $text;
502 } else {
503 $line .= $text . str_repeat( ' ', $pad );
504 }
505 }
506
507 $this->raw_string( $line );
508 $this->newline();
509 }
510
511 /**
512 * Emit a horizontal rule line.
513 *
514 * @param array $node The line AST node.
515 *
516 * @return void
517 */
518 private function emit_line( array $node ): void {
519 $style = isset( $node['style'] ) ? $node['style'] : 'single';
520
521 if ( 'dotted' === $style ) {
522 $pattern = '. ';
523 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
524 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
525 } elseif ( 'double' === $style ) {
526 $text = str_repeat( '=', $this->columns );
527 } else {
528 // single and dashed both render as '-' across the width.
529 $text = str_repeat( '-', $this->columns );
530 }
531
532 $this->raw_string( $text );
533 $this->newline();
534 }
535
536 /**
537 * Emit a 1D barcode using ESC b, terminated by RS.
538 *
539 * `ESC b n1 n2 n3 n4 <data> RS` — StarPRNT Command Specifications Ver 1.3E,
540 * barcode section. n1 is the symbology (owned by Barcode_Symbology; note
541 * Star numbers the UPC pair the opposite way round to ESC/POS), n2 = 2 for
542 * "HRI under the bars, line feed after printing" — matching the preview, the
543 * PDF and the raster lane, and matching Star's own reference plugin, which
544 * sets n2 = 2 whenever HRI is asked for — n3 = 2 for the medium module width
545 * (valid for every symbology we emit), and n4 is the height in dots, clamped
546 * to the printable 8-255 range.
547 *
548 * A StarPRNT printer handed data its symbology cannot encode discards the
549 * command up to the RS terminator without reporting an error, so an
550 * unencodable value is printed as text instead.
551 *
552 * @param array $node The barcode AST node.
553 *
554 * @return void
555 */
556 private function emit_barcode( array $node ): void {
557 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
558 if ( '' === trim( $value ) ) {
559 return;
560 }
561
562 // Before the validation branch, not after: ESC b starts a barcode block,
563 // and the rescue below centres its text against the full paper width, so
564 // both outcomes need the line closed first.
565 $this->close_open_line();
566
567 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
568 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
569 // The 8-dot floor is Star's, not the markup's: Thermal_Bounds allows a
570 // 1-dot barcode and ESC/POS prints one, but ESC b rejects anything
571 // shorter than 8. A device-specific bound, so it stays here.
572 $height = max( 8, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
573
574 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_STARPRNT ) ) {
575 $this->emit_centered_text( $value );
576
577 return;
578 }
579
580 $this->raw( array( 0x1b, 0x62, Barcode_Symbology::starprnt_id( $type ), 0x02, 0x02, $height ) );
581 $this->raw_string( Barcode_Symbology::starprnt_payload( $type, $value ) );
582 $this->raw( array( 0x1e ) );
583 }
584
585 /**
586 * Print a template `<image>` (in practice, the store logo).
587 *
588 * `ESC X nL nH d1..dk` — Star's column graphics, taken from the StarPRNT
589 * language module of NielsLeenheer/ReceiptPrinterEncoder, the same source the
590 * rest of this emitter was cross-checked against. Star has no row-major
591 * raster command to match ESC/POS `GS v 0`: the image goes out in 24-dot
592 * bands, three bytes per column, top bit first, so the dots are transposed
593 * out of the bitmap here. Line spacing is set to 24 dots (`ESC 0`) for the
594 * duration so consecutive bands butt together instead of leaving white
595 * stripes, and restored to the default (`ESC z 1`) afterwards.
596 *
597 * The image is centred unconditionally, ignoring any enclosing `<align>`.
598 * That is the contract the other three renderers already keep — the preview
599 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
600 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
601 * `<image>` — and inheriting the wrapper's alignment instead would left-align
602 * the bare `<image>` the template editor inserts, which all three show
603 * centred.
604 *
605 * A src that resolves to nothing (a remote URL, a missing file) prints
606 * nothing, and in particular does not disturb the line spacing.
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 // ESC X starts a raster band; close any open text line first.
619 $this->close_open_line();
620
621 $width = $bitmap->width();
622 $height = $bitmap->height();
623
624 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( 'center' ) ) );
625 $this->raw( array( 0x1b, 0x30 ) ); // ESC 0 — 24-dot line spacing.
626
627 for ( $top = 0; $top < $height; $top += 24 ) {
628 $this->raw( array( 0x1b, 0x58, $width & 0xff, ( $width >> 8 ) & 0xff ) );
629
630 $band = '';
631 for ( $x = 0; $x < $width; $x++ ) {
632 for ( $byte_index = 0; $byte_index < 3; $byte_index++ ) {
633 $byte = 0;
634 for ( $bit = 0; $bit < 8; $bit++ ) {
635 // pixel() reads out of range as blank, which is what makes
636 // the last band safe when the height is not a multiple of 24.
637 $byte |= $bitmap->pixel( $x, $top + ( $byte_index * 8 ) + $bit ) << ( 7 - $bit );
638 }
639 $band .= \chr( $byte );
640 }
641 }
642
643 $this->raw_string( $band );
644 $this->raw( array( 0x0a, 0x0d ) );
645 }
646
647 $this->raw( array( 0x1b, 0x7a, 0x01 ) ); // ESC z 1 — default line spacing.
648 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $this->align ) ) );
649 }
650
651 /**
652 * Print a value as a centered plain-text line.
653 *
654 * Mirrors the rescue in Html_Thermal_Emitter::render_barcode_fallback(): when
655 * the symbol cannot be produced, the value itself is still readable.
656 *
657 * Control bytes are folded to spaces first. This is the one path that routes
658 * a barcode value into the text stream, and a barcode value is exactly where
659 * a stray tab, LF or CR turns up — Code 128 validation rejects them on the
660 * ESC/POS lane precisely because code set B cannot encode them, which sends
661 * them here. Emitted raw they would break the line the rescue is centering.
662 *
663 * @param string $value The value to print.
664 *
665 * @return void
666 */
667 private function emit_centered_text( string $value ): void {
668 $text = Thermal_Text_Layout::normalize_text( $this->strip_control_bytes( $value ) );
669 $pad = (int) floor( max( 0, $this->columns - Thermal_Text_Layout::display_width( $text ) ) / 2 );
670 if ( $pad > 0 ) {
671 $this->raw_string( str_repeat( ' ', $pad ) );
672 }
673 $this->raw_string( $text );
674 $this->newline();
675 }
676
677 /**
678 * Replace control bytes with spaces so they cannot reach the print stream.
679 *
680 * @param string $value The value to clean.
681 *
682 * @return string The value with control bytes folded to spaces.
683 */
684 private function strip_control_bytes( string $value ): string {
685 $cleaned = preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value );
686
687 return null === $cleaned ? $value : $cleaned;
688 }
689
690 /**
691 * Emit a model-2 QR code using the ESC GS y command family.
692 *
693 * @param array $node The qrcode AST node.
694 *
695 * @return void
696 */
697 private function emit_qrcode( array $node ): void {
698 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
699 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
700 // Star's QR module size tops out at 8, half the ESC/POS ceiling in
701 // Thermal_Bounds::QRCODE_SIZE_MAX. Device-specific, so it stays here.
702 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( 8, $size ) );
703
704 // ESC GS y prints a QR block; close any open text line first.
705 $this->close_open_line();
706
707 // Select model 2.
708 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x30, 0x02 ) );
709 // Set error correction level (M).
710 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x31, 0x01 ) );
711 // Set cell size.
712 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x32, $size ) );
713
714 // Store data.
715 $data = substr( $value, 0, 0xffff );
716 $p_l = \strlen( $data ) & 0xff;
717 $p_h = ( \strlen( $data ) >> 8 ) & 0xff;
718 $this->raw( array( 0x1b, 0x1d, 0x79, 0x44, 0x31, 0x00, $p_l, $p_h ) );
719 $this->raw_string( $data );
720
721 // Print the stored symbol.
722 $this->raw( array( 0x1b, 0x1d, 0x79, 0x50 ) );
723 }
724
725 /**
726 * Emit a paper cut command (feed-then-cut variants).
727 *
728 * @param array $node The cut AST node.
729 *
730 * @return void
731 */
732 private function emit_cut( array $node ): void {
733 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
734 $this->raw( array( 0x1b, 0x64, 'full' === $cut_type ? 0x02 : 0x03 ) );
735 }
736
737 /**
738 * Emit a paper feed of N lines.
739 *
740 * @param array $node The feed AST node.
741 *
742 * @return void
743 */
744 private function emit_feed( array $node ): void {
745 $lines = Thermal_Bounds::clamp_int(
746 isset( $node['lines'] ) ? $node['lines'] : null,
747 Thermal_Bounds::FEED_LINES_MIN,
748 Thermal_Bounds::FEED_LINES_MIN,
749 Thermal_Bounds::FEED_LINES_MAX
750 );
751 for ( $index = 0; $index < $lines; $index++ ) {
752 $this->raw( array( 0x0a ) );
753 }
754 $this->line_open = false;
755 }
756
757 /**
758 * Emit a single newline.
759 *
760 * @return void
761 */
762 private function newline(): void {
763 $this->raw( array( 0x0a ) );
764 $this->line_open = false;
765 }
766
767 /**
768 * Append a list of ordinal bytes to the output buffer.
769 *
770 * @param array $bytes The ordinal bytes.
771 *
772 * @return void
773 */
774 private function raw( array $bytes ): void {
775 foreach ( $bytes as $byte ) {
776 $this->buffer .= \chr( $byte & 0xff );
777 }
778 }
779
780 /**
781 * Append a raw string to the output buffer.
782 *
783 * @param string $value The string to append.
784 *
785 * @return void
786 */
787 private function raw_string( string $value ): void {
788 $this->buffer .= $value;
789 }
790 }
791