PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.13
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.13
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.13, at includes/Templates/Thermal/Starprnt_Thermal_Emitter.php

814 lines 24.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, $this->effective_magnification( $this->width ) );
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 * The largest magnification ESC i can express: n1/n2 are 0-5, so 6x.
436 *
437 * `Thermal_Bounds::SIZE_MULTIPLIER_MAX` is 8, so a template may legitimately ask for more
438 * than the command can carry.
439 */
440 private const MAX_MAGNIFICATION = 6;
441
442 /**
443 * The magnification the printer will actually apply for a requested multiplier.
444 *
445 * Anything that measures the printed line -- alignment padding above -- must use this and
446 * not the requested value, or a `<size width="8">` line is padded as though its glyphs were
447 * 8 cells wide when ESC i only made them 6, and the line lands off-centre the other way.
448 *
449 * @param int $multiplier The requested width or height multiplier.
450 *
451 * @return int The applied magnification (1..6).
452 */
453 private function effective_magnification( int $multiplier ): int {
454 return max( 1, min( self::MAX_MAGNIFICATION, $multiplier ) );
455 }
456
457 /**
458 * Compute the ESC i magnification byte for a multiplier (0-based, max 6x).
459 *
460 * @param int $multiplier The width or height multiplier.
461 *
462 * @return int The ESC i parameter byte.
463 */
464 private function magnification_byte( int $multiplier ): int {
465 return $this->effective_magnification( $multiplier ) - 1;
466 }
467
468 /**
469 * Emit an alignment-wrapped block using ESC GS a.
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, 0x1d, 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, 0x1d, 0x61, $this->align_byte( $previous ) ) );
484 $this->align = $previous;
485 }
486
487 /**
488 * Map an alignment mode to its ESC GS a parameter byte.
489 *
490 * @param string $mode The alignment mode.
491 *
492 * @return int The ESC GS 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 ESC b, terminated by RS.
561 *
562 * `ESC b n1 n2 n3 n4 <data> RS` — StarPRNT Command Specifications Ver 1.3E,
563 * barcode section. n1 is the symbology (owned by Barcode_Symbology; note
564 * Star numbers the UPC pair the opposite way round to ESC/POS), n2 = 2 for
565 * "HRI under the bars, line feed after printing" — matching the preview, the
566 * PDF and the raster lane, and matching Star's own reference plugin, which
567 * sets n2 = 2 whenever HRI is asked for — n3 = 2 for the medium module width
568 * (valid for every symbology we emit), and n4 is the height in dots, clamped
569 * to the printable 8-255 range.
570 *
571 * A StarPRNT printer handed data its symbology cannot encode discards the
572 * command up to the RS terminator without reporting an error, so an
573 * unencodable value is printed as text instead.
574 *
575 * @param array $node The barcode AST node.
576 *
577 * @return void
578 */
579 private function emit_barcode( array $node ): void {
580 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
581 if ( '' === trim( $value ) ) {
582 return;
583 }
584
585 // Before the validation branch, not after: ESC b starts a barcode block,
586 // and the rescue below centres its text against the full paper width, so
587 // both outcomes need the line closed first.
588 $this->close_open_line();
589
590 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
591 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
592 // The 8-dot floor is Star's, not the markup's: Thermal_Bounds allows a
593 // 1-dot barcode and ESC/POS prints one, but ESC b rejects anything
594 // shorter than 8. A device-specific bound, so it stays here.
595 $height = max( 8, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
596
597 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_STARPRNT ) ) {
598 $this->emit_centered_text( $value );
599
600 return;
601 }
602
603 $this->raw( array( 0x1b, 0x62, Barcode_Symbology::starprnt_id( $type ), 0x02, 0x02, $height ) );
604 $this->raw_string( Barcode_Symbology::starprnt_payload( $type, $value ) );
605 $this->raw( array( 0x1e ) );
606 }
607
608 /**
609 * Print a template `<image>` (in practice, the store logo).
610 *
611 * `ESC X nL nH d1..dk` — Star's column graphics, taken from the StarPRNT
612 * language module of NielsLeenheer/ReceiptPrinterEncoder, the same source the
613 * rest of this emitter was cross-checked against. Star has no row-major
614 * raster command to match ESC/POS `GS v 0`: the image goes out in 24-dot
615 * bands, three bytes per column, top bit first, so the dots are transposed
616 * out of the bitmap here. Line spacing is set to 24 dots (`ESC 0`) for the
617 * duration so consecutive bands butt together instead of leaving white
618 * stripes, and restored to the default (`ESC z 1`) afterwards.
619 *
620 * The image is centred unconditionally, ignoring any enclosing `<align>`.
621 * That is the contract the other three renderers already keep — the preview
622 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
623 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
624 * `<image>` — and inheriting the wrapper's alignment instead would left-align
625 * the bare `<image>` the template editor inserts, which all three show
626 * centred.
627 *
628 * A src that resolves to nothing (a remote URL, a missing file) prints
629 * nothing, and in particular does not disturb the line spacing.
630 *
631 * @param array $node The image AST node.
632 *
633 * @return void
634 */
635 private function emit_image( array $node ): void {
636 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
637 if ( null === $bitmap ) {
638 return;
639 }
640
641 // ESC X starts a raster band; close any open text line first.
642 $this->close_open_line();
643
644 $width = $bitmap->width();
645 $height = $bitmap->height();
646
647 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( 'center' ) ) );
648 $this->raw( array( 0x1b, 0x30 ) ); // ESC 0 — 24-dot line spacing.
649
650 for ( $top = 0; $top < $height; $top += 24 ) {
651 $this->raw( array( 0x1b, 0x58, $width & 0xff, ( $width >> 8 ) & 0xff ) );
652
653 $band = '';
654 for ( $x = 0; $x < $width; $x++ ) {
655 for ( $byte_index = 0; $byte_index < 3; $byte_index++ ) {
656 $byte = 0;
657 for ( $bit = 0; $bit < 8; $bit++ ) {
658 // pixel() reads out of range as blank, which is what makes
659 // the last band safe when the height is not a multiple of 24.
660 $byte |= $bitmap->pixel( $x, $top + ( $byte_index * 8 ) + $bit ) << ( 7 - $bit );
661 }
662 $band .= \chr( $byte );
663 }
664 }
665
666 $this->raw_string( $band );
667 $this->raw( array( 0x0a, 0x0d ) );
668 }
669
670 $this->raw( array( 0x1b, 0x7a, 0x01 ) ); // ESC z 1 — default line spacing.
671 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $this->align ) ) );
672 }
673
674 /**
675 * Print a value as a centered plain-text line.
676 *
677 * Mirrors the rescue in Html_Thermal_Emitter::render_barcode_fallback(): when
678 * the symbol cannot be produced, the value itself is still readable.
679 *
680 * Control bytes are folded to spaces first. This is the one path that routes
681 * a barcode value into the text stream, and a barcode value is exactly where
682 * a stray tab, LF or CR turns up — Code 128 validation rejects them on the
683 * ESC/POS lane precisely because code set B cannot encode them, which sends
684 * them here. Emitted raw they would break the line the rescue is centering.
685 *
686 * @param string $value The value to print.
687 *
688 * @return void
689 */
690 private function emit_centered_text( string $value ): void {
691 $text = Thermal_Text_Layout::normalize_text( $this->strip_control_bytes( $value ) );
692 $pad = (int) floor( max( 0, $this->columns - Thermal_Text_Layout::display_width( $text ) ) / 2 );
693 if ( $pad > 0 ) {
694 $this->raw_string( str_repeat( ' ', $pad ) );
695 }
696 $this->raw_string( $text );
697 $this->newline();
698 }
699
700 /**
701 * Replace control bytes with spaces so they cannot reach the print stream.
702 *
703 * @param string $value The value to clean.
704 *
705 * @return string The value with control bytes folded to spaces.
706 */
707 private function strip_control_bytes( string $value ): string {
708 $cleaned = preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value );
709
710 return null === $cleaned ? $value : $cleaned;
711 }
712
713 /**
714 * Emit a model-2 QR code using the ESC GS y command family.
715 *
716 * @param array $node The qrcode AST node.
717 *
718 * @return void
719 */
720 private function emit_qrcode( array $node ): void {
721 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
722 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
723 // Star's QR module size tops out at 8, half the ESC/POS ceiling in
724 // Thermal_Bounds::QRCODE_SIZE_MAX. Device-specific, so it stays here.
725 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( 8, $size ) );
726
727 // ESC GS y prints a QR block; close any open text line first.
728 $this->close_open_line();
729
730 // Select model 2.
731 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x30, 0x02 ) );
732 // Set error correction level (M).
733 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x31, 0x01 ) );
734 // Set cell size.
735 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x32, $size ) );
736
737 // Store data.
738 $data = substr( $value, 0, 0xffff );
739 $p_l = \strlen( $data ) & 0xff;
740 $p_h = ( \strlen( $data ) >> 8 ) & 0xff;
741 $this->raw( array( 0x1b, 0x1d, 0x79, 0x44, 0x31, 0x00, $p_l, $p_h ) );
742 $this->raw_string( $data );
743
744 // Print the stored symbol.
745 $this->raw( array( 0x1b, 0x1d, 0x79, 0x50 ) );
746 }
747
748 /**
749 * Emit a paper cut command (feed-then-cut variants).
750 *
751 * @param array $node The cut AST node.
752 *
753 * @return void
754 */
755 private function emit_cut( array $node ): void {
756 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
757 $this->raw( array( 0x1b, 0x64, 'full' === $cut_type ? 0x02 : 0x03 ) );
758 }
759
760 /**
761 * Emit a paper feed of N lines.
762 *
763 * @param array $node The feed AST node.
764 *
765 * @return void
766 */
767 private function emit_feed( array $node ): void {
768 $lines = Thermal_Bounds::clamp_int(
769 isset( $node['lines'] ) ? $node['lines'] : null,
770 Thermal_Bounds::FEED_LINES_MIN,
771 Thermal_Bounds::FEED_LINES_MIN,
772 Thermal_Bounds::FEED_LINES_MAX
773 );
774 for ( $index = 0; $index < $lines; $index++ ) {
775 $this->raw( array( 0x0a ) );
776 }
777 $this->line_open = false;
778 }
779
780 /**
781 * Emit a single newline.
782 *
783 * @return void
784 */
785 private function newline(): void {
786 $this->raw( array( 0x0a ) );
787 $this->line_open = false;
788 }
789
790 /**
791 * Append a list of ordinal bytes to the output buffer.
792 *
793 * @param array $bytes The ordinal bytes.
794 *
795 * @return void
796 */
797 private function raw( array $bytes ): void {
798 foreach ( $bytes as $byte ) {
799 $this->buffer .= \chr( $byte & 0xff );
800 }
801 }
802
803 /**
804 * Append a raw string to the output buffer.
805 *
806 * @param string $value The string to append.
807 *
808 * @return void
809 */
810 private function raw_string( string $value ): void {
811 $this->buffer .= $value;
812 }
813 }
814