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

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

485 lines 18.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * HTML Thermal Emitter Class.
4 *
5 * Renders an HTML receipt string from a thermal AST (produced by
6 * Thermal_Markup_Parser). This is a PHP port of `renderNodes()` / `renderNode()`
7 * in packages/thermal-utils/src/thermal-renderer.ts, used for the thermal -> PDF
8 * path (Dompdf). The output mirrors the JS renderer's CONTENT; bwip-js is
9 * swapped for the vendor-prefixed picqer barcode generator (1D barcodes) and
10 * chillerlan QR code generator (QR codes).
11 *
12 * Deliberate deviations from the JS renderer (Dompdf has no flexbox engine and
13 * no `ch` unit, so the JS renderer's flex rows would collapse):
14 * - Rows are emitted as real single-row tables with em-based column widths
15 * (1 monospace character ≈ 0.6em), Dompdf's most reliable layout primitive.
16 * - The base font size is scaled so the template's character grid exactly
17 * fills the paper width passed by the caller, matching what the printer
18 * does on a physical roll.
19 * - Images (`<image>`) are emitted as plain `<img>` tags; Pdf_Renderer embeds
20 * local WordPress images (store logos) as data URIs before Dompdf renders.
21 * Remote image URLs stay blank because Dompdf remote access is disabled.
22 * - Barcode/QR rendering uses PHP libraries that emit PNG `<img>` tags. On any
23 * failure the value is rendered as escaped monospace text instead of throwing.
24 *
25 * @author Paul Kilmurray <[email protected]>
26 *
27 * @see http://wcpos.com
28 * @package WCPOS\WooCommercePOS
29 */
30
31 namespace WCPOS\WooCommercePOS\Templates\Thermal;
32
33 use WCPOS\WooCommercePOS\Templates\Barcode_Image;
34 use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;
35
36 /**
37 * Html_Thermal_Emitter class.
38 */
39 class Html_Thermal_Emitter {
40
41 /**
42 * Advance width of a monospace character relative to the font size.
43 */
44 private const CHAR_WIDTH_EM = 0.6;
45
46 /**
47 * Smallest `<size>` rendering, in em.
48 *
49 * PDF-only, deliberately: this path renders sizes as CSS em and can express
50 * a half-size run, where the printers cannot — the ESC/POS and Star size
51 * bytes have no multiplier below 1. Parsed markup never reaches it either
52 * (Thermal_Markup_Parser floors `<size>` at Thermal_Bounds::SIZE_MULTIPLIER_MIN),
53 * so it only applies to a hand-built AST asking for a fractional size.
54 */
55 private const MIN_SIZE_EM = 0.5;
56
57 /**
58 * Printer dot budgets for image sizing: wide (80mm, ≥40 columns) printers
59 * are 576 dots across, narrow (58mm) 384. The client preview carries the same
60 * two numbers as DOT_BUDGET_WIDE / DOT_BUDGET_NARROW in
61 * packages/thermal-utils/src/generate-barcode-svg.ts, and inline in
62 * dotsToCh() in packages/thermal-utils/src/thermal-renderer.ts. All three
63 * must agree or PDF/preview parity silently drifts.
64 */
65 private const DOT_BUDGET_WIDE = 576;
66
67 /**
68 * Narrow-roll printer dot budget (see DOT_BUDGET_WIDE sync note).
69 */
70 private const DOT_BUDGET_NARROW = 384;
71
72 /**
73 * Column count at/above which the wide dot budget applies.
74 */
75 private const NARROW_PAPER_THRESHOLD_CHARS = 40;
76
77 /**
78 * Wrapper side padding in px. Mirrors the `padding: 16px 12px` on the receipt
79 * wrapper that renderThermalPreview() emits in
80 * packages/thermal-utils/src/thermal-renderer.ts; in the PDF path
81 * Pdf_Layout_Preprocessor lifts this padding into the @page margins.
82 */
83 private const PADDING_X_PX = 12.0;
84
85 /**
86 * Wrapper top/bottom padding in px (see PADDING_X_PX).
87 */
88 private const PADDING_Y_PX = 16.0;
89
90 /**
91 * Render an HTML receipt string from a thermal AST.
92 *
93 * @param array $ast The thermal AST root (a receipt node).
94 * @param array $opts Optional: 'paper_width_px' — the PDF paper width in CSS
95 * px; the base font is scaled so the template's character
96 * grid fills the printable width like a real roll printer.
97 *
98 * @return string The receipt HTML.
99 */
100 public function emit( array $ast, array $opts = array() ): string {
101 $width_chars = $this->clamp_integer( isset( $ast['paper_width'] ) ? $ast['paper_width'] : null, 48, Thermal_Bounds::PAPER_WIDTH_PDF_MIN, Thermal_Bounds::PAPER_WIDTH_MAX );
102
103 // 13px matches the JS preview renderer's base font; with a known paper
104 // width the font scales so the grid fills the printable width instead.
105 $font_px = 13.0;
106 if ( isset( $opts['paper_width_px'] ) && is_numeric( $opts['paper_width_px'] ) ) {
107 $inner_px = (float) $opts['paper_width_px'] - 2 * self::PADDING_X_PX;
108 if ( $inner_px > 50 ) {
109 // Clamp to a legible range so a malformed paper/column combination
110 // cannot produce microscopic or oversized receipt text.
111 $font_px = max( 6.0, min( 14.0, $inner_px / ( $width_chars * self::CHAR_WIDTH_EM ) ) );
112 }
113 }
114
115 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
116 $inner = $this->render_nodes( $children, $width_chars );
117
118 return '<div style="font-family: \'Courier New\', Courier, monospace; '
119 . 'font-size: ' . $this->format_float( $font_px ) . 'px; line-height: 1.4; background: #fff; color: #000; '
120 . 'padding: ' . $this->format_float( self::PADDING_Y_PX ) . 'px ' . $this->format_float( self::PADDING_X_PX ) . 'px; '
121 . 'overflow: hidden; white-space: pre-wrap; word-break: break-all;">' . $inner . '</div>';
122 }
123
124 /**
125 * Render a list of AST nodes.
126 *
127 * @param array $nodes The AST nodes.
128 * @param int $width_chars The receipt character width.
129 *
130 * @return string The concatenated HTML.
131 */
132 private function render_nodes( array $nodes, int $width_chars ): string {
133 $html = '';
134 foreach ( $nodes as $node ) {
135 if ( \is_array( $node ) ) {
136 $html .= $this->render_node( $node, $width_chars );
137 }
138 }
139
140 return $html;
141 }
142
143 /**
144 * Render a single AST node.
145 *
146 * @param array $node The AST node.
147 * @param int $width_chars The receipt character width.
148 *
149 * @return string The HTML fragment.
150 */
151 private function render_node( array $node, int $width_chars ): string {
152 $type = isset( $node['type'] ) ? $node['type'] : '';
153 $children = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
154
155 switch ( $type ) {
156 case 'raw-text':
157 return $this->escape_html( isset( $node['value'] ) ? (string) $node['value'] : '' );
158 case 'text':
159 return '<div>' . $this->render_nodes( $children, $width_chars ) . '</div>';
160 case 'bold':
161 return '<strong>' . $this->render_nodes( $children, $width_chars ) . '</strong>';
162 case 'underline':
163 return '<span style="text-decoration: underline">' . $this->render_nodes( $children, $width_chars ) . '</span>';
164 case 'invert':
165 return '<span style="background: #000; color: #fff; padding: 0 4px">' . $this->render_nodes( $children, $width_chars ) . '</span>';
166 case 'size':
167 $em = $this->clamp_float( isset( $node['width'] ) ? $node['width'] : null, 1, self::MIN_SIZE_EM, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
168 return '<span style="font-size: ' . $this->format_float( $em ) . 'em; line-height: 1.2">' . $this->render_nodes( $children, $width_chars ) . '</span>';
169 case 'align':
170 $mode = $this->safe_align( isset( $node['mode'] ) ? $node['mode'] : null );
171 return '<div style="text-align: ' . $mode . '">' . $this->render_nodes( $children, $width_chars ) . '</div>';
172 case 'row':
173 return $this->render_row( $node, $width_chars );
174 case 'col':
175 // Standalone column outside a row: plain aligned block.
176 return '<div style="text-align: ' . $this->safe_align( isset( $node['align'] ) ? $node['align'] : null ) . '">'
177 . $this->render_nodes( $children, $width_chars ) . '</div>';
178 case 'line':
179 return $this->render_line( $node );
180 case 'barcode':
181 $barcode_type = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
182 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
183 if ( Barcode_Symbology::is_qr( $barcode_type ) ) {
184 return $this->render_qrcode( $value, $this->height_to_qr_size( isset( $node['height'] ) ? (int) $node['height'] : 40 ) );
185 }
186 return $this->render_barcode( $barcode_type, $value, isset( $node['height'] ) ? (int) $node['height'] : 40 );
187 case 'qrcode':
188 $value = isset( $node['value'] ) ? (string) $node['value'] : '';
189 $size = isset( $node['size'] ) ? (int) $node['size'] : 4;
190 return $this->render_qrcode( $value, $size );
191 case 'feed':
192 $lines = $this->clamp_integer( isset( $node['lines'] ) ? $node['lines'] : null, Thermal_Bounds::FEED_LINES_MIN, Thermal_Bounds::FEED_LINES_MIN, Thermal_Bounds::FEED_LINES_MAX );
193 return '<div style="height: ' . $this->format_float( $lines * 1.4 ) . 'em"></div>';
194 case 'cut':
195 // The scissors glyph is missing from the monospace core fonts, so
196 // it gets the bundled DejaVu face (present in every Dompdf install).
197 return '<div style="border-top: 1px dashed #ccc; margin: 12px 0; position: relative">'
198 . '<span style="position: absolute; top: -8px; left: -4px; font-size: 14px; font-family: \'DejaVu Sans\', sans-serif">&#9986;</span></div>';
199 case 'receipt':
200 return $this->render_nodes( $children, $width_chars );
201 case 'image':
202 return $this->render_image( $node, $width_chars );
203 case 'drawer':
204 default:
205 return '';
206 }
207 }
208
209 /**
210 * Render a row as a single-row table of columns.
211 *
212 * Dompdf has no flexbox engine and no `ch` unit, so the JS renderer's flex
213 * rows are expressed as a fixed-layout table: fixed columns get em widths
214 * (chars × 0.6em) and `*` columns share the remaining width.
215 *
216 * Known divergence, not yet reconciled: there is no star-width algebra here.
217 * `*` columns are emitted as `<td>` without a width and Dompdf distributes
218 * whatever the fixed columns leave, whereas the preview and the ESC/POS
219 * emitter floor-divide the remaining characters between star columns and give
220 * the remainder to the last one. The two agree on ordinary receipts and can
221 * differ by a character or two on rows with several star columns. Fixing it
222 * means porting resolve_row_widths() here and verifying the result against a
223 * real Dompdf render; do that before assuming the layouts match.
224 *
225 * @param array $node The row AST node.
226 * @param int $width_chars The receipt character width.
227 *
228 * @return string The HTML fragment.
229 */
230 private function render_row( array $node, int $width_chars ): string {
231 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
232 $cells = '';
233 foreach ( $cols as $col ) {
234 if ( \is_array( $col ) ) {
235 $cells .= $this->render_row_cell( $col, $width_chars );
236 }
237 }
238
239 return '<table style="width: 100%; table-layout: fixed; border-collapse: collapse"><tr>' . $cells . '</tr></table>';
240 }
241
242 /**
243 * Render a single row column as a table cell.
244 *
245 * @param array $node The col AST node.
246 * @param int $width_chars The receipt character width.
247 *
248 * @return string The HTML fragment.
249 */
250 private function render_row_cell( array $node, int $width_chars ): string {
251 $width = isset( $node['width'] ) ? $node['width'] : 12;
252 $width_style = '';
253 if ( '*' !== $width ) {
254 $chars = $this->clamp_integer( $width, 12, Thermal_Bounds::COL_WIDTH_MIN, Thermal_Bounds::COL_WIDTH_MAX );
255 $width_style = 'width: ' . $this->format_float( $chars * self::CHAR_WIDTH_EM ) . 'em; ';
256 }
257
258 $align = $this->safe_align( isset( $node['align'] ) ? $node['align'] : null );
259 $children = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
260
261 return '<td style="' . $width_style . 'text-align: ' . $align . '; vertical-align: top; padding: 0; overflow: hidden">'
262 . $this->render_nodes( $children, $width_chars ) . '</td>';
263 }
264
265 /**
266 * Render an image node as a centered <img>.
267 *
268 * Image widths are authored in printer dots; mirroring the JS renderer they
269 * scale by the paper's dot budget so the image keeps the same fraction of
270 * the receipt width (em-based because Dompdf has no `ch` unit). Local
271 * WordPress URLs (store logos, uploads) are embedded as data URIs by
272 * Pdf_Renderer before Dompdf sees the HTML; remote URLs render blank because
273 * remote access stays disabled.
274 *
275 * @param array $node The image AST node.
276 * @param int $width_chars The receipt character width.
277 *
278 * @return string The HTML fragment.
279 */
280 private function render_image( array $node, int $width_chars ): string {
281 $src = trim( isset( $node['src'] ) ? (string) $node['src'] : '' );
282 if ( '' === $src ) {
283 return '';
284 }
285
286 $width_dots = $this->clamp_integer( isset( $node['width'] ) ? $node['width'] : null, 200, Thermal_Bounds::IMAGE_WIDTH_DOTS_MIN, Thermal_Bounds::IMAGE_WIDTH_DOTS_MAX );
287 $dot_budget = $width_chars >= self::NARROW_PAPER_THRESHOLD_CHARS ? self::DOT_BUDGET_WIDE : self::DOT_BUDGET_NARROW;
288 $width_em = $width_dots * $width_chars / $dot_budget * self::CHAR_WIDTH_EM;
289
290 return '<div style="text-align: center; padding: 8px 0">'
291 . '<img src="' . $this->escape_html( $src ) . '" alt="" style="width: ' . $this->format_float( $width_em ) . 'em; max-width: 100%; height: auto" />'
292 . '</div>';
293 }
294
295 /**
296 * Render a horizontal rule line.
297 *
298 * @param array $node The line AST node.
299 *
300 * @return string The HTML fragment.
301 */
302 private function render_line( array $node ): string {
303 $style = isset( $node['style'] ) ? $node['style'] : 'single';
304
305 if ( 'double' === $style ) {
306 return '<hr style="border: none; border-top: 3px double #000; margin: 4px 0" />';
307 }
308 if ( 'dashed' === $style ) {
309 return '<hr style="border: none; border-top: 1px dashed #000; margin: 4px 0" />';
310 }
311 if ( 'dotted' === $style ) {
312 return '<hr style="border: none; border-top: 1px dotted #000; margin: 4px 0" />';
313 }
314
315 return '<hr style="border: none; border-top: 1px solid #000; margin: 4px 0" />';
316 }
317
318 /**
319 * Render a 1D barcode as a centered PNG image, falling back to text on failure.
320 *
321 * @param string $barcode_type The barcode symbology string.
322 * @param string $value The barcode value.
323 * @param int $height The barcode height in pixels.
324 *
325 * @return string The HTML fragment.
326 */
327 private function render_barcode( string $barcode_type, string $value, int $height = 40 ): string {
328 $text = trim( $value );
329 if ( '' === $text ) {
330 return '';
331 }
332
333 $img = Barcode_Image::barcode_img( $barcode_type, $text, $height );
334
335 return '' !== $img
336 ? '<div style="text-align: center; padding: 8px 0">' . $img . '</div>'
337 : $this->render_barcode_fallback( $text );
338 }
339
340 /**
341 * Render a QR code as a centered PNG image, falling back to text on failure.
342 *
343 * @param string $value The QR code value.
344 * @param int $size The QR code module scale (pixels per module).
345 *
346 * @return string The HTML fragment.
347 */
348 private function render_qrcode( string $value, int $size ): string {
349 $text = trim( $value );
350 if ( '' === $text ) {
351 return '';
352 }
353
354 $img = Barcode_Image::qrcode_img( $text, $size );
355
356 return '' !== $img
357 ? '<div style="text-align: center; padding: 8px 0">' . $img . '</div>'
358 : $this->render_barcode_fallback( $text );
359 }
360
361 /**
362 * Render the escaped value as monospace fallback text.
363 *
364 * @param string $text The value to render.
365 *
366 * @return string The HTML fragment.
367 */
368 private function render_barcode_fallback( string $text ): string {
369 return '<div style="text-align: center; padding: 8px 0"><code>' . $this->escape_html( $text ) . '</code></div>';
370 }
371
372 /**
373 * Convert a barcode height into a QR code size.
374 *
375 * A QR written as `<barcode type="qr" height="40">` carries a pixel height
376 * where a QR wants a module scale, so the height is folded into the scale the
377 * `<qrcode size="...">` element would have used. Mirrored by heightToQrSize()
378 * in packages/thermal-utils/src/thermal-renderer.ts; the two must agree or a
379 * QR previews at a different size than it prints.
380 *
381 * @param int $height The barcode height.
382 *
383 * @return int The QR code size clamped between 2 and 8, or 4 by default.
384 */
385 private function height_to_qr_size( int $height ): int {
386 if ( $height <= 0 ) {
387 return 4;
388 }
389
390 $size = (int) round( $height / 10 );
391
392 return max( 2, min( 8, $size ) );
393 }
394
395 /**
396 * Clamp a value into an integer range, falling back when it is not numeric.
397 *
398 * Out-of-range values are clamped to the nearest bound, never replaced by
399 * the fallback: a template asking for a 5000-dot logo on a 576-dot roll
400 * should render as wide as the roll allows, not silently snap back to the
401 * 200-dot default with no signal. The fallback covers only missing and
402 * non-numeric values. Fractions truncate toward zero.
403 *
404 * Counterpart to safeInteger() in
405 * packages/thermal-utils/src/thermal-renderer.ts, which clamps the same way.
406 * The preview has no safeFloat: it applies each bound inline at the node it
407 * renders, so the bounds passed here must match the ones written there.
408 *
409 * @param mixed $value The candidate value.
410 * @param int $fallback The fallback for missing/non-numeric values.
411 * @param int $min The minimum allowed value.
412 * @param int $max The maximum allowed value.
413 *
414 * @return int The clamped integer.
415 */
416 private function clamp_integer( $value, int $fallback, int $min, int $max ): int {
417 if ( ! is_numeric( $value ) ) {
418 return $fallback;
419 }
420
421 return max( $min, min( $max, (int) $value ) );
422 }
423
424 /**
425 * Clamp a value into a float range, falling back when it is not numeric.
426 *
427 * PHP-only: the preview renderer has no safeFloat helper, it inlines the
428 * equivalent clamp where it writes the CSS. See clamp_integer() above for
429 * why out-of-range clamps instead of falling back.
430 *
431 * @param mixed $value The candidate value.
432 * @param float $fallback The fallback for missing/non-numeric values.
433 * @param float $min The minimum allowed value.
434 * @param float $max The maximum allowed value.
435 *
436 * @return float The clamped float.
437 */
438 private function clamp_float( $value, float $fallback, float $min, float $max ): float {
439 if ( ! is_numeric( $value ) ) {
440 return $fallback;
441 }
442
443 return max( $min, min( $max, (float) $value ) );
444 }
445
446 /**
447 * Format a float for CSS output, trimming a trailing ".0".
448 *
449 * @param float $value The value to format.
450 *
451 * @return string The formatted value.
452 */
453 private function format_float( float $value ): string {
454 $formatted = rtrim( rtrim( sprintf( '%.2f', $value ), '0' ), '.' );
455
456 return '' === $formatted ? '0' : $formatted;
457 }
458
459 /**
460 * Resolve an alignment value to a valid CSS text-align keyword.
461 *
462 * @param mixed $value The candidate value.
463 *
464 * @return string One of left, center, or right.
465 */
466 private function safe_align( $value ): string {
467 if ( 'center' === $value || 'right' === $value || 'left' === $value ) {
468 return $value;
469 }
470
471 return 'left';
472 }
473
474 /**
475 * HTML-escape a string for safe embedding.
476 *
477 * @param string $value The input text.
478 *
479 * @return string The escaped text.
480 */
481 private function escape_html( string $value ): string {
482 return htmlspecialchars( $value, ENT_QUOTES, 'UTF-8' );
483 }
484 }
485