PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.4
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.4
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 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Templates / Thermal / Thermal_Markup_Parser.php

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

324 lines 10.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Thermal Markup Parser Class.
4 *
5 * Parses a thermal XML template string into a nested AST array. This is a PHP
6 * port of `parseXml()` / `parseChildren()` in
7 * packages/thermal-utils/src/thermal-renderer.ts, and mirrors their defaults and
8 * behaviour so server-rendered output matches the client preview.
9 *
10 * @author Paul Kilmurray <paul@kilbot.com>
11 *
12 * @see http://wcpos.com
13 * @package WCPOS\WooCommercePOS
14 */
15
16 namespace WCPOS\WooCommercePOS\Templates\Thermal;
17
18 use DOMDocument;
19 use DOMElement;
20 use DOMNode;
21 use RuntimeException;
22 use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;
23
24 /**
25 * Thermal_Markup_Parser class.
26 */
27 class Thermal_Markup_Parser {
28
29 /**
30 * Parse a thermal XML template string into an AST.
31 *
32 * @param string $xml The thermal XML markup.
33 *
34 * @throws RuntimeException When the markup cannot be parsed or the root is not <receipt>.
35 *
36 * @return array The root receipt AST node as a nested array.
37 */
38 public function parse( string $xml ): array {
39 $doc = $this->load_document( $xml );
40
41 $root = $doc->documentElement;
42 if ( null === $root || 'receipt' !== strtolower( $root->tagName ) ) {
43 throw new RuntimeException( 'XML parse error' );
44 }
45
46 return array(
47 'type' => 'receipt',
48 'paper_width' => $this->int_attr( $root, 'paper-width', 48, Thermal_Bounds::PAPER_WIDTH_MIN, Thermal_Bounds::PAPER_WIDTH_MAX ),
49 'children' => $this->parse_children( $root ),
50 );
51 }
52
53 /**
54 * Load an XML string into a DOMDocument, suppressing libxml warnings.
55 *
56 * @param string $xml The thermal XML markup.
57 *
58 * @throws RuntimeException When DOMDocument cannot load the markup.
59 *
60 * @return DOMDocument The loaded document.
61 */
62 private function load_document( string $xml ): DOMDocument {
63 $previous = libxml_use_internal_errors( true );
64 libxml_clear_errors();
65
66 $doc = new DOMDocument();
67 $loaded = $doc->loadXML( $xml, LIBXML_NONET );
68
69 libxml_clear_errors();
70 libxml_use_internal_errors( $previous );
71
72 if ( false === $loaded || null === $doc->documentElement ) {
73 throw new RuntimeException( 'XML parse error' );
74 }
75
76 return $doc;
77 }
78
79 /**
80 * Parse the child nodes of an element into AST nodes.
81 *
82 * @param DOMElement $parent The parent element.
83 *
84 * @return array List of AST nodes.
85 */
86 private function parse_children( DOMElement $parent ): array {
87 $nodes = array();
88
89 foreach ( $parent->childNodes as $child ) {
90 if ( XML_TEXT_NODE === $child->nodeType ) {
91 $text = null === $child->textContent ? '' : $child->textContent;
92 // Skip whitespace-only nodes (indentation), but preserve
93 // non-empty text as-is so spaces around inline elements survive.
94 if ( preg_match( '/\S/', $text ) ) {
95 $nodes[] = array(
96 'type' => 'raw-text',
97 'value' => $text,
98 );
99 }
100 continue;
101 }
102
103 if ( XML_ELEMENT_NODE !== $child->nodeType || ! $child instanceof DOMElement ) {
104 continue;
105 }
106
107 $tag = strtolower( $child->tagName );
108
109 switch ( $tag ) {
110 case 'text':
111 case 'bold':
112 case 'underline':
113 case 'invert':
114 $nodes[] = array(
115 'type' => $tag,
116 'children' => $this->parse_children( $child ),
117 );
118 break;
119 case 'size':
120 $width = $this->int_attr( $child, 'width', 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
121 $nodes[] = array(
122 'type' => 'size',
123 'width' => $width,
124 'height' => $this->int_attr( $child, 'height', $width, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX ),
125 'children' => $this->parse_children( $child ),
126 );
127 break;
128 case 'align':
129 $nodes[] = array(
130 'type' => 'align',
131 'mode' => $this->enum_attr( $child, 'mode', array( 'left', 'center', 'right' ), 'left' ),
132 'children' => $this->parse_children( $child ),
133 );
134 break;
135 case 'row':
136 $nodes[] = array(
137 'type' => 'row',
138 'children' => $this->parse_row_children( $child ),
139 );
140 break;
141 case 'col':
142 break;
143 case 'line':
144 $nodes[] = array(
145 'type' => 'line',
146 'style' => $this->enum_attr( $child, 'style', array( 'single', 'double', 'dashed', 'dotted' ), 'single' ),
147 );
148 break;
149 case 'barcode':
150 $type = $child->hasAttribute( 'type' ) ? $child->getAttribute( 'type' ) : 'code128';
151 if ( Barcode_Symbology::is_qr( $type ) ) {
152 $nodes[] = array(
153 'type' => 'qrcode',
154 'size' => $this->height_to_qr_size( $this->int_attr( $child, 'height', 40, Thermal_Bounds::BARCODE_HEIGHT_MIN, Thermal_Bounds::BARCODE_HEIGHT_MAX ) ),
155 'value' => trim( $child->textContent ),
156 );
157 } else {
158 $nodes[] = array(
159 'type' => 'barcode',
160 'barcode_type' => $type,
161 'height' => $this->int_attr( $child, 'height', 40, Thermal_Bounds::BARCODE_HEIGHT_MIN, Thermal_Bounds::BARCODE_HEIGHT_MAX ),
162 'value' => trim( $child->textContent ),
163 );
164 }
165 break;
166 case 'qrcode':
167 $nodes[] = array(
168 'type' => 'qrcode',
169 'size' => $this->int_attr( $child, 'size', 4, Thermal_Bounds::QRCODE_SIZE_MIN, Thermal_Bounds::QRCODE_SIZE_MAX ),
170 'value' => trim( $child->textContent ),
171 );
172 break;
173 case 'image':
174 $nodes[] = array(
175 'type' => 'image',
176 'src' => $child->hasAttribute( 'src' ) ? $child->getAttribute( 'src' ) : '',
177 'width' => $this->int_attr( $child, 'width', 200, Thermal_Bounds::IMAGE_WIDTH_DOTS_MIN, Thermal_Bounds::IMAGE_WIDTH_DOTS_MAX ),
178 );
179 break;
180 case 'cut':
181 $nodes[] = array(
182 'type' => 'cut',
183 'cut_type' => $this->enum_attr( $child, 'type', array( 'full', 'partial' ), 'partial' ),
184 );
185 break;
186 case 'feed':
187 $nodes[] = array(
188 'type' => 'feed',
189 'lines' => $this->int_attr( $child, 'lines', 1, Thermal_Bounds::FEED_LINES_MIN, Thermal_Bounds::FEED_LINES_MAX ),
190 );
191 break;
192 case 'drawer':
193 $nodes[] = array( 'type' => 'drawer' );
194 break;
195 default:
196 foreach ( $this->parse_children( $child ) as $node ) {
197 $nodes[] = $node;
198 }
199 }
200 }
201
202 return $nodes;
203 }
204
205 /**
206 * Parse the children of a row element, keeping only <col> elements.
207 *
208 * @param DOMElement $row The row element.
209 *
210 * @return array List of col AST nodes.
211 */
212 private function parse_row_children( DOMElement $row ): array {
213 $cols = array();
214
215 foreach ( $row->childNodes as $child ) {
216 if ( XML_ELEMENT_NODE !== $child->nodeType || ! $child instanceof DOMElement ) {
217 continue;
218 }
219 if ( 'col' !== strtolower( $child->tagName ) ) {
220 continue;
221 }
222
223 $raw_width = $child->hasAttribute( 'width' ) ? $child->getAttribute( 'width' ) : null;
224 $width = ( '*' === $raw_width ) ? '*' : $this->int_attr( $child, 'width', 12, Thermal_Bounds::COL_WIDTH_MIN, Thermal_Bounds::COL_WIDTH_MAX );
225
226 $cols[] = array(
227 'type' => 'col',
228 'width' => $width,
229 'align' => $this->enum_attr( $child, 'align', array( 'left', 'right' ), 'left' ),
230 'children' => $this->parse_children( $child ),
231 );
232 }
233
234 return $cols;
235 }
236
237 /**
238 * Resolve an attribute against a fixed set of valid values.
239 *
240 * @param DOMElement $el The element to read from.
241 * @param string $name The attribute name.
242 * @param array $valid The allowed values.
243 * @param string $fallback The fallback value when missing or invalid.
244 *
245 * @return string The resolved value.
246 */
247 private function enum_attr( DOMElement $el, string $name, array $valid, string $fallback ): string {
248 $value = $el->hasAttribute( $name ) ? $el->getAttribute( $name ) : null;
249
250 return ( null !== $value && in_array( $value, $valid, true ) ) ? $value : $fallback;
251 }
252
253 /**
254 * Resolve a numeric attribute into its legal integer range.
255 *
256 * Every attribute routed through here is a physical dimension (paper width,
257 * size multiplier, barcode height, QR scale, image dots, feed lines, column
258 * characters), so an out-of-range value is CLAMPED to the nearest bound
259 * rather than replaced by the fallback. A merchant who writes width="5000"
260 * gets the widest thing the device can print; substituting the default would
261 * render something unrelated to what they wrote, with no signal.
262 *
263 * The bounds are per-attribute and come from Thermal_Bounds, so the AST can
264 * only ever carry values every downstream path can render. One shared
265 * ceiling is not enough: `<feed lines="1e15">` is a legal-looking numeral,
266 * and every wire emitter turns `lines` straight into a loop or a
267 * str_repeat(), so an unbounded feed hangs the print request instead of
268 * printing something merely odd.
269 *
270 * The fallback covers only a missing or non-numeric attribute. Fractions
271 * truncate toward zero. Keep in step with safeInteger()/intAttr() in
272 * packages/thermal-utils/src/thermal-renderer.ts, which clamps identically
273 * against the same table.
274 *
275 * @param DOMElement $el The element to read from.
276 * @param string $name The attribute name.
277 * @param int $fallback The fallback for missing/non-numeric values.
278 * @param int $min The lowest legal value for this attribute.
279 * @param int $max The highest legal value for this attribute.
280 *
281 * @return int The clamped integer.
282 */
283 private function int_attr( DOMElement $el, string $name, int $fallback, int $min, int $max ): int {
284 if ( ! $el->hasAttribute( $name ) ) {
285 return $fallback;
286 }
287
288 $raw = trim( $el->getAttribute( $name ) );
289 if ( ! is_numeric( $raw ) ) {
290 return $fallback;
291 }
292
293 $number = (float) $raw;
294 if ( ! is_finite( $number ) ) {
295 return $fallback;
296 }
297
298 return (int) max( (float) $min, min( (float) $max, $number ) );
299 }
300
301 /**
302 * Convert a barcode height into a QR code size.
303 *
304 * A QR written as `<barcode type="qr" height="40">` carries a pixel height
305 * where a QR wants a module scale, so the height is folded into the scale the
306 * `<qrcode size="...">` element would have used. Mirrored by heightToQrSize()
307 * in packages/thermal-utils/src/thermal-renderer.ts; the two must agree or a
308 * QR previews at a different size than it prints.
309 *
310 * @param int $height The barcode height.
311 *
312 * @return int The QR code size clamped between 2 and 8, or 4 by default.
313 */
314 private function height_to_qr_size( int $height ): int {
315 if ( $height <= 0 ) {
316 return 4;
317 }
318
319 $size = (int) round( $height / 10 );
320
321 return max( 2, min( 8, $size ) );
322 }
323 }
324