| 1 |
<?php |
| 2 |
/** |
| 3 |
* Thermal Renderer Orchestrator Class. |
| 4 |
* |
| 5 |
* Ties together the thermal pipeline shipped in earlier phases: it Mustache-renders |
| 6 |
* a thermal template against canonical receipt data, parses the resulting markup |
| 7 |
* into an AST, and emits the requested wire format — ESC/POS or StarPRNT command |
| 8 |
* bytes, Epson ePOS-Print XML, Star Document Markup, plain text, or a PNG raster |
| 9 |
* of the whole receipt. |
| 10 |
* |
| 11 |
* The Mustache engine configuration mirrors Logicless_Renderer so that data values |
| 12 |
* containing XML-significant characters (`&`, `<`, `>`, quotes) are escaped to valid |
| 13 |
* XML before the markup is parsed. |
| 14 |
* |
| 15 |
* @author Paul Kilmurray <paul@kilbot.com> |
| 16 |
* |
| 17 |
* @see http://wcpos.com |
| 18 |
* @package WCPOS\WooCommercePOS |
| 19 |
*/ |
| 20 |
|
| 21 |
namespace WCPOS\WooCommercePOS\Templates\Thermal; |
| 22 |
|
| 23 |
use InvalidArgumentException; |
| 24 |
use Mustache\Engine as Mustache_Engine; |
| 25 |
use WCPOS\WooCommercePOS\Services\Receipt_Data_Builder; |
| 26 |
use WCPOS\WooCommercePOS\Services\Receipt_Data_Schema; |
| 27 |
use WC_Abstract_Order; |
| 28 |
|
| 29 |
/** |
| 30 |
* Thermal_Renderer class. |
| 31 |
*/ |
| 32 |
class Thermal_Renderer { |
| 33 |
|
| 34 |
/** |
| 35 |
* Render a thermal template for an order into the requested wire format. |
| 36 |
* |
| 37 |
* @param array $template Template metadata/content. |
| 38 |
* @param WC_Abstract_Order $order The order to render. |
| 39 |
* @param string $wire_format The target wire format ('escpos', 'starprnt', 'epos-xml', 'star-markup', 'text' or 'png'). |
| 40 |
* @param array $options Render options. |
| 41 |
* |
| 42 |
* @throws InvalidArgumentException When the wire format is not supported. |
| 43 |
* |
| 44 |
* @return string The rendered wire-format payload. |
| 45 |
*/ |
| 46 |
public function render( array $template, WC_Abstract_Order $order, string $wire_format, array $options = array() ): string { |
| 47 |
return $this->render_with_control( $template, $order, $wire_format, $options )['body']; |
| 48 |
} |
| 49 |
|
| 50 |
/** |
| 51 |
* Render a thermal template, reporting peripherals the payload cannot carry. |
| 52 |
* |
| 53 |
* Command formats (ESC/POS, StarPRNT, ePOS-XML) express cut and cash-drawer |
| 54 |
* in-band, so they report null for both. Command-free formats — `text` and |
| 55 |
* `png` — cannot, and the transport has to ask for them instead: |
| 56 |
* on Star CloudPRNT that means the `X-Star-Cut` / `X-Star-CashDrawer` headers |
| 57 |
* on the job fetch. Callers serving those formats must forward what comes |
| 58 |
* back here or the receipt will neither cut nor open the drawer. |
| 59 |
* |
| 60 |
* @param array $template Template metadata/content. |
| 61 |
* @param WC_Abstract_Order $order The order to render. |
| 62 |
* @param string $wire_format The target wire format. |
| 63 |
* @param array $options Render options. |
| 64 |
* |
| 65 |
* @throws InvalidArgumentException When the wire format is not supported. |
| 66 |
* |
| 67 |
* @return array{body:string, cut:string|null, drawer:string|null} |
| 68 |
*/ |
| 69 |
public function render_with_control( array $template, WC_Abstract_Order $order, string $wire_format, array $options = array() ): array { |
| 70 |
$ast = $this->build_ast( $template, $order ); |
| 71 |
|
| 72 |
switch ( $wire_format ) { |
| 73 |
case 'escpos': |
| 74 |
return self::in_band( ( new Escpos_Thermal_Emitter( $options ) )->emit( $ast ) ); |
| 75 |
case 'starprnt': |
| 76 |
return self::in_band( ( new Starprnt_Thermal_Emitter( $options ) )->emit( $ast ) ); |
| 77 |
case 'epos-xml': |
| 78 |
return self::in_band( ( new Epos_Xml_Thermal_Emitter( $options ) )->emit( $ast ) ); |
| 79 |
case 'star-markup': |
| 80 |
return self::in_band( ( new Star_Markup_Thermal_Emitter() )->emit( $ast ) ); |
| 81 |
case 'text': |
| 82 |
$emitter = new Text_Thermal_Emitter( $options ); |
| 83 |
$body = $emitter->emit( $ast ); |
| 84 |
|
| 85 |
return array( |
| 86 |
'body' => $body, |
| 87 |
'cut' => $emitter->cut_type(), |
| 88 |
'drawer' => $emitter->drawer(), |
| 89 |
); |
| 90 |
case 'png': |
| 91 |
$emitter = new Raster_Thermal_Emitter( $options ); |
| 92 |
$body = $emitter->emit( $ast ); |
| 93 |
|
| 94 |
return array( |
| 95 |
'body' => $body, |
| 96 |
'cut' => $emitter->cut_type(), |
| 97 |
'drawer' => $emitter->drawer(), |
| 98 |
); |
| 99 |
default: |
| 100 |
throw new InvalidArgumentException( |
| 101 |
esc_html( "Unsupported thermal wire format: {$wire_format}" ) |
| 102 |
); |
| 103 |
} |
| 104 |
} |
| 105 |
|
| 106 |
/** |
| 107 |
* Wrap a payload that carries its own cut and drawer commands. |
| 108 |
* |
| 109 |
* @param string $body The rendered payload. |
| 110 |
* |
| 111 |
* @return array{body:string, cut:string|null, drawer:string|null} |
| 112 |
*/ |
| 113 |
private static function in_band( string $body ): array { |
| 114 |
return array( |
| 115 |
'body' => $body, |
| 116 |
'cut' => null, |
| 117 |
'drawer' => null, |
| 118 |
); |
| 119 |
} |
| 120 |
|
| 121 |
/** |
| 122 |
* Build the thermal AST for an order from a template. |
| 123 |
* |
| 124 |
* Shared pipeline used by both render() and the PDF path: Mustache-render the |
| 125 |
* template against canonical receipt data, strip XML-illegal control characters, |
| 126 |
* then parse the markup into an AST. |
| 127 |
* |
| 128 |
* @param array $template Template metadata/content. |
| 129 |
* @param WC_Abstract_Order $order The order to render. |
| 130 |
* @param array|null $receipt_data Optional canonical receipt payload. |
| 131 |
* |
| 132 |
* @return array The thermal AST root (a receipt node). |
| 133 |
*/ |
| 134 |
public function build_ast( array $template, WC_Abstract_Order $order, ?array $receipt_data = null ): array { |
| 135 |
$content = (string) ( $template['content'] ?? '' ); |
| 136 |
|
| 137 |
$data = null === $receipt_data ? ( new Receipt_Data_Builder() )->build( $order, 'live' ) : $receipt_data; |
| 138 |
|
| 139 |
// Pre-format money/display fields so {{*_display}} placeholders resolve, |
| 140 |
// mirroring Logicless_Renderer. |
| 141 |
$currency = $data['order']['currency'] ?? 'USD'; |
| 142 |
$data = Receipt_Data_Schema::format_money_fields( $data, $currency ); |
| 143 |
|
| 144 |
// Safety net for templates that wrap content in {{#t}}...{{/t}} markers. |
| 145 |
$data['t'] = true; |
| 146 |
|
| 147 |
$flags = ENT_QUOTES | ENT_SUBSTITUTE; |
| 148 |
$mustache = new Mustache_Engine( |
| 149 |
array( |
| 150 |
'entity_flags' => $flags, |
| 151 |
'escape' => function ( $value ) use ( $flags ) { |
| 152 |
if ( \is_array( $value ) ) { |
| 153 |
return ''; |
| 154 |
} |
| 155 |
|
| 156 |
return htmlspecialchars( (string) $value, $flags, 'UTF-8' ); |
| 157 |
}, |
| 158 |
) |
| 159 |
); |
| 160 |
|
| 161 |
$xml = $mustache->render( $content, $data ); |
| 162 |
|
| 163 |
// Strip control characters that XML 1.0 forbids (everything below 0x20 |
| 164 |
// except tab, LF and CR). Order data can carry these — e.g. a customer |
| 165 |
// note pasted with a form-feed — and Mustache's HTML escaping leaves them |
| 166 |
// intact, so they would make DOMDocument::loadXML() fail downstream. They |
| 167 |
// can never print meaningfully, so removing them is safe. |
| 168 |
$stripped = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F]/u', '', $xml ); |
| 169 |
if ( null !== $stripped ) { |
| 170 |
$xml = $stripped; |
| 171 |
} |
| 172 |
|
| 173 |
return ( new Thermal_Markup_Parser() )->parse( $xml ); |
| 174 |
} |
| 175 |
} |
| 176 |
|