PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
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_Bitmap.php

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

286 lines 9.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Monochrome Bitmap Class.
4 *
5 * Turns a template `<image>` (in practice, the store logo) into the one thing
6 * every native printer command set wants: a grid of black-or-white dots at the
7 * paper's own resolution. The three native lanes then pack those dots in
8 * whatever order their command takes — row-major for ESC/POS `GS v 0` and
9 * ePOS-Print `<image>`, column-major 24-dot bands for StarPRNT `ESC X`.
10 *
11 * Before this existed the native lanes dropped `<image>` nodes on the floor and
12 * said server-side rasterization was out of scope. It was never out of scope:
13 * Raster_Thermal_Emitter already decoded, scaled and thresholded logos the same
14 * way, and Local_Image_Resolver already read them off disk without an outbound
15 * request. This is that work, lifted to where all four lanes can share it.
16 *
17 * @author Paul Kilmurray <paul@kilbot.com>
18 *
19 * @see http://wcpos.com
20 * @package WCPOS\WooCommercePOS
21 */
22
23 namespace WCPOS\WooCommercePOS\Templates\Thermal;
24
25 use WCPOS\WooCommercePOS\Services\Local_Image_Resolver;
26
27 /**
28 * Thermal_Bitmap class.
29 */
30 final class Thermal_Bitmap {
31
32 /**
33 * Luma below which a pixel is printed as a black dot.
34 *
35 * Mid-grey, matching Raster_Thermal_Emitter::composite_thresholded() so a
36 * logo lands identically whichever lane renders it.
37 */
38 private const BLACK_THRESHOLD = 128;
39
40 /**
41 * Hard ceiling on the rendered height, in dots.
42 *
43 * 2047 is the most restrictive vertical limit documented across the raster
44 * commands these dots feed — ESC/POS `GS v 0` counts rows in (yL + yH x 256)
45 * and Epson's TM reference caps that range on several models. An image past
46 * it is scaled down to fit rather than rejected: 2047 dots is already ~25 cm
47 * of paper, so anything taller is a template mistake, and a smaller logo
48 * beats a command the printer silently ignores.
49 */
50 private const MAX_HEIGHT = 2047;
51
52 /**
53 * Ceiling on the source image, in pixels, before it is decoded.
54 *
55 * Decoding decompresses the whole image into ~4 bytes per pixel, so a
56 * merchant who sets a 40-megapixel photo as the store logo would
57 * spend ~160 MB to produce a 576-dot bitmap — inside the printer's job fetch,
58 * where running out of memory means no receipt at all. 16 MP costs ~64 MB and
59 * is far past any real logo, so the dimensions are read from the header
60 * first and anything larger is skipped without decoding.
61 */
62 private const MAX_SOURCE_PIXELS = 16000000;
63
64 /**
65 * Width in dots. Always a multiple of 8.
66 *
67 * @var int
68 */
69 private $width;
70
71 /**
72 * Height in dots.
73 *
74 * @var int
75 */
76 private $height;
77
78 /**
79 * Row-major packed dots: ceil(width / 8) bytes per row, MSB first, set = black.
80 *
81 * @var string
82 */
83 private $raster;
84
85 /**
86 * Construct from packed geometry.
87 *
88 * @param int $width Width in dots (a multiple of 8).
89 * @param int $height Height in dots.
90 * @param string $raster Row-major packed dots.
91 */
92 private function __construct( int $width, int $height, string $raster ) {
93 $this->width = $width;
94 $this->height = $height;
95 $this->raster = $raster;
96 }
97
98 /**
99 * Build a bitmap from an image AST node.
100 *
101 * Resolution goes through Local_Image_Resolver, so a data URI is decoded in
102 * place, a local WordPress URL is read off disk, and a remote URL resolves to
103 * nothing rather than being fetched — this runs inside the printer's job
104 * fetch, where an outbound request would stall the print.
105 *
106 * @param array $node The image AST node (`src`, `width`).
107 * @param int $max_dots Printable width of the paper, in dots.
108 *
109 * @return self|null The bitmap, or null when the image is unusable.
110 */
111 public static function from_node( array $node, int $max_dots ): ?self {
112 $bytes = ( new Local_Image_Resolver() )->bytes( isset( $node['src'] ) ? (string) $node['src'] : '' );
113
114 return self::from_bytes(
115 $bytes,
116 isset( $node['width'] ) ? (int) $node['width'] : 0,
117 $max_dots
118 );
119 }
120
121 /**
122 * Build a bitmap from raw image bytes.
123 *
124 * @param string $bytes Encoded image bytes (PNG, JPEG, GIF, ...).
125 * @param int $requested_dots Preferred width in dots, or 0 for natural size.
126 * @param int $max_dots Printable width of the paper, in dots.
127 *
128 * @return self|null The bitmap, or null when the bytes are not a usable image.
129 */
130 public static function from_bytes( string $bytes, int $requested_dots, int $max_dots ): ?self {
131 if ( '' === $bytes || ! \function_exists( 'imagecreatefromstring' ) ) {
132 return null;
133 }
134
135 // Read the dimensions from the header before decoding. getimagesizefromstring()
136 // parses only the header, so an oversized source costs nothing to reject,
137 // where imagecreatefromstring() would have to decompress it first.
138 $size = \function_exists( 'getimagesizefromstring' ) ? @getimagesizefromstring( $bytes ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Non-images return false rather than warning.
139 if ( \is_array( $size ) && isset( $size[0], $size[1] ) ) {
140 if ( $size[0] < 1 || $size[1] < 1 || ( $size[0] * $size[1] ) > self::MAX_SOURCE_PIXELS ) {
141 return null;
142 }
143 }
144
145 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid image data returns false rather than warning.
146 $source = @imagecreatefromstring( $bytes );
147 if ( false === $source ) {
148 return null;
149 }
150
151 // A decoded image always has at least one pixel in each axis, so the
152 // division below needs no zero guard.
153 $natural_width = imagesx( $source );
154 $natural_height = imagesy( $source );
155
156 $max_dots = max( 8, $max_dots );
157 $target = $requested_dots > 0 ? min( $requested_dots, $max_dots ) : min( $natural_width, $max_dots );
158 $target = max( 1, $target );
159 $height = max( 1, (int) round( $natural_height * ( $target / $natural_width ) ) );
160
161 // A very tall image is scaled down to fit rather than dropped, so the
162 // aspect ratio survives and the raster stays inside every lane's row count.
163 if ( $height > self::MAX_HEIGHT ) {
164 $target = max( 1, (int) floor( $target * ( self::MAX_HEIGHT / $height ) ) );
165 $height = self::MAX_HEIGHT;
166 }
167
168 // ePOS-Print wants the width in whole bytes ("set the image width to a
169 // multiple of 8, or set the missing bits to 0" — ePOS-Print XML User's
170 // Manual). Padding here rather than in each lane keeps every lane's
171 // packing loop free of a partial trailing byte.
172 $padded = (int) ( ceil( $target / 8 ) * 8 );
173
174 $scaled = imagecreatetruecolor( $padded, $height );
175 if ( false === $scaled ) {
176 unset( $source );
177
178 return null;
179 }
180
181 // White ground, so the pad columns read as blank paper and a logo with an
182 // alpha channel composites onto white rather than onto black.
183 imagefilledrectangle( $scaled, 0, 0, $padded - 1, $height - 1, imagecolorallocate( $scaled, 255, 255, 255 ) );
184 imagecopyresampled( $scaled, $source, 0, 0, 0, 0, $target, $height, $natural_width, $natural_height );
185 unset( $source );
186
187 $raster = self::pack( $scaled, $padded, $height );
188 unset( $scaled );
189
190 return new self( $padded, $height, $raster );
191 }
192
193 /**
194 * The width in dots. Always a multiple of 8.
195 *
196 * @return int
197 */
198 public function width(): int {
199 return $this->width;
200 }
201
202 /**
203 * The height in dots.
204 *
205 * @return int
206 */
207 public function height(): int {
208 return $this->height;
209 }
210
211 /**
212 * The dots row by row: width/8 bytes per row, MSB first, set bit = black dot.
213 *
214 * This is the layout ESC/POS `GS v 0` and ePOS-Print `<image>` both take.
215 *
216 * @return string
217 */
218 public function raster(): string {
219 return $this->raster;
220 }
221
222 /**
223 * The number of bytes in one raster row.
224 *
225 * @return int
226 */
227 public function bytes_per_row(): int {
228 return (int) ( $this->width / 8 );
229 }
230
231 /**
232 * Read one dot.
233 *
234 * Out-of-range coordinates read as white, so a caller packing fixed-height
235 * bands does not have to special-case the last, short band.
236 *
237 * @param int $x Column, from the left.
238 * @param int $y Row, from the top.
239 *
240 * @return int 1 for a black dot, 0 for blank paper.
241 */
242 public function pixel( int $x, int $y ): int {
243 if ( $x < 0 || $y < 0 || $x >= $this->width || $y >= $this->height ) {
244 return 0;
245 }
246
247 $byte = \ord( $this->raster[ ( $y * $this->bytes_per_row() ) + ( $x >> 3 ) ] );
248
249 return ( $byte >> ( 7 - ( $x % 8 ) ) ) & 1;
250 }
251
252 /**
253 * Threshold a GD image into row-major packed dots.
254 *
255 * @param resource|object $image The scaled image (GD resource on PHP 7.4, GdImage on 8+).
256 * @param int $width Width in dots (a multiple of 8).
257 * @param int $height Height in dots.
258 *
259 * @return string The packed raster.
260 */
261 private static function pack( $image, int $width, int $height ): string {
262 $raster = '';
263
264 for ( $y = 0; $y < $height; $y++ ) {
265 for ( $x = 0; $x < $width; $x += 8 ) {
266 $byte = 0;
267 for ( $bit = 0; $bit < 8; $bit++ ) {
268 $rgb = imagecolorat( $image, $x + $bit, $y );
269 // Rec. 601 luma, the standard grey weighting, thresholded at
270 // mid-grey — the same conversion Raster_Thermal_Emitter uses.
271 $luma = ( 0.299 * ( ( $rgb >> 16 ) & 0xFF ) )
272 + ( 0.587 * ( ( $rgb >> 8 ) & 0xFF ) )
273 + ( 0.114 * ( $rgb & 0xFF ) );
274
275 if ( $luma < self::BLACK_THRESHOLD ) {
276 $byte |= 1 << ( 7 - $bit );
277 }
278 }
279 $raster .= \chr( $byte );
280 }
281 }
282
283 return $raster;
284 }
285 }
286