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

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

1,116 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Raster Thermal Emitter Class.
4 *
5 * Renders a thermal AST to a monochrome PNG of the whole receipt. `image/png` is
6 * the one media type every Star CloudPRNT model decodes, including the Line
7 * Mode-only ones (TSP650II/TSP700II/TSP800II) that cannot decode StarPRNT at
8 * all — so this is the floor beneath native emission, and the only path on which
9 * a template's logo, barcode and QR reach that hardware at all.
10 *
11 * The receipt is a character grid, so this draws one: glyphs are painted cell by
12 * cell rather than as whole strings, which keeps columns aligned no matter what
13 * the font's own advance rounds to, and lets a full-width glyph occupy the two
14 * cells `Thermal_Text_Layout::display_width()` already counts for it.
15 *
16 * Geometry is 203 dpi, the resolution of every Star thermal head: 576 dots for
17 * 80 mm paper and 384 for 58 mm. Both divide into exactly 12 px per cell at the
18 * usual column counts (48 and 32), and a count that does not divide evenly gets
19 * a floored cell and a centred block rather than an overhang past the paper.
20 *
21 * Like `Text_Thermal_Emitter`, the format carries no commands, so cut and drawer
22 * are reported for the transport to request with `X-Star-Cut` /
23 * `X-Star-CashDrawer` headers, and the drawer connector cannot be selected.
24 *
25 * Glyph coverage is the bundled DejaVu Sans Mono's: Latin, Greek, Cyrillic,
26 * Arabic and currency symbols, but not CJK, Hebrew or Thai. Sites needing those
27 * point `woocommerce_pos_receipt_raster_font` at a face that has them — see
28 * issue #1682.
29 *
30 * Complex scripts are a harder limit than coverage. Placing glyphs in cells
31 * means drawing them in logical order, left to right, with no contextual
32 * shaping — so Arabic and Persian come out as unjoined, unreversed letterforms
33 * even though the font has the glyphs. GD has no HarfBuzz binding, so shaping
34 * cannot be done here at all; an RTL store is better served by native StarPRNT,
35 * which leads the offer anyway. Tracked with the font question on #1682.
36 *
37 * @package WCPOS\WooCommercePOS\Templates\Thermal
38 */
39
40 namespace WCPOS\WooCommercePOS\Templates\Thermal;
41
42 use WCPOS\WooCommercePOS\Services\Local_Image_Resolver;
43 use WCPOS\WooCommercePOS\Templates\Barcode_Image;
44
45 /**
46 * Raster_Thermal_Emitter class.
47 */
48 class Raster_Thermal_Emitter {
49
50 /**
51 * Line box height as a multiple of the cell width.
52 *
53 * Monospace cells are about twice as tall as they are wide; 1.7 leaves the
54 * receipt readable without wasting paper.
55 */
56 private const LINE_HEIGHT_RATIO = 1.7;
57
58 /**
59 * Where the text baseline sits inside its line box.
60 */
61 private const BASELINE_RATIO = 0.78;
62
63 /**
64 * Hard ceiling on the rendered height, in dots.
65 *
66 * A runaway template must not allocate an unbounded image. 40 000 dots is
67 * about five metres of paper — far past any real receipt.
68 */
69 private const MAX_HEIGHT = 40000;
70
71 /**
72 * Render options.
73 *
74 * @var array
75 */
76 private $options = array();
77
78 /**
79 * Display list built by the measure pass.
80 *
81 * @var array<int, array>
82 */
83 private $ops = array();
84
85 /**
86 * Styled runs buffered for the line currently being built.
87 *
88 * A line is a list of runs rather than one string because styling nests
89 * inside `<text>`: `<text>plain <bold>bold</bold></text>` is one printed line
90 * carrying two different inks, and collapsing it to a single style would
91 * render the whole line in whichever style happened to be current when the
92 * line closed.
93 *
94 * @var array<int, array{text:string, bold:bool, invert:bool, w:int, h:int}>
95 */
96 private $runs = array();
97
98 /**
99 * The paper width in character columns.
100 *
101 * @var int
102 */
103 private $columns = 48;
104
105 /**
106 * The paper width in dots.
107 *
108 * @var int
109 */
110 private $dots = Thermal_Bounds::DOTS_80MM;
111
112 /**
113 * The width of one character cell in dots.
114 *
115 * @var int
116 */
117 private $cell = 12;
118
119 /**
120 * Left inset that centres the character grid on the paper.
121 *
122 * @var int
123 */
124 private $margin = 0;
125
126 /**
127 * The base font size whose advance fits one cell.
128 *
129 * @var int
130 */
131 private $font_size = 15;
132
133 /**
134 * The current alignment mode (left|center|right).
135 *
136 * @var string
137 */
138 private $align = 'left';
139
140 /**
141 * The current text width multiplier.
142 *
143 * @var int
144 */
145 private $width = 1;
146
147 /**
148 * The current text height multiplier.
149 *
150 * @var int
151 */
152 private $height = 1;
153
154 /**
155 * Whether bold is currently active.
156 *
157 * @var bool
158 */
159 private $bold = false;
160
161 /**
162 * Whether invert is currently active.
163 *
164 * @var bool
165 */
166 private $invert = false;
167
168 /**
169 * The cut requested by the AST, or null when it asked for none.
170 *
171 * @var string|null
172 */
173 private $cut_type = null;
174
175 /**
176 * The drawer kick requested by the AST/options, or null when none.
177 *
178 * @var string|null
179 */
180 private $drawer = null;
181
182 /**
183 * Constructor.
184 *
185 * @param array $options Render options.
186 */
187 public function __construct( array $options = array() ) {
188 $this->options = $options;
189 }
190
191 /**
192 * Whether this build can rasterize at all.
193 *
194 * GD with FreeType is near-universal on WordPress hosts (core needs an image
195 * library for media), but it is not guaranteed, and a caller that offers
196 * `image/png` without checking would advertise a format it cannot produce.
197 *
198 * @return bool
199 */
200 public static function is_supported(): bool {
201 return \function_exists( 'imagecreatetruecolor' )
202 && \function_exists( 'imagettftext' )
203 && \function_exists( 'imagettfbbox' )
204 && \function_exists( 'imagepng' )
205 && '' !== self::font_path( false );
206 }
207
208 /**
209 * Emit a monochrome PNG of the receipt.
210 *
211 * @param array $ast The thermal AST root (a receipt node).
212 *
213 * @return string The PNG bytes, or '' when this build cannot rasterize.
214 */
215 public function emit( array $ast ): string {
216 if ( ! self::is_supported() ) {
217 return '';
218 }
219
220 $this->ops = array();
221 $this->runs = array();
222 $this->align = 'left';
223 $this->width = 1;
224 $this->height = 1;
225 $this->bold = false;
226 $this->invert = false;
227 $this->cut_type = null;
228 $this->drawer = null;
229
230 $this->configure_geometry( $ast );
231
232 $children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
233 $this->walk_nodes( $children );
234 $this->flush_line();
235
236 if ( null === $this->drawer && ! empty( $this->options['auto_open_drawer'] ) ) {
237 $this->drawer = 'end';
238 }
239
240 return $this->paint();
241 }
242
243 /**
244 * The cut the rendered AST asked for, for the `X-Star-Cut` header.
245 *
246 * @return string|null 'full', 'partial', or null when the receipt cuts nothing.
247 */
248 public function cut_type(): ?string {
249 return $this->cut_type;
250 }
251
252 /**
253 * The drawer kick the rendered job asked for, for `X-Star-CashDrawer`.
254 *
255 * @return string|null 'end', or null when no drawer should fire.
256 */
257 public function drawer(): ?string {
258 return $this->drawer;
259 }
260
261 /**
262 * Resolve paper width, cell size and the font size that fits a cell.
263 *
264 * @param array $ast The thermal AST root.
265 *
266 * @return void
267 */
268 private function configure_geometry( array $ast ): void {
269 $this->columns = isset( $ast['paper_width'] ) ? max( 1, (int) $ast['paper_width'] ) : 48;
270 $this->dots = Thermal_Bounds::paper_dots( $this->columns );
271
272 // Floor the cell so the grid can never run past the paper edge, then centre
273 // whatever slack that leaves. 48 and 32 columns divide exactly; 42 does not.
274 $this->cell = max( 1, (int) floor( $this->dots / $this->columns ) );
275 $this->margin = (int) floor( ( $this->dots - ( $this->cell * $this->columns ) ) / 2 );
276
277 $this->font_size = $this->fitting_font_size( $this->cell );
278 }
279
280 /**
281 * The largest font size whose advance still fits inside one cell.
282 *
283 * Measured rather than derived: the em-to-advance ratio is not perfectly
284 * linear at small sizes once hinting rounds glyphs to the pixel grid, so a
285 * computed size can overflow the paper by a few dots per line.
286 *
287 * @param int $cell The cell width in dots.
288 *
289 * @return int
290 */
291 private function fitting_font_size( int $cell ): int {
292 $font = self::font_path();
293 $best = 1;
294
295 for ( $size = 4; $size <= 72; $size++ ) {
296 $box = imagettfbbox( $size, 0, $font, str_repeat( 'M', 20 ) );
297 if ( ! \is_array( $box ) ) {
298 break;
299 }
300 if ( ( ( $box[2] - $box[0] ) / 20 ) > $cell ) {
301 break;
302 }
303 $best = $size;
304 }
305
306 return $best;
307 }
308
309 /**
310 * Path to the receipt raster font.
311 *
312 * Defaults to the DejaVu Sans Mono that already ships inside the bundled
313 * dompdf, so no font is added to the plugin. A site whose receipts need
314 * glyphs DejaVu lacks — CJK, Hebrew, Thai — points this at a face that has
315 * them.
316 *
317 * @param bool $filtered Whether to run the filter. The support probe skips it
318 * so a broken filter cannot make the emitter look absent.
319 *
320 * @return string The readable font path, or '' when none resolves.
321 */
322 private static function font_path( bool $filtered = true ): string {
323 $bundled = \dirname( __DIR__, 3 ) . '/vendor_prefixed/dompdf/dompdf/lib/fonts/DejaVuSansMono.ttf';
324
325 if ( ! $filtered ) {
326 return is_readable( $bundled ) ? $bundled : '';
327 }
328
329 /**
330 * Filters the TrueType font used to rasterize receipts for cloud printers.
331 *
332 * @since 1.10.0
333 *
334 * @param string $path Absolute path to a .ttf file.
335 */
336 $path = (string) apply_filters( 'woocommerce_pos_receipt_raster_font', $bundled );
337
338 if ( '' === $path || ! is_readable( $path ) ) {
339 return is_readable( $bundled ) ? $bundled : '';
340 }
341
342 return $path;
343 }
344
345 /**
346 * Path to the bold companion of the raster font.
347 *
348 * Falls back to the regular face when a filtered font has no `-Bold` sibling,
349 * so a custom font never breaks bold text — it just stops looking bold.
350 *
351 * @return string
352 */
353 private static function bold_font_path(): string {
354 $regular = self::font_path();
355 $bold = preg_replace( '/\.ttf$/i', '-Bold.ttf', $regular );
356
357 return ( \is_string( $bold ) && is_readable( $bold ) ) ? $bold : $regular;
358 }
359
360 /**
361 * Walk a list of AST nodes.
362 *
363 * @param array $nodes The AST nodes.
364 *
365 * @return void
366 */
367 private function walk_nodes( array $nodes ): void {
368 foreach ( $nodes as $node ) {
369 if ( \is_array( $node ) ) {
370 $this->walk_node( $node );
371 }
372 }
373 }
374
375 /**
376 * Walk a single AST node.
377 *
378 * @param array $node The AST node.
379 *
380 * @return void
381 */
382 private function walk_node( array $node ): void {
383 $type = isset( $node['type'] ) ? $node['type'] : '';
384 $children = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
385
386 switch ( $type ) {
387 case 'raw-text':
388 $this->append_text( Thermal_Text_Layout::normalize_text( isset( $node['value'] ) ? (string) $node['value'] : '' ) );
389 break;
390 case 'text':
391 $this->emit_text_line( $children );
392 break;
393 case 'bold':
394 $previous = $this->bold;
395 $this->bold = true;
396 $this->walk_nodes( $children );
397 $this->bold = $previous;
398 break;
399 case 'invert':
400 $previous = $this->invert;
401 $this->invert = true;
402 $this->walk_nodes( $children );
403 $this->invert = $previous;
404 break;
405 case 'underline':
406 // No raster expression yet; the children still print.
407 $this->walk_nodes( $children );
408 break;
409 case 'size':
410 $this->emit_size( $node );
411 break;
412 case 'align':
413 $previous = $this->align;
414 $this->align = isset( $node['mode'] ) ? (string) $node['mode'] : 'left';
415 $this->walk_nodes( $children );
416 $this->align = $previous;
417 break;
418 case 'row':
419 $this->emit_row( $node );
420 break;
421 case 'line':
422 $this->emit_rule( $node );
423 break;
424 case 'barcode':
425 $this->emit_barcode( $node );
426 break;
427 case 'qrcode':
428 $this->emit_qrcode( $node );
429 break;
430 case 'image':
431 $this->emit_image( $node );
432 break;
433 case 'cut':
434 $this->cut_type = isset( $node['cut_type'] ) ? (string) $node['cut_type'] : 'partial';
435 break;
436 case 'feed':
437 $this->push(
438 array(
439 'op' => 'feed',
440 'lines' => isset( $node['lines'] ) ? max( 1, (int) $node['lines'] ) : 1,
441 )
442 );
443 break;
444 case 'drawer':
445 $this->drawer = 'end';
446 break;
447 case 'receipt':
448 $this->walk_nodes( $children );
449 break;
450 }
451 }
452
453 /**
454 * Walk a size-wrapped block with its multipliers applied.
455 *
456 * GD cannot scale a glyph's axes independently, so the height multiplier
457 * drives the glyph size and the width multiplier drives the cell advance.
458 * Templates in practice scale both together, where the two agree.
459 *
460 * @param array $node The size AST node.
461 *
462 * @return void
463 */
464 private function emit_size( array $node ): void {
465 $previous_width = $this->width;
466 $previous_height = $this->height;
467
468 $this->width = isset( $node['width'] ) ? max( 1, min( 8, (int) $node['width'] ) ) : 1;
469 $this->height = isset( $node['height'] ) ? max( 1, min( 8, (int) $node['height'] ) ) : 1;
470
471 $this->walk_nodes( isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array() );
472
473 $this->width = $previous_width;
474 $this->height = $previous_height;
475 }
476
477 /**
478 * Emit one printed text line, aligned within the paper width.
479 *
480 * @param array $children The child nodes of the text node.
481 *
482 * @return void
483 */
484 private function emit_text_line( array $children ): void {
485 $this->walk_nodes( $children );
486 $this->close_line( $this->align );
487 }
488
489 /**
490 * Emit a row as one physical line of fixed columns.
491 *
492 * @param array $node The row AST node.
493 *
494 * @return void
495 */
496 private function emit_row( array $node ): void {
497 $cols = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
498 $widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );
499
500 $row = '';
501 foreach ( $cols as $index => $col ) {
502 $width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
503 $text = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
504 $text = Thermal_Text_Layout::truncate_display( $text, $width );
505 $pad = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
506 $align = isset( $col['align'] ) ? (string) $col['align'] : 'left';
507 $row .= 'right' === $align ? str_repeat( ' ', $pad ) . $text : $text . str_repeat( ' ', $pad );
508 }
509
510 $this->append_text( $row );
511 $this->close_line( 'left' );
512 }
513
514 /**
515 * Emit a horizontal rule line.
516 *
517 * @param array $node The line AST node.
518 *
519 * @return void
520 */
521 private function emit_rule( array $node ): void {
522 $style = isset( $node['style'] ) ? (string) $node['style'] : 'single';
523
524 if ( 'dotted' === $style ) {
525 $pattern = '. ';
526 $repeat = (int) ceil( $this->columns / \strlen( $pattern ) );
527 $text = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
528 } elseif ( 'double' === $style ) {
529 $text = str_repeat( '=', $this->columns );
530 } else {
531 $text = str_repeat( '-', $this->columns );
532 }
533
534 $this->append_text( $text );
535 $this->close_line( 'left' );
536 }
537
538 /**
539 * Rasterize a barcode into the receipt.
540 *
541 * @param array $node The barcode AST node.
542 *
543 * @return void
544 */
545 private function emit_barcode( array $node ): void {
546 $png = Barcode_Image::barcode_png(
547 isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128',
548 isset( $node['value'] ) ? (string) $node['value'] : '',
549 isset( $node['height'] ) ? (int) $node['height'] : 40
550 );
551
552 $this->push_image( $png );
553
554 // The human-readable value, as the HTML and PDF paths render it — and, when
555 // generation failed (a non-numeric EAN-13, say), the only trace of the
556 // barcode left on the receipt. Suppressing it with the image would leave a
557 // silent gap where a scannable code should be.
558 $value = Thermal_Text_Layout::normalize_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
559 if ( '' !== $value ) {
560 $this->append_text( $value );
561 $this->close_line( 'center' );
562 }
563 }
564
565 /**
566 * Rasterize a QR code into the receipt.
567 *
568 * @param array $node The qrcode AST node.
569 *
570 * @return void
571 */
572 private function emit_qrcode( array $node ): void {
573 $this->push_image(
574 Barcode_Image::qrcode_png(
575 isset( $node['value'] ) ? (string) $node['value'] : '',
576 isset( $node['size'] ) ? (int) $node['size'] : 4
577 )
578 );
579 }
580
581 /**
582 * Composite a template `<image>` (typically the store logo).
583 *
584 * Templates carry the logo as an ordinary WordPress URL, not a data URI, so
585 * dropping everything that is not inline would drop every real store logo —
586 * the main thing this format exists to carry. Local URLs are read from disk;
587 * remote ones are left out rather than fetched, because this runs inside the
588 * printer's job fetch and an outbound request there would stall the print.
589 *
590 * @param array $node The image AST node.
591 *
592 * @return void
593 */
594 private function emit_image( array $node ): void {
595 $bytes = ( new Local_Image_Resolver() )->bytes( isset( $node['src'] ) ? (string) $node['src'] : '' );
596
597 $this->push_image( $bytes, isset( $node['width'] ) ? (int) $node['width'] : 0 );
598 }
599
600 /**
601 * Queue an image block, scaled to fit the paper.
602 *
603 * @param string $png Raw image bytes.
604 * @param int $requested_dots Preferred width in dots, or 0 for natural size.
605 *
606 * @return void
607 */
608 private function push_image( string $png, int $requested_dots = 0 ): void {
609 if ( '' === $png ) {
610 return;
611 }
612
613 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid image data returns false rather than warning.
614 $image = @imagecreatefromstring( $png );
615 if ( false === $image ) {
616 return;
617 }
618
619 // A decoded image always has at least one pixel in each axis, so there is no
620 // zero-width case to guard the division below against.
621 $natural_width = imagesx( $image );
622 $natural_height = imagesy( $image );
623
624 // unset() rather than imagedestroy(), here and at the other four GD
625 // handles in this class. imagedestroy() is deprecated as of PHP 8.5 and
626 // has been a no-op since 8.0, where GD started returning GdImage objects
627 // that the collector frees. Dropping the only reference does the same
628 // work on 8.x AND on 7.4, where the handle is still a resource freed by
629 // refcount — so this needs no version branch. Do not reintroduce the
630 // call; the lint gate fails on it once CI moves past PHP 8.1.
631 unset( $image );
632
633 $target = $requested_dots > 0 ? min( $requested_dots, $this->dots ) : min( $natural_width, $this->dots );
634 $scale = $target / $natural_width;
635
636 $this->push(
637 array(
638 'op' => 'image',
639 'png' => $png,
640 'width' => max( 1, (int) round( $natural_width * $scale ) ),
641 'height' => max( 1, (int) round( $natural_height * $scale ) ),
642 )
643 );
644 }
645
646 /**
647 * Append text to the buffered line under the current style.
648 *
649 * @param string $text The text.
650 *
651 * @return void
652 */
653 private function append_text( string $text ): void {
654 if ( '' === $text ) {
655 return;
656 }
657
658 $last = \count( $this->runs ) - 1;
659 if ( $last >= 0
660 && $this->runs[ $last ]['bold'] === $this->bold
661 && $this->runs[ $last ]['invert'] === $this->invert
662 && $this->runs[ $last ]['w'] === $this->width
663 && $this->runs[ $last ]['h'] === $this->height
664 ) {
665 $this->runs[ $last ]['text'] .= $text;
666
667 return;
668 }
669
670 $this->runs[] = array(
671 'text' => $text,
672 'bold' => $this->bold,
673 'invert' => $this->invert,
674 'w' => $this->width,
675 'h' => $this->height,
676 );
677 }
678
679 /**
680 * Close the buffered runs into one display-list entry per physical line.
681 *
682 * A thermal printer wraps a line that runs past the paper; nothing wraps a
683 * raster, so the wrapping happens here. Without it an over-long product name
684 * would simply be cut off at the paper edge — text the other emitters print
685 * in full on the next line.
686 *
687 * @param string $align Alignment for the closed lines.
688 *
689 * @return void
690 */
691 private function close_line( string $align ): void {
692 $runs = $this->runs;
693 $this->runs = array();
694
695 if ( array() === $runs ) {
696 $this->push_line( array(), $align );
697
698 return;
699 }
700
701 $line = array();
702 $cells = 0;
703
704 foreach ( $runs as $run ) {
705 $pending = '';
706 foreach ( Thermal_Text_Layout::split_chars( $run['text'] ) as $char ) {
707 if ( "\n" === $char ) {
708 $line = $this->close_run( $line, $run, $pending );
709 $this->push_line( $line, $align );
710 $line = array();
711 $cells = 0;
712 $pending = '';
713 continue;
714 }
715
716 $cost = ( Thermal_Text_Layout::is_full_width( $char ) ? 2 : 1 ) * max( 1, (int) $run['w'] );
717 if ( $cells + $cost > $this->columns && ( array() !== $line || '' !== $pending ) ) {
718 $line = $this->close_run( $line, $run, $pending );
719 $this->push_line( $line, $align );
720 $line = array();
721 $cells = 0;
722 $pending = '';
723 }
724
725 $pending .= $char;
726 $cells += $cost;
727 }
728
729 $line = $this->close_run( $line, $run, $pending );
730 }
731
732 $this->push_line( $line, $align );
733 }
734
735 /**
736 * Append a run's accumulated characters to the line being assembled.
737 *
738 * @param array $line The line so far.
739 * @param array $run The run supplying the style.
740 * @param string $pending The characters accumulated for this run.
741 *
742 * @return array The line.
743 */
744 private function close_run( array $line, array $run, string $pending ): array {
745 if ( '' !== $pending ) {
746 $line[] = array_merge( $run, array( 'text' => $pending ) );
747 }
748
749 return $line;
750 }
751
752 /**
753 * Push one physical line onto the display list.
754 *
755 * @param array $line Runs making up the line.
756 * @param string $align Alignment mode.
757 *
758 * @return void
759 */
760 private function push_line( array $line, string $align ): void {
761 $line = $this->rtrim_line( $line );
762
763 $cells = 0;
764 $scale = 1;
765 foreach ( $line as $run ) {
766 $cells += Thermal_Text_Layout::display_width( $run['text'] ) * max( 1, (int) $run['w'] );
767 $scale = max( $scale, max( 1, (int) $run['h'] ) );
768 }
769
770 $this->push(
771 array(
772 'op' => 'text',
773 'runs' => $line,
774 'indent' => 'left' === $align ? 0 : Thermal_Text_Layout::alignment_padding( $align, $cells, $this->columns ),
775 'height' => $scale,
776 )
777 );
778 }
779
780 /**
781 * Drop trailing whitespace from a line's last run.
782 *
783 * @param array $line Runs making up the line.
784 *
785 * @return array
786 */
787 private function rtrim_line( array $line ): array {
788 for ( $index = \count( $line ) - 1; $index >= 0; $index-- ) {
789 $trimmed = rtrim( $line[ $index ]['text'], " \t" );
790 if ( '' !== $trimmed ) {
791 $line[ $index ]['text'] = $trimmed;
792 break;
793 }
794 unset( $line[ $index ] );
795 }
796
797 return array_values( $line );
798 }
799
800 /**
801 * Flush text buffered outside a line-terminating node.
802 *
803 * @return void
804 */
805 private function flush_line(): void {
806 if ( array() !== $this->runs ) {
807 $this->close_line( $this->align );
808 }
809 }
810
811 /**
812 * Append a display-list entry.
813 *
814 * @param array $op The entry.
815 *
816 * @return void
817 */
818 private function push( array $op ): void {
819 $this->ops[] = $op;
820 }
821
822 /**
823 * Height in dots of one display-list entry.
824 *
825 * @param array $op The entry.
826 *
827 * @return int
828 */
829 private function op_height( array $op ): int {
830 $line = (int) round( $this->cell * self::LINE_HEIGHT_RATIO );
831
832 switch ( $op['op'] ) {
833 case 'text':
834 return $line * (int) $op['height'];
835 case 'feed':
836 return $line * (int) $op['lines'];
837 case 'image':
838 return (int) $op['height'];
839 default:
840 return 0;
841 }
842 }
843
844 /**
845 * Draw the display list onto a canvas and encode it.
846 *
847 * @return string The PNG bytes.
848 */
849 private function paint(): string {
850 $total = 0;
851 foreach ( $this->ops as $op ) {
852 $total += $this->op_height( $op );
853 }
854 $total = max( 1, min( self::MAX_HEIGHT, $total ) );
855
856 // A palette canvas, not a truecolour one: allocating white and then black
857 // gives an image with exactly those two colours and a white background.
858 // Drawing truecolour and calling imagetruecolortopalette() afterwards does
859 // NOT — GD's quantizer rewrites pure white as (252,254,252), which is not
860 // what a thermal head should be handed.
861 $canvas = imagecreate( $this->dots, $total );
862 if ( false === $canvas ) {
863 return '';
864 }
865
866 $white = imagecolorallocate( $canvas, 255, 255, 255 );
867 $black = imagecolorallocate( $canvas, 0, 0, 0 );
868
869 // Antialiasing is switched off by passing imagettftext() a *negative* colour
870 // index — but white is index 0 here, and -0 is 0, so inverted text would
871 // silently keep antialiasing and stipple grey into a two-colour image. A
872 // second, deliberately duplicate white allocation gives it an index that can
873 // actually be negated.
874 $white_ink = imagecolorallocate( $canvas, 255, 255, 255 );
875
876 $y = 0;
877 foreach ( $this->ops as $op ) {
878 $height = $this->op_height( $op );
879 if ( $y >= $total ) {
880 break;
881 }
882
883 if ( 'text' === $op['op'] && array() !== $op['runs'] ) {
884 $this->draw_text( $canvas, $op, $y, $white, $white_ink, $black );
885 } elseif ( 'image' === $op['op'] ) {
886 $this->draw_image( $canvas, $op, $y, $white, $black );
887 }
888
889 $y += $height;
890 }
891
892 ob_start();
893 imagepng( $canvas, null, 9 );
894 $png = (string) ob_get_clean();
895 unset( $canvas );
896
897 return $png;
898 }
899
900 /**
901 * Draw one physical line, run by run and cell by cell.
902 *
903 * @param resource|object $canvas The target canvas.
904 * @param array $op The display-list entry.
905 * @param int $top Top of the line box, in dots.
906 * @param int $white Background colour index.
907 * @param int $white_ink Negatable white index, for inverted text.
908 * @param int $black Foreground colour index.
909 *
910 * @return void
911 */
912 private function draw_text( $canvas, array $op, int $top, int $white, int $white_ink, int $black ): void {
913 $box = (int) round( $this->cell * self::LINE_HEIGHT_RATIO ) * max( 1, (int) $op['height'] );
914 $x = $this->margin + ( (int) $op['indent'] * $this->cell );
915
916 foreach ( $op['runs'] as $run ) {
917 $x = $this->draw_run( $canvas, $run, $x, $top, $box, $white, $white_ink, $black );
918 if ( $x >= $this->dots ) {
919 break;
920 }
921 }
922 }
923
924 /**
925 * Draw one styled run, returning the x it ends at.
926 *
927 * Unscaled runs are painted straight onto the canvas. Scaled ones are drawn
928 * at base size into a scratch canvas and copied across at integer factors,
929 * because GD cannot scale a glyph's axes independently: doubling the font
930 * size for `<size height="2">` would also double the glyph's width while the
931 * cell advance stayed put, overlapping every character with its neighbour.
932 * Nearest-neighbour scaling of a two-colour bitmap stays two-colour, so this
933 * costs no crispness.
934 *
935 * @param resource|object $canvas The target canvas.
936 * @param array $run The run.
937 * @param int $x Left edge, in dots.
938 * @param int $top Top of the line box, in dots.
939 * @param int $box Line box height, in dots.
940 * @param int $white Background colour index.
941 * @param int $white_ink Negatable white index.
942 * @param int $black Foreground colour index.
943 *
944 * @return int The x after the run.
945 */
946 private function draw_run( $canvas, array $run, int $x, int $top, int $box, int $white, int $white_ink, int $black ): int {
947 $scale_x = max( 1, (int) $run['w'] );
948 $scale_y = max( 1, (int) $run['h'] );
949 $chars = Thermal_Text_Layout::split_chars( (string) $run['text'] );
950 $cells = 0;
951 foreach ( $chars as $char ) {
952 $cells += Thermal_Text_Layout::is_full_width( $char ) ? 2 : 1;
953 }
954
955 $span = $cells * $this->cell * $scale_x;
956 if ( $run['invert'] ) {
957 imagefilledrectangle( $canvas, $x, $top, min( $this->dots - 1, $x + $span - 1 ), $top + $box - 1, $black );
958 }
959
960 if ( 1 === $scale_x && 1 === $scale_y ) {
961 $this->draw_cells( $canvas, $chars, $x, $top + (int) round( $box * self::BASELINE_RATIO ), $this->cell, $this->font_size, $run, $white_ink, $black );
962
963 return $x + $span;
964 }
965
966 // Scratch is one line box at base scale; the copy below stretches it.
967 $base_box = (int) round( $this->cell * self::LINE_HEIGHT_RATIO );
968 $base_span = max( 1, $cells * $this->cell );
969 $scratch = imagecreatetruecolor( $base_span, $base_box );
970 if ( false === $scratch ) {
971 return $x + $span;
972 }
973
974 $scratch_bg = imagecolorallocate( $scratch, 255, 255, 255 );
975 $scratch_ink = imagecolorallocate( $scratch, 0, 0, 0 );
976 if ( $run['invert'] ) {
977 $swap = $scratch_bg;
978 $scratch_bg = $scratch_ink;
979 $scratch_ink = $swap;
980 }
981 imagefilledrectangle( $scratch, 0, 0, $base_span - 1, $base_box - 1, $scratch_bg );
982 $this->draw_cells(
983 $scratch,
984 $chars,
985 0,
986 (int) round( $base_box * self::BASELINE_RATIO ),
987 $this->cell,
988 $this->font_size,
989 $run,
990 $scratch_ink,
991 $scratch_ink
992 );
993
994 $this->composite_thresholded( $canvas, $scratch, $x, $top, $base_span * $scale_x, $base_box * $scale_y, $white, $black );
995 unset( $scratch );
996
997 return $x + $span;
998 }
999
1000 /**
1001 * Paint a run's glyphs at fixed cell positions.
1002 *
1003 * @param resource|object $canvas The target canvas.
1004 * @param array $chars The characters.
1005 * @param int $x Left edge, in dots.
1006 * @param int $baseline Text baseline, in dots.
1007 * @param int $cell Cell width, in dots.
1008 * @param int $size Font size.
1009 * @param array $run The run, for its bold flag.
1010 * @param int $white_ink Negatable white index.
1011 * @param int $black Foreground colour index.
1012 *
1013 * @return void
1014 */
1015 private function draw_cells( $canvas, array $chars, int $x, int $baseline, int $cell, int $size, array $run, int $white_ink, int $black ): void {
1016 $font = $run['bold'] ? self::bold_font_path() : self::font_path();
1017 $ink = $run['invert'] ? $white_ink : $black;
1018 $limit = imagesx( $canvas );
1019
1020 foreach ( $chars as $char ) {
1021 if ( $x >= $limit ) {
1022 break;
1023 }
1024 if ( ' ' !== $char ) {
1025 // A negative colour index turns antialiasing off, so a glyph lands as
1026 // the exact ink colour instead of allocating grey palette entries the
1027 // printer would have to guess at.
1028 imagettftext( $canvas, $size, 0, $x, $baseline, -$ink, $font, $char );
1029 }
1030 // A full-width glyph occupies the two cells display_width() counts.
1031 $x += $cell * ( Thermal_Text_Layout::is_full_width( $char ) ? 2 : 1 );
1032 }
1033 }
1034
1035 /**
1036 * Composite an image block, centred on the paper.
1037 *
1038 * @param resource|object $canvas The target canvas (GD resource on PHP 7.4, GdImage on 8+).
1039 * @param array $op The display-list entry.
1040 * @param int $top Top of the block, in dots.
1041 * @param int $white Background colour index.
1042 * @param int $black Foreground colour index.
1043 *
1044 * @return void
1045 */
1046 private function draw_image( $canvas, array $op, int $top, int $white, int $black ): void {
1047 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid image data returns false rather than warning.
1048 $source = @imagecreatefromstring( (string) $op['png'] );
1049 if ( false === $source ) {
1050 return;
1051 }
1052
1053 $left = max( 0, (int) floor( ( $this->dots - (int) $op['width'] ) / 2 ) );
1054 $this->composite_thresholded( $canvas, $source, $left, $top, (int) $op['width'], (int) $op['height'], $white, $black );
1055 unset( $source );
1056 }
1057
1058 /**
1059 * Scale a source image onto the canvas as pure black and white.
1060 *
1061 * Copying straight onto the palette canvas would allocate a grey entry for
1062 * every interpolated pixel, leaving the printer to dither a receipt we want
1063 * crisp — so the resample lands in a scratch buffer and each pixel is
1064 * thresholded on its way across.
1065 *
1066 * @param resource|object $canvas The target canvas.
1067 * @param resource|object $source The source image.
1068 * @param int $left Destination left, in dots.
1069 * @param int $top Destination top, in dots.
1070 * @param int $width Destination width, in dots.
1071 * @param int $height Destination height, in dots.
1072 * @param int $white Background colour index.
1073 * @param int $black Foreground colour index.
1074 *
1075 * @return void
1076 */
1077 private function composite_thresholded( $canvas, $source, int $left, int $top, int $width, int $height, int $white, int $black ): void {
1078 $width = max( 1, $width );
1079 $height = max( 1, $height );
1080
1081 $scaled = imagecreatetruecolor( $width, $height );
1082 if ( false === $scaled ) {
1083 return;
1084 }
1085
1086 imagefilledrectangle( $scaled, 0, 0, $width - 1, $height - 1, imagecolorallocate( $scaled, 255, 255, 255 ) );
1087 imagecopyresampled( $scaled, $source, 0, 0, 0, 0, $width, $height, imagesx( $source ), imagesy( $source ) );
1088
1089 $canvas_width = imagesx( $canvas );
1090 $canvas_height = imagesy( $canvas );
1091
1092 for ( $row = 0; $row < $height; $row++ ) {
1093 $y = $top + $row;
1094 if ( $y < 0 || $y >= $canvas_height ) {
1095 continue;
1096 }
1097 for ( $column = 0; $column < $width; $column++ ) {
1098 $x = $left + $column;
1099 if ( $x < 0 || $x >= $canvas_width ) {
1100 continue;
1101 }
1102
1103 $rgb = imagecolorat( $scaled, $column, $row );
1104 // Rec. 601 luma, the standard grey weighting, thresholded at mid-grey.
1105 $luma = ( 0.299 * ( ( $rgb >> 16 ) & 0xFF ) )
1106 + ( 0.587 * ( ( $rgb >> 8 ) & 0xFF ) )
1107 + ( 0.114 * ( $rgb & 0xFF ) );
1108
1109 imagesetpixel( $canvas, $x, $y, $luma < 128 ? $black : $white );
1110 }
1111 }
1112
1113 unset( $scaled );
1114 }
1115 }
1116