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

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

688 lines 19.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Epson ePOS-Print XML Thermal Emitter Class.
4 *
5 * Maps a thermal AST (produced by Thermal_Markup_Parser) to Epson ePOS-Print
6 * XML for use with Server Direct Print. The emitted document uses the same
7 * namespace and escaping conventions as Epos_Xml_Output_Adapter.
8 *
9 * This is the template-driven counterpart to
10 * `WCPOS\WooCommercePOS\Templates\Adapters\Epos_Xml_Output_Adapter`, which emits a
11 * fixed, non-template layout from canonical receipt data.
12 *
13 * Deliberate deviations / limitations:
14 * - ePOS `<text>` attributes are persistent printer state, so the emitter
15 * tracks that state and emits only changes after a reset preamble.
16 * - Double rules (`<line style="double"/>`) are emitted as ASCII `=` repeated
17 * across the paper width (consistent with the ESC/POS emitter) rather than a
18 * box-drawing glyph, so output is codepage-independent.
19 * - Paper cuts (`<cut>`), both full and partial, map to `<cut type="feed"/>`.
20 * - Images (`<image>`) are thresholded to 1-bit dots by Thermal_Bitmap and sent
21 * as base64 in an `<image>` element.
22 * - Text is emitted as plain UTF-8 (Epson handles UTF-8), but still passes
23 * through Thermal_Text_Layout::normalize_text(): the typographic spaces and
24 * dashes it folds are not in any printer character table, and a printer that
25 * cannot map a codepoint substitutes `?` on the paper.
26 *
27 * @author Paul Kilmurray <[email protected]>
28 *
29 * @see http://wcpos.com
30 * @package WCPOS\WooCommercePOS
31 */
32
33 namespace WCPOS\WooCommercePOS\Templates\Thermal;
34
35 use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;
36
37 /**
38 * Epos_Xml_Thermal_Emitter class.
39 */
40 class Epos_Xml_Thermal_Emitter {
41
42 /**
43 * Render options.
44 *
45 * @var array
46 */
47 private $options = array();
48
49 /**
50 * Accumulated output XML.
51 *
52 * @var string
53 */
54 private $buffer = '';
55
56 /**
57 * The paper width in character columns.
58 *
59 * @var int
60 */
61 private $columns = 48;
62
63 /**
64 * The current alignment mode (left|center|right).
65 *
66 * @var string
67 */
68 private $align = 'left';
69
70 /**
71 * Whether bold (emphasis) is currently active.
72 *
73 * @var bool
74 */
75 private $em = false;
76
77 /**
78 * Whether underline is currently active.
79 *
80 * @var bool
81 */
82 private $ul = false;
83
84 /**
85 * Whether reverse (invert) is currently active.
86 *
87 * @var bool
88 */
89 private $reverse = false;
90
91 /**
92 * Whether double-width is currently active.
93 *
94 * @var bool
95 */
96 private $dw = false;
97
98 /**
99 * Whether double-height is currently active.
100 *
101 * @var bool
102 */
103 private $dh = false;
104
105 /**
106 * The text style state currently held by the printer.
107 *
108 * A null value means the state is unknown and must be re-emitted.
109 *
110 * @var array
111 */
112 private $printer = array(
113 'align' => null,
114 'em' => null,
115 'ul' => null,
116 'reverse' => null,
117 'dw' => null,
118 'dh' => null,
119 );
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 ePOS-Print XML from a thermal AST.
132 *
133 * @param array $ast The thermal AST root (a receipt node).
134 *
135 * @return string The ePOS-Print XML document.
136 */
137 public function emit( array $ast ): string {
138 $this->buffer = '';
139 $this->align = 'left';
140 $this->em = false;
141 $this->ul = false;
142 $this->reverse = false;
143 $this->dw = false;
144 $this->dh = false;
145
146 $this->columns = isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48;
147
148 $this->buffer .= '<epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">';
149
150 // The printer still holds whatever the previous job left, so treat every
151 // attribute as unknown and let the transition write the full reset preamble.
152 $this->printer = array_fill_keys( array( 'align', 'em', 'ul', 'reverse', 'dw', 'dh' ), null );
153 $this->buffer .= '<text' . $this->style_transition() . '/>';
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 $this->buffer .= '</epos-print>';
159
160 return $this->buffer;
161 }
162
163 /**
164 * Walk a list of AST nodes.
165 *
166 * @param array $nodes The AST nodes.
167 *
168 * @return void
169 */
170 private function walk_nodes( array $nodes ): void {
171 foreach ( $nodes as $node ) {
172 if ( \is_array( $node ) ) {
173 $this->walk_node( $node );
174 }
175 }
176 }
177
178 /**
179 * Insert an auto drawer node before the first trailing cut when enabled.
180 *
181 * @param array $nodes AST nodes.
182 *
183 * @return array
184 */
185 private function nodes_with_auto_drawer( array $nodes ): array {
186 if ( empty( $this->options['auto_open_drawer'] ) || $this->nodes_contain_drawer( $nodes ) ) {
187 return $nodes;
188 }
189
190 $drawer = array(
191 'type' => 'drawer',
192 'connector' => \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( (string) ( $this->options['drawer_connector'] ?? 'pin2' ) ),
193 );
194
195 for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
196 $type = isset( $nodes[ $i ]['type'] ) ? (string) $nodes[ $i ]['type'] : '';
197 if ( 'cut' === $type ) {
198 array_splice( $nodes, $i, 0, array( $drawer ) );
199 return $nodes;
200 }
201 if ( in_array( $type, array( 'feed' ), true ) ) {
202 continue;
203 }
204 break;
205 }
206
207 $nodes[] = $drawer;
208 return $nodes;
209 }
210
211 /**
212 * Whether a node list contains an explicit drawer node.
213 *
214 * @param array $nodes AST nodes.
215 *
216 * @return bool
217 */
218 private function nodes_contain_drawer( array $nodes ): bool {
219 foreach ( $nodes as $node ) {
220 if ( ! is_array( $node ) ) {
221 continue;
222 }
223 if ( 'drawer' === ( $node['type'] ?? '' ) ) {
224 return true;
225 }
226 if ( ! empty( $node['children'] ) && is_array( $node['children'] ) && $this->nodes_contain_drawer( $node['children'] ) ) {
227 return true;
228 }
229 }
230
231 return false;
232 }
233
234 /**
235 * Emit an Epson ePOS-Print drawer pulse.
236 *
237 * @param string $connector Drawer connector.
238 */
239 private function emit_pulse( string $connector ): void {
240 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
241 $drawer = 'pin5' === $connector ? 'drawer_2' : 'drawer_1';
242 $this->buffer .= '<pulse drawer="' . $drawer . '" time="pulse_100"/>';
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_text_element( isset( $node['value'] ) ? (string) $node['value'] : '' );
258 break;
259 case 'text':
260 $this->emit_text_node( $node );
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->buffer .= '<cut type="feed"/>';
294 break;
295 case 'feed':
296 $this->emit_feed( $node );
297 break;
298 case 'drawer':
299 $this->emit_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 a single <text> element from a text node, unioning the current style
309 * state with any style wrappers found within the node's own subtree.
310 *
311 * @param array $node The text AST node.
312 *
313 * @return void
314 */
315 private function emit_text_node( array $node ): void {
316 $children = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
317
318 $previous_em = $this->em;
319 $previous_ul = $this->ul;
320 $previous_reverse = $this->reverse;
321 $previous_dw = $this->dw;
322 $previous_dh = $this->dh;
323
324 $this->collect_subtree_styles( $children );
325
326 $content = Thermal_Text_Layout::extract_text( $children );
327 $this->emit_text_element( $content );
328
329 $this->em = $previous_em;
330 $this->ul = $previous_ul;
331 $this->reverse = $previous_reverse;
332 $this->dw = $previous_dw;
333 $this->dh = $previous_dh;
334 }
335
336 /**
337 * Union the style flags implied by wrappers within a node subtree.
338 *
339 * @param array $nodes The AST nodes to scan.
340 *
341 * @return void
342 */
343 private function collect_subtree_styles( array $nodes ): void {
344 foreach ( $nodes as $node ) {
345 if ( ! \is_array( $node ) ) {
346 continue;
347 }
348 $type = isset( $node['type'] ) ? $node['type'] : '';
349 if ( 'bold' === $type ) {
350 $this->em = true;
351 } elseif ( 'underline' === $type ) {
352 $this->ul = true;
353 } elseif ( 'invert' === $type ) {
354 $this->reverse = true;
355 } elseif ( 'size' === $type ) {
356 $width = isset( $node['width'] ) ? (int) $node['width'] : 1;
357 $height = isset( $node['height'] ) ? (int) $node['height'] : 1;
358 if ( $width > 1 ) {
359 $this->dw = true;
360 }
361 if ( $height > 1 ) {
362 $this->dh = true;
363 }
364 }
365 if ( isset( $node['children'] ) && \is_array( $node['children'] ) ) {
366 $this->collect_subtree_styles( $node['children'] );
367 }
368 }
369 }
370
371 /**
372 * Emit a single <text> line in the current style state.
373 *
374 * @param string $content The plain text content (will be XML-escaped).
375 * @param string|null $align Alignment override for this line (rows are
376 * pre-padded and always print left-aligned).
377 *
378 * @return void
379 */
380 private function emit_text_element( string $content, ?string $align = null ): void {
381 // normalize_text() is a 1:1 character substitution, so it cannot change a
382 // display width the row/rule callers have already padded against.
383 $content = Thermal_Text_Layout::normalize_text( $content );
384 $this->buffer .= '<text' . $this->style_transition( $align ) . '>' . $this->escape( $content ) . "\n" . '</text>';
385 }
386
387 /**
388 * Emit a template `<image>` (in practice, the store logo).
389 *
390 * The dots go out as base64 raw raster — 1 bit per dot, MSB first, rows whole
391 * bytes — not as an encoded PNG (ePOS-Print XML User's Manual, "Encoding
392 * Graphic Data"). Thermal_Bitmap produces exactly that layout, and
393 * resolves the src without an outbound request, which matters because this
394 * runs inside the printer's job fetch.
395 *
396 * The image is centred unconditionally, ignoring any enclosing `<align>`.
397 * That is the contract the other three renderers already keep — the preview
398 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
399 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
400 * `<image>` — and following the wrapper here instead would left-align the
401 * bare `<image>` that the template editor inserts, which every one of those
402 * three shows centred.
403 *
404 * A src that resolves to nothing (a remote URL, a missing file) emits
405 * nothing: a receipt without its logo still prints, where a broken `<image>`
406 * element risks the printer rejecting the whole job.
407 *
408 * @param array $node The image AST node.
409 *
410 * @return void
411 */
412 private function emit_image( array $node ): void {
413 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
414 if ( null === $bitmap ) {
415 return;
416 }
417
418 // align is a documented <image> attribute, not borrowed from <text>:
419 // ePOS-Print XML User's Manual, chapter 4, lists left/center/right on this
420 // element and defaults it to "left".
421 $this->buffer .= '<image width="' . $bitmap->width() . '" height="' . $bitmap->height() . '"'
422 . ' align="center" color="color_1" mode="mono">'
423 . base64_encode( $bitmap->raster() )
424 . '</image>';
425
426 // The manual's note on <text align> — "the align setting specified in this
427 // element is also applied to <image>, <logo>, <barcode> and <symbol>" —
428 // runs both ways: this <image> has moved the printer's persistent
429 // alignment, so the tracked state can no longer be trusted.
430 $this->printer['align'] = null;
431 }
432
433 /**
434 * Build the <text> attributes that move the printer to the current style,
435 * and record the printer as now holding that style.
436 *
437 * The attributes persist on the printer until changed, so only the values
438 * that differ from what it holds are written; a null entry in $printer
439 * (unknown) is always written.
440 *
441 * @param string|null $align Alignment override; null uses the wrapper state.
442 *
443 * @return string The attribute string (with a leading space when non-empty).
444 */
445 private function style_transition( ?string $align = null ): string {
446 $desired = array(
447 'align' => null === $align ? $this->align : $align,
448 'em' => $this->em,
449 'ul' => $this->ul,
450 'reverse' => $this->reverse,
451 'dw' => $this->dw,
452 'dh' => $this->dh,
453 );
454 $attrs = '';
455 foreach ( $desired as $name => $value ) {
456 if ( $this->printer[ $name ] !== $value ) {
457 $attrs .= ' ' . $name . '="' . ( \is_bool( $value ) ? ( $value ? 'true' : 'false' ) : $value ) . '"';
458 }
459 }
460 $this->printer = $desired;
461
462 return $attrs;
463 }
464
465 /**
466 * Emit a bold-wrapped block.
467 *
468 * @param array $node The bold AST node.
469 *
470 * @return void
471 */
472 private function emit_bold( array $node ): void {
473 $previous = $this->em;
474 $this->em = true;
475 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
476 $this->em = $previous;
477 }
478
479 /**
480 * Emit an underline-wrapped block.
481 *
482 * @param array $node The underline AST node.
483 *
484 * @return void
485 */
486 private function emit_underline( array $node ): void {
487 $previous = $this->ul;
488 $this->ul = true;
489 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
490 $this->ul = $previous;
491 }
492
493 /**
494 * Emit an invert-wrapped block.
495 *
496 * @param array $node The invert AST node.
497 *
498 * @return void
499 */
500 private function emit_invert( array $node ): void {
501 $previous = $this->reverse;
502 $this->reverse = true;
503 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
504 $this->reverse = $previous;
505 }
506
507 /**
508 * Emit a size-wrapped block.
509 *
510 * @param array $node The size AST node.
511 *
512 * @return void
513 */
514 private function emit_size( array $node ): void {
515 $previous_dw = $this->dw;
516 $previous_dh = $this->dh;
517 $width = isset( $node['width'] ) ? (int) $node['width'] : 1;
518 $height = isset( $node['height'] ) ? (int) $node['height'] : 1;
519
520 if ( $width > 1 ) {
521 $this->dw = true;
522 }
523 if ( $height > 1 ) {
524 $this->dh = true;
525 }
526
527 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
528
529 $this->dw = $previous_dw;
530 $this->dh = $previous_dh;
531 }
532
533 /**
534 * Emit an alignment-wrapped block.
535 *
536 * @param array $node The align AST node.
537 *
538 * @return void
539 */
540 private function emit_align( array $node ): void {
541 $previous = $this->align;
542 $this->align = isset( $node['mode'] ) ? (string) $node['mode'] : 'left';
543 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
544 $this->align = $previous;
545 }
546
547 /**
548 * Emit a row as one left-aligned <text> line.
549 *
550 * @param array $node The row AST node.
551 *
552 * @return void
553 */
554 private function emit_row( array $node ): void {
555 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
556 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
557
558 $line = '';
559 foreach ( $cols as $index => $col ) {
560 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
561 $text = Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() );
562 $text = Thermal_Text_Layout::truncate_display( $text, $width );
563 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
564 $align = isset( $col['align'] ) ? $col['align'] : 'left';
565 if ( 'right' === $align ) {
566 $line .= str_repeat( ' ', $pad ) . $text;
567 } else {
568 $line .= $text . str_repeat( ' ', $pad );
569 }
570 }
571
572 $this->emit_text_element( $line, 'left' );
573 }
574
575 /**
576 * Emit a horizontal rule as a <text> line.
577 *
578 * @param array $node The line AST node.
579 *
580 * @return void
581 */
582 private function emit_line( array $node ): void {
583 $style = isset( $node['style'] ) ? $node['style'] : 'single';
584
585 if ( 'dotted' === $style ) {
586 $pattern = '. ';
587 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
588 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
589 } elseif ( 'double' === $style ) {
590 $text = str_repeat( '=', $this->columns );
591 } else {
592 // single and dashed both render as '-' across the width.
593 $text = str_repeat( '-', $this->columns );
594 }
595
596 $this->emit_text_element( $text );
597 }
598
599 /**
600 * Emit a native barcode element.
601 *
602 * The `type` attribute is an ePOS-Print enum, not the template's spelling —
603 * the UPC pair is underscored there (`upc_a` / `upc_e`) and the unseparated
604 * form is rejected — so the name comes from Barcode_Symbology.
605 *
606 * @param array $node The barcode AST node.
607 *
608 * @return void
609 */
610 private function emit_barcode( array $node ): void {
611 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
612 if ( '' === trim( $value ) ) {
613 return;
614 }
615
616 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
617 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
618 $height = max( 1, min( 255, $height ) );
619
620 // Same rescue as the ESC/POS lane: a value the symbology cannot encode
621 // would be dropped by the printer "with no error returned" (ePOS-Print
622 // manual), so print it as a centered line instead of nothing.
623 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
624 $this->emit_text_element( (string) preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value ), 'center' );
625
626 return;
627 }
628
629 $payload = Barcode_Symbology::epos_xml_payload( $type, $value );
630
631 // hri="below" prints the value under the bars, as the preview, the PDF and
632 // the raster lane all do. Without it the merchant designs against a
633 // receipt that carries the order number and the printer hands the customer
634 // one that does not.
635 $this->buffer .= '<barcode type="' . $this->escape( Barcode_Symbology::epos_xml_name( $type ) ) . '" hri="below" height="' . $height . '" align="' . $this->escape( $this->align ) . '">' . $this->escape( $payload ) . '</barcode>';
636 $this->printer['align'] = null;
637 }
638
639 /**
640 * Emit a native QR code (symbol) element.
641 *
642 * @param array $node The qrcode AST node.
643 *
644 * @return void
645 */
646 private function emit_qrcode( array $node ): void {
647 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
648 if ( '' === trim( $value ) ) {
649 return;
650 }
651 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
652
653 // <symbol> data shares the barcode escape layer (`\xnn`, `\\`).
654 $payload = Barcode_Symbology::epos_xml_escape_data( $value );
655
656 $this->buffer .= '<symbol type="qrcode_model_2" level="default" width="' . $size . '" align="' . $this->escape( $this->align ) . '">' . $this->escape( $payload ) . '</symbol>';
657 $this->printer['align'] = null;
658 }
659
660 /**
661 * Emit a paper feed of N lines.
662 *
663 * @param array $node The feed AST node.
664 *
665 * @return void
666 */
667 private function emit_feed( array $node ): void {
668 $lines = Thermal_Bounds::clamp_int(
669 isset( $node['lines'] ) ? $node['lines'] : null,
670 Thermal_Bounds::FEED_LINES_MIN,
671 Thermal_Bounds::FEED_LINES_MIN,
672 Thermal_Bounds::FEED_LINES_MAX
673 );
674 $this->buffer .= '<feed line="' . $lines . '"/>';
675 }
676
677 /**
678 * Escape XML text content and attribute values.
679 *
680 * @param string $value Raw text.
681 *
682 * @return string The escaped text.
683 */
684 private function escape( string $value ): string {
685 return htmlspecialchars( $value, ENT_XML1 | ENT_COMPAT, 'UTF-8' );
686 }
687 }
688