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

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

619 lines 18.0 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 use Thermal_Emitter_Support;
43
44 /**
45 * Render options.
46 *
47 * @var array
48 */
49 private $options = array();
50
51 /**
52 * Accumulated output XML.
53 *
54 * @var string
55 */
56 private $buffer = '';
57
58 /**
59 * The paper width in character columns.
60 *
61 * @var int
62 */
63 private $columns = 48;
64
65 /**
66 * The current alignment mode (left|center|right).
67 *
68 * @var string
69 */
70 private $align = 'left';
71
72 /**
73 * Whether bold (emphasis) is currently active.
74 *
75 * @var bool
76 */
77 private $em = false;
78
79 /**
80 * Whether underline is currently active.
81 *
82 * @var bool
83 */
84 private $ul = false;
85
86 /**
87 * Whether reverse (invert) is currently active.
88 *
89 * @var bool
90 */
91 private $reverse = false;
92
93 /**
94 * Whether double-width is currently active.
95 *
96 * @var bool
97 */
98 private $dw = false;
99
100 /**
101 * Whether double-height is currently active.
102 *
103 * @var bool
104 */
105 private $dh = false;
106
107 /**
108 * The text style state currently held by the printer.
109 *
110 * A null value means the state is unknown and must be re-emitted.
111 *
112 * @var array
113 */
114 private $printer = array(
115 'align' => null,
116 'em' => null,
117 'ul' => null,
118 'reverse' => null,
119 'dw' => null,
120 'dh' => null,
121 );
122
123 /**
124 * Constructor.
125 *
126 * @param array $options Render options.
127 */
128 public function __construct( array $options = array() ) {
129 $this->options = $options;
130 }
131
132 /**
133 * Emit ePOS-Print XML from a thermal AST.
134 *
135 * @param array $ast The thermal AST root (a receipt node).
136 *
137 * @return string The ePOS-Print XML document.
138 */
139 public function emit( array $ast ): string {
140 $this->buffer = '';
141 $this->align = 'left';
142 $this->em = false;
143 $this->ul = false;
144 $this->reverse = false;
145 $this->dw = false;
146 $this->dh = false;
147
148 $this->columns = isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48;
149
150 $this->buffer .= '<epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">';
151
152 // The printer still holds whatever the previous job left, so treat every
153 // attribute as unknown and let the transition write the full reset preamble.
154 $this->printer = array_fill_keys( array( 'align', 'em', 'ul', 'reverse', 'dw', 'dh' ), null );
155 $this->buffer .= '<text' . $this->style_transition() . '/>';
156
157 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
158 $this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );
159
160 $this->buffer .= '</epos-print>';
161
162 return $this->buffer;
163 }
164
165 /**
166 * Emit an Epson ePOS-Print drawer pulse.
167 *
168 * @param string $connector Drawer connector.
169 */
170 private function emit_pulse( string $connector ): void {
171 $connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
172 $drawer = 'pin5' === $connector ? 'drawer_2' : 'drawer_1';
173 $this->buffer .= '<pulse drawer="' . $drawer . '" time="pulse_100"/>';
174 }
175
176 /**
177 * Walk a single AST node.
178 *
179 * @param array $node The AST node.
180 *
181 * @return void
182 */
183 private function walk_node( array $node ): void {
184 $type = isset( $node['type'] ) ? $node['type'] : '';
185
186 switch ( $type ) {
187 case 'raw-text':
188 $this->emit_text_element( isset( $node['value'] ) ? (string) $node['value'] : '' );
189 break;
190 case 'text':
191 $this->emit_text_node( $node );
192 break;
193 case 'bold':
194 $this->emit_bold( $node );
195 break;
196 case 'underline':
197 $this->emit_underline( $node );
198 break;
199 case 'invert':
200 $this->emit_invert( $node );
201 break;
202 case 'size':
203 $this->emit_size( $node );
204 break;
205 case 'align':
206 $this->emit_align( $node );
207 break;
208 case 'row':
209 $this->emit_row( $node );
210 break;
211 case 'line':
212 $this->emit_line( $node );
213 break;
214 case 'barcode':
215 $this->emit_barcode( $node );
216 break;
217 case 'qrcode':
218 $this->emit_qrcode( $node );
219 break;
220 case 'image':
221 $this->emit_image( $node );
222 break;
223 case 'cut':
224 $this->buffer .= '<cut type="feed"/>';
225 break;
226 case 'feed':
227 $this->emit_feed( $node );
228 break;
229 case 'drawer':
230 $this->emit_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
231 break;
232 case 'receipt':
233 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
234 break;
235 }
236 }
237
238 /**
239 * Emit a single <text> element from a text node, unioning the current style
240 * state with any style wrappers found within the node's own subtree.
241 *
242 * @param array $node The text AST node.
243 *
244 * @return void
245 */
246 private function emit_text_node( array $node ): void {
247 $children = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
248
249 $previous_em = $this->em;
250 $previous_ul = $this->ul;
251 $previous_reverse = $this->reverse;
252 $previous_dw = $this->dw;
253 $previous_dh = $this->dh;
254
255 $this->collect_subtree_styles( $children );
256
257 $content = Thermal_Text_Layout::extract_text( $children );
258 $this->emit_text_element( $content );
259
260 $this->em = $previous_em;
261 $this->ul = $previous_ul;
262 $this->reverse = $previous_reverse;
263 $this->dw = $previous_dw;
264 $this->dh = $previous_dh;
265 }
266
267 /**
268 * Union the style flags implied by wrappers within a node subtree.
269 *
270 * @param array $nodes The AST nodes to scan.
271 *
272 * @return void
273 */
274 private function collect_subtree_styles( array $nodes ): void {
275 foreach ( $nodes as $node ) {
276 if ( ! \is_array( $node ) ) {
277 continue;
278 }
279 $type = isset( $node['type'] ) ? $node['type'] : '';
280 if ( 'bold' === $type ) {
281 $this->em = true;
282 } elseif ( 'underline' === $type ) {
283 $this->ul = true;
284 } elseif ( 'invert' === $type ) {
285 $this->reverse = true;
286 } elseif ( 'size' === $type ) {
287 $width = isset( $node['width'] ) ? (int) $node['width'] : 1;
288 $height = isset( $node['height'] ) ? (int) $node['height'] : 1;
289 if ( $width > 1 ) {
290 $this->dw = true;
291 }
292 if ( $height > 1 ) {
293 $this->dh = true;
294 }
295 }
296 if ( isset( $node['children'] ) && \is_array( $node['children'] ) ) {
297 $this->collect_subtree_styles( $node['children'] );
298 }
299 }
300 }
301
302 /**
303 * Emit a single <text> line in the current style state.
304 *
305 * @param string $content The plain text content (will be XML-escaped).
306 * @param string|null $align Alignment override for this line (rows are
307 * pre-padded and always print left-aligned).
308 *
309 * @return void
310 */
311 private function emit_text_element( string $content, ?string $align = null ): void {
312 // normalize_text() is a 1:1 character substitution, so it cannot change a
313 // display width the row/rule callers have already padded against.
314 $content = Thermal_Text_Layout::normalize_text( $content );
315 $this->buffer .= '<text' . $this->style_transition( $align ) . '>' . $this->escape( $content ) . "\n" . '</text>';
316 }
317
318 /**
319 * Emit a template `<image>` (in practice, the store logo).
320 *
321 * The dots go out as base64 raw raster — 1 bit per dot, MSB first, rows whole
322 * bytes — not as an encoded PNG (ePOS-Print XML User's Manual, "Encoding
323 * Graphic Data"). Thermal_Bitmap produces exactly that layout, and
324 * resolves the src without an outbound request, which matters because this
325 * runs inside the printer's job fetch.
326 *
327 * The image is centred unconditionally, ignoring any enclosing `<align>`.
328 * That is the contract the other three renderers already keep — the preview
329 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
330 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
331 * `<image>` — and following the wrapper here instead would left-align the
332 * bare `<image>` that the template editor inserts, which every one of those
333 * three shows centred.
334 *
335 * A src that resolves to nothing (a remote URL, a missing file) emits
336 * nothing: a receipt without its logo still prints, where a broken `<image>`
337 * element risks the printer rejecting the whole job.
338 *
339 * @param array $node The image AST node.
340 *
341 * @return void
342 */
343 private function emit_image( array $node ): void {
344 $bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
345 if ( null === $bitmap ) {
346 return;
347 }
348
349 // align is a documented <image> attribute, not borrowed from <text>:
350 // ePOS-Print XML User's Manual, chapter 4, lists left/center/right on this
351 // element and defaults it to "left".
352 $this->buffer .= '<image width="' . $bitmap->width() . '" height="' . $bitmap->height() . '"'
353 . ' align="center" color="color_1" mode="mono">'
354 . base64_encode( $bitmap->raster() )
355 . '</image>';
356
357 // The manual's note on <text align> — "the align setting specified in this
358 // element is also applied to <image>, <logo>, <barcode> and <symbol>" —
359 // runs both ways: this <image> has moved the printer's persistent
360 // alignment, so the tracked state can no longer be trusted.
361 $this->printer['align'] = null;
362 }
363
364 /**
365 * Build the <text> attributes that move the printer to the current style,
366 * and record the printer as now holding that style.
367 *
368 * The attributes persist on the printer until changed, so only the values
369 * that differ from what it holds are written; a null entry in $printer
370 * (unknown) is always written.
371 *
372 * @param string|null $align Alignment override; null uses the wrapper state.
373 *
374 * @return string The attribute string (with a leading space when non-empty).
375 */
376 private function style_transition( ?string $align = null ): string {
377 $desired = array(
378 'align' => null === $align ? $this->align : $align,
379 'em' => $this->em,
380 'ul' => $this->ul,
381 'reverse' => $this->reverse,
382 'dw' => $this->dw,
383 'dh' => $this->dh,
384 );
385 $attrs = '';
386 foreach ( $desired as $name => $value ) {
387 if ( $this->printer[ $name ] !== $value ) {
388 $attrs .= ' ' . $name . '="' . ( \is_bool( $value ) ? ( $value ? 'true' : 'false' ) : $value ) . '"';
389 }
390 }
391 $this->printer = $desired;
392
393 return $attrs;
394 }
395
396 /**
397 * Emit a bold-wrapped block.
398 *
399 * @param array $node The bold AST node.
400 *
401 * @return void
402 */
403 private function emit_bold( array $node ): void {
404 $previous = $this->em;
405 $this->em = true;
406 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
407 $this->em = $previous;
408 }
409
410 /**
411 * Emit an underline-wrapped block.
412 *
413 * @param array $node The underline AST node.
414 *
415 * @return void
416 */
417 private function emit_underline( array $node ): void {
418 $previous = $this->ul;
419 $this->ul = true;
420 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
421 $this->ul = $previous;
422 }
423
424 /**
425 * Emit an invert-wrapped block.
426 *
427 * @param array $node The invert AST node.
428 *
429 * @return void
430 */
431 private function emit_invert( array $node ): void {
432 $previous = $this->reverse;
433 $this->reverse = true;
434 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
435 $this->reverse = $previous;
436 }
437
438 /**
439 * Emit a size-wrapped block.
440 *
441 * @param array $node The size AST node.
442 *
443 * @return void
444 */
445 private function emit_size( array $node ): void {
446 $previous_dw = $this->dw;
447 $previous_dh = $this->dh;
448 $width = isset( $node['width'] ) ? (int) $node['width'] : 1;
449 $height = isset( $node['height'] ) ? (int) $node['height'] : 1;
450
451 if ( $width > 1 ) {
452 $this->dw = true;
453 }
454 if ( $height > 1 ) {
455 $this->dh = true;
456 }
457
458 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
459
460 $this->dw = $previous_dw;
461 $this->dh = $previous_dh;
462 }
463
464 /**
465 * Emit an alignment-wrapped block.
466 *
467 * @param array $node The align AST node.
468 *
469 * @return void
470 */
471 private function emit_align( array $node ): void {
472 $previous = $this->align;
473 $this->align = isset( $node['mode'] ) ? (string) $node['mode'] : 'left';
474 $this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
475 $this->align = $previous;
476 }
477
478 /**
479 * Emit a row as one left-aligned <text> line.
480 *
481 * @param array $node The row AST node.
482 *
483 * @return void
484 */
485 private function emit_row( array $node ): void {
486 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
487 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
488
489 $line = '';
490 foreach ( $cols as $index => $col ) {
491 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
492 $text = Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() );
493 $text = Thermal_Text_Layout::truncate_display( $text, $width );
494 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
495 $align = isset( $col['align'] ) ? $col['align'] : 'left';
496 if ( 'right' === $align ) {
497 $line .= str_repeat( ' ', $pad ) . $text;
498 } else {
499 $line .= $text . str_repeat( ' ', $pad );
500 }
501 }
502
503 $this->emit_text_element( $line, 'left' );
504 }
505
506 /**
507 * Emit a horizontal rule as a <text> line.
508 *
509 * @param array $node The line AST node.
510 *
511 * @return void
512 */
513 private function emit_line( array $node ): void {
514 $style = isset( $node['style'] ) ? $node['style'] : 'single';
515
516 if ( 'dotted' === $style ) {
517 $pattern = '. ';
518 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
519 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
520 } elseif ( 'double' === $style ) {
521 $text = str_repeat( '=', $this->columns );
522 } else {
523 // single and dashed both render as '-' across the width.
524 $text = str_repeat( '-', $this->columns );
525 }
526
527 $this->emit_text_element( $text );
528 }
529
530 /**
531 * Emit a native barcode element.
532 *
533 * The `type` attribute is an ePOS-Print enum, not the template's spelling —
534 * the UPC pair is underscored there (`upc_a` / `upc_e`) and the unseparated
535 * form is rejected — so the name comes from Barcode_Symbology.
536 *
537 * @param array $node The barcode AST node.
538 *
539 * @return void
540 */
541 private function emit_barcode( array $node ): void {
542 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
543 if ( '' === trim( $value ) ) {
544 return;
545 }
546
547 $type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
548 $height = isset( $node['height'] ) ? (int) $node['height'] : 40;
549 $height = max( 1, min( 255, $height ) );
550
551 // Same rescue as the ESC/POS lane: a value the symbology cannot encode
552 // would be dropped by the printer "with no error returned" (ePOS-Print
553 // manual), so print it as a centered line instead of nothing.
554 if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
555 $this->emit_text_element( (string) preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value ), 'center' );
556
557 return;
558 }
559
560 $payload = Barcode_Symbology::epos_xml_payload( $type, $value );
561
562 // hri="below" prints the value under the bars, as the preview, the PDF and
563 // the raster lane all do. Without it the merchant designs against a
564 // receipt that carries the order number and the printer hands the customer
565 // one that does not.
566 $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>';
567 $this->printer['align'] = null;
568 }
569
570 /**
571 * Emit a native QR code (symbol) element.
572 *
573 * @param array $node The qrcode AST node.
574 *
575 * @return void
576 */
577 private function emit_qrcode( array $node ): void {
578 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
579 if ( '' === trim( $value ) ) {
580 return;
581 }
582 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
583
584 // <symbol> data shares the barcode escape layer (`\xnn`, `\\`).
585 $payload = Barcode_Symbology::epos_xml_escape_data( $value );
586
587 $this->buffer .= '<symbol type="qrcode_model_2" level="default" width="' . $size . '" align="' . $this->escape( $this->align ) . '">' . $this->escape( $payload ) . '</symbol>';
588 $this->printer['align'] = null;
589 }
590
591 /**
592 * Emit a paper feed of N lines.
593 *
594 * @param array $node The feed AST node.
595 *
596 * @return void
597 */
598 private function emit_feed( array $node ): void {
599 $lines = Thermal_Bounds::clamp_int(
600 isset( $node['lines'] ) ? $node['lines'] : null,
601 Thermal_Bounds::FEED_LINES_MIN,
602 Thermal_Bounds::FEED_LINES_MIN,
603 Thermal_Bounds::FEED_LINES_MAX
604 );
605 $this->buffer .= '<feed line="' . $lines . '"/>';
606 }
607
608 /**
609 * Escape XML text content and attribute values.
610 *
611 * @param string $value Raw text.
612 *
613 * @return string The escaped text.
614 */
615 private function escape( string $value ): string {
616 return htmlspecialchars( $value, ENT_XML1 | ENT_COMPAT, 'UTF-8' );
617 }
618 }
619