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

666 lines 19.6 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 use Thermal_Emitter_Support;
46
47 /**
48 * Largest StarPRNT text multiplier: ESC i n1/n2 accept 0-5 (1-6x).
49 */
50 private const MAX_MAGNIFICATION = 6;
51
52 /**
53 * Render options.
54 *
55 * @var array
56 */
57 private $options = array();
58
59 /**
60 * Accumulated output bytes.
61 *
62 * @var string
63 */
64 private $buffer = '';
65
66 /**
67 * Per-job text metrics and applied size stack.
68 *
69 * @var Thermal_Text_Layout
70 */
71 private $layout;
72
73 /**
74 * The current alignment mode (left|center|right).
75 *
76 * @var string
77 */
78 private $align = 'left';
79
80 /**
81 * Whether bold is currently active.
82 *
83 * @var bool
84 */
85 private $bold = false;
86
87 /**
88 * Whether underline is currently active.
89 *
90 * @var bool
91 */
92 private $underline = false;
93
94 /**
95 * Whether invert is currently active.
96 *
97 * @var bool
98 */
99 private $invert = false;
100
101 /**
102 * Whether unterminated text is sitting in the printer's line buffer.
103 *
104 * Star's graphics and barcode commands, like their ESC/POS counterparts, are
105 * line-oriented: `ESC X` starts a raster band and `ESC b` a barcode, and both
106 * expect to begin at the start of a line. Bare text in a template
107 * (`<receipt>Total<image/></receipt>`) parses to a `raw-text` node, which
108 * prints without a terminator, so the emitter tracks whether a line is open.
109 *
110 * @var bool
111 */
112 private $line_open = false;
113
114 /**
115 * Constructor.
116 *
117 * @param array $options Render options.
118 */
119 public function __construct( array $options = array() ) {
120 $this->options = $options;
121 }
122
123 /**
124 * Emit native StarPRNT bytes from a thermal AST.
125 *
126 * @param array $ast The thermal AST root (a receipt node).
127 *
128 * @return string The raw StarPRNT bytes.
129 */
130 public function emit( array $ast ): string {
131 $this->buffer = '';
132 $this->align = 'left';
133 $this->bold = false;
134 $this->underline = false;
135 $this->invert = false;
136 $this->line_open = false;
137
138 $this->layout = new Thermal_Text_Layout( isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48, self::MAX_MAGNIFICATION );
139
140 // ESC GS ) U — select UTF-8 encoding, then the companion font/width
141 // setting, per Star's reference implementation. No initialize command:
142 // CloudPRNT jobs must not reset the printer.
143 $this->raw( array( 0x1b, 0x1d, 0x29, 0x55, 0x02, 0x00, 0x30, 0x01 ) );
144 $this->raw( array( 0x1b, 0x1d, 0x29, 0x55, 0x02, 0x00, 0x40, 0x00 ) );
145
146 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
147 $this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );
148
149 return $this->buffer;
150 }
151
152 /**
153 * Emit a StarPRNT drawer pulse.
154 *
155 * ESC BEL sets the pulse width (on/off in 10ms units), then the trigger
156 * byte fires the peripheral: 0x07 for device 1 (pin2), 0x1A for device 2
157 * (pin5).
158 *
159 * @param string $connector Drawer connector.
160 */
161 private function emit_drawer_pulse( string $connector ): void {
162 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
163 $trigger = 'pin5' === $connector ? 0x1a : 0x07;
164
165 $this->raw( array( 0x1b, 0x07, 0x0a, 0x0a, $trigger ) );
166 }
167
168 /**
169 * Walk a single AST node.
170 *
171 * @param array $node The AST node.
172 *
173 * @return void
174 */
175 private function walk_node( array $node ): void {
176 $type = isset( $node['type'] ) ? $node['type'] : '';
177
178 switch ( $type ) {
179 case 'raw-text':
180 $this->emit_inline_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
181 break;
182 case 'text':
183 $this->emit_text_line( isset( $node['children'] ) ? $node['children'] : array() );
184 break;
185 case 'bold':
186 $this->emit_bold( $node );
187 break;
188 case 'underline':
189 $this->emit_underline( $node );
190 break;
191 case 'invert':
192 $this->emit_invert( $node );
193 break;
194 case 'size':
195 $this->emit_size( $node );
196 break;
197 case 'align':
198 $this->emit_align( $node );
199 break;
200 case 'row':
201 $this->emit_row( $node );
202 break;
203 case 'line':
204 $this->emit_line( $node );
205 break;
206 case 'barcode':
207 $this->emit_barcode( $node );
208 break;
209 case 'qrcode':
210 $this->emit_qrcode( $node );
211 break;
212 case 'image':
213 $this->emit_image( $node );
214 break;
215 case 'cut':
216 $this->emit_cut( $node );
217 break;
218 case 'feed':
219 $this->emit_feed( $node );
220 break;
221 case 'drawer':
222 $this->emit_drawer_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
223 break;
224 case 'receipt':
225 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
226 break;
227 }
228 }
229
230 /**
231 * Emit inline (styled) text bytes for the current line.
232 *
233 * @param string $value The raw text value.
234 *
235 * @return void
236 */
237 private function emit_inline_text( string $value ): void {
238 $text = Thermal_Text_Layout::normalize_text( $value );
239 if ( '' === $text ) {
240 return;
241 }
242
243 $this->raw_string( $text );
244
245 // The parser preserves a text node verbatim, newlines included, so this
246 // may have ended the line itself — `<receipt>Total\n<image/></receipt>`
247 // leaves the printer at column zero. Reading the state off the bytes just
248 // written keeps close_open_line() from spending a second line feed there.
249 $this->line_open = "\n" !== substr( $text, -1 );
250 }
251
252 /**
253 * Close an open line so a line-oriented command can start cleanly.
254 *
255 * @return void
256 */
257 private function close_open_line(): void {
258 if ( $this->line_open ) {
259 $this->newline();
260 }
261 }
262
263 /**
264 * Emit a single printed text line (the children, padding, then a newline).
265 *
266 * @param array $children The child nodes of the text node.
267 *
268 * @return void
269 */
270 private function emit_text_line( array $children ): void {
271 if ( 'left' !== $this->align ) {
272 $plain = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( $children ) );
273 $pad = $this->layout->measure_padding( $this->align, $plain );
274 if ( $pad > 0 ) {
275 $this->raw_string( str_repeat( ' ', $pad ) );
276 }
277 }
278 $this->walk_nodes( $children );
279 $this->newline();
280 }
281
282 /**
283 * Emit a bold-wrapped block using ESC E / ESC F.
284 *
285 * @param array $node The bold AST node.
286 *
287 * @return void
288 */
289 private function emit_bold( array $node ): void {
290 $previous = $this->bold;
291 $this->raw( array( 0x1b, 0x45 ) );
292 $this->bold = true;
293 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
294 $this->raw( $previous ? array( 0x1b, 0x45 ) : array( 0x1b, 0x46 ) );
295 $this->bold = $previous;
296 }
297
298 /**
299 * Emit an underline-wrapped block using ESC - n.
300 *
301 * @param array $node The underline AST node.
302 *
303 * @return void
304 */
305 private function emit_underline( array $node ): void {
306 $previous = $this->underline;
307 $this->raw( array( 0x1b, 0x2d, 0x01 ) );
308 $this->underline = true;
309 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
310 $this->raw( array( 0x1b, 0x2d, $previous ? 0x01 : 0x00 ) );
311 $this->underline = $previous;
312 }
313
314 /**
315 * Emit an invert-wrapped block using ESC 4 / ESC 5.
316 *
317 * @param array $node The invert AST node.
318 *
319 * @return void
320 */
321 private function emit_invert( array $node ): void {
322 $previous = $this->invert;
323 $this->raw( array( 0x1b, 0x34 ) );
324 $this->invert = true;
325 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
326 $this->raw( $previous ? array( 0x1b, 0x34 ) : array( 0x1b, 0x35 ) );
327 $this->invert = $previous;
328 }
329
330 /**
331 * Emit a size-wrapped block using ESC i (height, width).
332 *
333 * @param array $node The size AST node.
334 *
335 * @return void
336 */
337 private function emit_size( array $node ): void {
338 $this->layout->enter_size(
339 is_numeric( $node['width'] ?? null ) ? (int) $node['width'] : 1,
340 is_numeric( $node['height'] ?? null ) ? (int) $node['height'] : 1
341 );
342 $this->emit_applied_size();
343 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
344 $this->layout->leave_size();
345 $this->emit_applied_size();
346 }
347
348 /**
349 * Encode the applied scale as ESC i (height, width), zero-based.
350 *
351 * @return void
352 */
353 private function emit_applied_size(): void {
354 $scale = $this->layout->applied_scale();
355 $this->raw( array( 0x1b, 0x69, $scale['height'] - 1, $scale['width'] - 1 ) );
356 }
357
358 /**
359 * Emit an alignment-wrapped block using ESC GS a.
360 *
361 * @param array $node The align AST node.
362 *
363 * @return void
364 */
365 private function emit_align( array $node ): void {
366 $previous = $this->align;
367 $mode = isset( $node['mode'] ) ? $node['mode'] : 'left';
368 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $mode ) ) );
369 $this->align = $mode;
370
371 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
372
373 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $previous ) ) );
374 $this->align = $previous;
375 }
376
377 /**
378 * Map an alignment mode to its ESC GS a parameter byte.
379 *
380 * @param string $mode The alignment mode.
381 *
382 * @return int The ESC GS a parameter byte.
383 */
384 private function align_byte( string $mode ): int {
385 if ( 'center' === $mode ) {
386 return 0x01;
387 }
388 if ( 'right' === $mode ) {
389 return 0x02;
390 }
391
392 return 0x00;
393 }
394
395 /**
396 * Emit a row as one physical line followed by a newline.
397 *
398 * @param array $node The row AST node.
399 *
400 * @return void
401 */
402 private function emit_row( array $node ): void {
403 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
404 $widths = $this->layout->measure_row_widths( $cols );
405
406 $line = '';
407 foreach ( $cols as $index => $col ) {
408 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
409 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
410 $text = Thermal_Text_Layout::truncate_display( $text, $width );
411 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
412 $align = isset( $col['align'] ) ? $col['align'] : 'left';
413 if ( 'right' === $align ) {
414 $line .= str_repeat( ' ', $pad ) . $text;
415 } else {
416 $line .= $text . str_repeat( ' ', $pad );
417 }
418 }
419
420 $this->raw_string( $line );
421 $this->newline();
422 }
423
424 /**
425 * Emit a horizontal rule line.
426 *
427 * @param array $node The line AST node.
428 *
429 * @return void
430 */
431 private function emit_line( array $node ): void {
432 $style = isset( $node['style'] ) ? $node['style'] : 'single';
433
434 if ( 'dotted' === $style ) {
435 $pattern = '. ';
436 $repeat = (int) ceil( $this->layout->columns() / \strlen( $pattern ) );
437 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->layout->columns() );
438 } elseif ( 'double' === $style ) {
439 $text = str_repeat( '=', $this->layout->columns() );
440 } else {
441 // single and dashed both render as '-' across the width.
442 $text = str_repeat( '-', $this->layout->columns() );
443 }
444
445 $this->raw_string( $text );
446 $this->newline();
447 }
448
449 /**
450 * Emit a 1D barcode using ESC b, terminated by RS.
451 *
452 * `ESC b n1 n2 n3 n4 <data> RS` — StarPRNT Command Specifications Ver 1.3E,
453 * barcode section. n1 is the symbology (owned by Barcode_Symbology; note
454 * Star numbers the UPC pair the opposite way round to ESC/POS), n2 = 2 for
455 * "HRI under the bars, line feed after printing" — matching the preview, the
456 * PDF and the raster lane, and matching Star's own reference plugin, which
457 * sets n2 = 2 whenever HRI is asked for — n3 = 2 for the medium module width
458 * (valid for every symbology we emit), and n4 is the height in dots, clamped
459 * to the printable 8-255 range.
460 *
461 * A StarPRNT printer handed data its symbology cannot encode discards the
462 * command up to the RS terminator without reporting an error, so an
463 * unencodable value is printed as text instead.
464 *
465 * @param array $node The barcode AST node.
466 *
467 * @return void
468 */
469 private function emit_barcode( array $node ): void {
470 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
471 if ( '' === trim( $value ) ) {
472 return;
473 }
474
475 // Before the validation branch, not after: ESC b starts a barcode block,
476 // and the rescue below centres its text against the full paper width, so
477 // both outcomes need the line closed first.
478 $this->close_open_line();
479
480 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
481 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
482 // The 8-dot floor is Star's, not the markup's: Thermal_Bounds allows a
483 // 1-dot barcode and ESC/POS prints one, but ESC b rejects anything
484 // shorter than 8. A device-specific bound, so it stays here.
485 $height = max( 8, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );
486
487 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_STARPRNT ) ) {
488 $this->raw_string( $this->centered_text( $value, $this->layout->columns() ) );
489 $this->newline();
490
491 return;
492 }
493
494 $this->raw( array( 0x1b, 0x62, Barcode_Symbology::starprnt_id( $type ), 0x02, 0x02, $height ) );
495 $this->raw_string( Barcode_Symbology::starprnt_payload( $type, $value ) );
496 $this->raw( array( 0x1e ) );
497 }
498
499 /**
500 * Print a template `<image>` (in practice, the store logo).
501 *
502 * `ESC X nL nH d1..dk` — Star's column graphics, taken from the StarPRNT
503 * language module of NielsLeenheer/ReceiptPrinterEncoder, the same source the
504 * rest of this emitter was cross-checked against. Star has no row-major
505 * raster command to match ESC/POS `GS v 0`: the image goes out in 24-dot
506 * bands, three bytes per column, top bit first, so the dots are transposed
507 * out of the bitmap here. Line spacing is set to 24 dots (`ESC 0`) for the
508 * duration so consecutive bands butt together instead of leaving white
509 * stripes, and restored to the default (`ESC z 1`) afterwards.
510 *
511 * The image is centred unconditionally, ignoring any enclosing `<align>`.
512 * That is the contract the other three renderers already keep — the preview
513 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
514 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
515 * `<image>` — and inheriting the wrapper's alignment instead would left-align
516 * the bare `<image>` the template editor inserts, which all three show
517 * centred.
518 *
519 * A src that resolves to nothing (a remote URL, a missing file) prints
520 * nothing, and in particular does not disturb the line spacing.
521 *
522 * @param array $node The image AST node.
523 *
524 * @return void
525 */
526 private function emit_image( array $node ): void {
527 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->layout->columns() ) );
528 if ( null === $bitmap ) {
529 return;
530 }
531
532 // ESC X starts a raster band; close any open text line first.
533 $this->close_open_line();
534
535 $width = $bitmap->width();
536 $height = $bitmap->height();
537
538 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( 'center' ) ) );
539 $this->raw( array( 0x1b, 0x30 ) ); // ESC 0 — 24-dot line spacing.
540
541 for ( $top = 0; $top < $height; $top += 24 ) {
542 $this->raw( array( 0x1b, 0x58, $width & 0xff, ( $width >> 8 ) & 0xff ) );
543
544 $band = '';
545 for ( $x = 0; $x < $width; $x++ ) {
546 for ( $byte_index = 0; $byte_index < 3; $byte_index++ ) {
547 $byte = 0;
548 for ( $bit = 0; $bit < 8; $bit++ ) {
549 // pixel() reads out of range as blank, which is what makes
550 // the last band safe when the height is not a multiple of 24.
551 $byte |= $bitmap->pixel( $x, $top + ( $byte_index * 8 ) + $bit ) << ( 7 - $bit );
552 }
553 $band .= \chr( $byte );
554 }
555 }
556
557 $this->raw_string( $band );
558 $this->raw( array( 0x0a, 0x0d ) );
559 }
560
561 $this->raw( array( 0x1b, 0x7a, 0x01 ) ); // ESC z 1 — default line spacing.
562 $this->raw( array( 0x1b, 0x1d, 0x61, $this->align_byte( $this->align ) ) );
563 }
564
565 /**
566 * Emit a model-2 QR code using the ESC GS y command family.
567 *
568 * @param array $node The qrcode AST node.
569 *
570 * @return void
571 */
572 private function emit_qrcode( array $node ): void {
573 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
574 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
575 // Star's QR module size tops out at 8, half the ESC/POS ceiling in
576 // Thermal_Bounds::QRCODE_SIZE_MAX. Device-specific, so it stays here.
577 $size = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( 8, $size ) );
578
579 // ESC GS y prints a QR block; close any open text line first.
580 $this->close_open_line();
581
582 // Select model 2.
583 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x30, 0x02 ) );
584 // Set error correction level (M).
585 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x31, 0x01 ) );
586 // Set cell size.
587 $this->raw( array( 0x1b, 0x1d, 0x79, 0x53, 0x32, $size ) );
588
589 // Store data.
590 $data = substr( $value, 0, 0xffff );
591 $p_l = \strlen( $data ) & 0xff;
592 $p_h = ( \strlen( $data ) >> 8 ) & 0xff;
593 $this->raw( array( 0x1b, 0x1d, 0x79, 0x44, 0x31, 0x00, $p_l, $p_h ) );
594 $this->raw_string( $data );
595
596 // Print the stored symbol.
597 $this->raw( array( 0x1b, 0x1d, 0x79, 0x50 ) );
598 }
599
600 /**
601 * Emit a paper cut command (feed-then-cut variants).
602 *
603 * @param array $node The cut AST node.
604 *
605 * @return void
606 */
607 private function emit_cut( array $node ): void {
608 $cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
609 $this->raw( array( 0x1b, 0x64, 'full' === $cut_type ? 0x02 : 0x03 ) );
610 }
611
612 /**
613 * Emit a paper feed of N lines.
614 *
615 * @param array $node The feed AST node.
616 *
617 * @return void
618 */
619 private function emit_feed( array $node ): void {
620 $lines = Thermal_Bounds::clamp_int(
621 isset( $node['lines'] ) ? $node['lines'] : null,
622 Thermal_Bounds::FEED_LINES_MIN,
623 Thermal_Bounds::FEED_LINES_MIN,
624 Thermal_Bounds::FEED_LINES_MAX
625 );
626 for ( $index = 0; $index < $lines; $index++ ) {
627 $this->raw( array( 0x0a ) );
628 }
629 $this->line_open = false;
630 }
631
632 /**
633 * Emit a single newline.
634 *
635 * @return void
636 */
637 private function newline(): void {
638 $this->raw( array( 0x0a ) );
639 $this->line_open = false;
640 }
641
642 /**
643 * Append a list of ordinal bytes to the output buffer.
644 *
645 * @param array $bytes The ordinal bytes.
646 *
647 * @return void
648 */
649 private function raw( array $bytes ): void {
650 foreach ( $bytes as $byte ) {
651 $this->buffer .= \chr( $byte & 0xff );
652 }
653 }
654
655 /**
656 * Append a raw string to the output buffer.
657 *
658 * @param string $value The string to append.
659 *
660 * @return void
661 */
662 private function raw_string( string $value ): void {
663 $this->buffer .= $value;
664 }
665 }
666