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

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

409 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared character-cell layout for thermal emitters.
4 *
5 * Thermal printers lay text out in fixed character cells, so every emitter that
6 * targets one needs the same primitives: display-width measurement that counts
7 * CJK glyphs as two cells, truncation and padding against that width, star-column
8 * distribution for `<row>`, plain-text extraction, and typographic normalization
9 * for characters the printer's codepage cannot render.
10 *
11 * None of this has anything to do with ESC/POS, StarPRNT, ePOS-XML or Star
12 * Document Markup — a column is a column on all of them, and a receipt that lays
13 * out differently per protocol is a bug. These primitives lived as private copies
14 * in each emitter and drifted apart: the Star Markup copy measured U+FFE5
15 * FULLWIDTH YEN as one cell while the others measured two, and two copies of the
16 * no-mbstring fallback called mb_convert_encoding() — itself an mbstring
17 * function — so a host without the extension took a fatal.
18 *
19 * A per-lane instance owns the paper columns and nested applied magnification.
20 * The existing static primitives remain available to raster and other emitters.
21 *
22 * Text emission itself stays with each emitter: only the measuring moved.
23 *
24 * @author Paul Kilmurray <paul@kilbot.com>
25 *
26 * @see http://wcpos.com
27 * @package WCPOS\WooCommercePOS
28 */
29
30 namespace WCPOS\WooCommercePOS\Templates\Thermal;
31
32 /**
33 * Thermal_Text_Layout class.
34 */
35 final class Thermal_Text_Layout {
36
37 /**
38 * Paper width in character columns.
39 *
40 * @var int
41 */
42 private $columns;
43
44 /**
45 * Command-specific magnification ceiling (8 for ESC/POS, 6 for StarPRNT).
46 *
47 * @var int
48 */
49 private $max_magnification;
50
51 /**
52 * Applied sizes; the base entry is normal size.
53 *
54 * @var array
55 */
56 private $sizes = array(
57 array(
58 'width' => 1,
59 'height' => 1,
60 ),
61 );
62
63 /**
64 * Construct per-lane metrics.
65 *
66 * @param int $columns Paper width in character columns.
67 * @param int $max_magnification Largest multiplier the lane can encode.
68 */
69 public function __construct( int $columns, int $max_magnification ) {
70 $this->columns = $columns;
71 $this->max_magnification = $max_magnification;
72 }
73
74 /**
75 * Return the paper width in character columns.
76 *
77 * @return int
78 */
79 public function columns(): int {
80 return $this->columns;
81 }
82
83 /**
84 * Enter a size wrapper, replacing (not multiplying) the parent scale.
85 *
86 * @param int $width Requested width multiplier.
87 * @param int $height Requested height multiplier.
88 * @return void
89 */
90 public function enter_size( int $width, int $height ): void {
91 $this->sizes[] = array(
92 'width' => max( 1, min( $this->max_magnification, $width ) ),
93 'height' => max( 1, min( $this->max_magnification, $height ) ),
94 );
95 }
96
97 /**
98 * Leave a size wrapper and restore its parent.
99 *
100 * @return void
101 */
102 public function leave_size(): void {
103 if ( count( $this->sizes ) > 1 ) {
104 array_pop( $this->sizes );
105 }
106 }
107
108 /**
109 * Return the magnification actually encoded by the lane.
110 *
111 * @return array{width: int, height: int}
112 */
113 public function applied_scale(): array {
114 return $this->sizes[ count( $this->sizes ) - 1 ];
115 }
116
117 /**
118 * Count leading spaces using printed columns and the applied width scale.
119 *
120 * @param string $align Alignment mode (left|center|right).
121 * @param string $text Normalized plain text.
122 * @return int Number of literal spaces, each occupying the applied width.
123 */
124 public function measure_padding( string $align, string $text ): int {
125 return self::alignment_padding( $align, self::display_width( $text ), $this->columns, $this->applied_scale()['width'] );
126 }
127
128 /**
129 * Resolve row widths using the existing unscaled paper-column contract.
130 *
131 * @param array $cols Column AST nodes.
132 * @return array
133 */
134 public function measure_row_widths( array $cols ): array {
135 return self::resolve_row_widths( $cols, $this->columns );
136 }
137
138 /**
139 * Normalize text by replacing non-ASCII typographic characters.
140 *
141 * @param string $value The input text.
142 *
143 * @return string The normalized text.
144 */
145 public static function normalize_text( string $value ): string {
146 $search = array( "\u{2010}", "\u{2011}", "\u{2012}", "\u{2013}", "\u{2014}", "\u{2212}" );
147 $value = str_replace( $search, '-', $value );
148 $value = str_replace( array( "\u{2018}", "\u{2019}" ), "'", $value );
149 $value = str_replace( array( "\u{201C}", "\u{201D}" ), '"', $value );
150 // CLDR time patterns separate the hour from the day period with a narrow
151 // or thin no-break space; neither survives a printer character table.
152 $value = str_replace( array( "\u{00A0}", "\u{202F}", "\u{2009}" ), ' ', $value );
153
154 return $value;
155 }
156
157 /**
158 * Compute the display width of a string (full-width chars count as 2 cells).
159 *
160 * @param string $value The input text.
161 *
162 * @return int The display width in character cells.
163 */
164 public static function display_width( string $value ): int {
165 $width = 0;
166 foreach ( self::split_chars( $value ) as $char ) {
167 $width += self::is_full_width( $char ) ? 2 : 1;
168 }
169
170 return $width;
171 }
172
173 /**
174 * Truncate a string to a maximum display width.
175 *
176 * A full-width character that would straddle the limit is dropped whole
177 * rather than half-printed.
178 *
179 * @param string $value The input text.
180 * @param int $width The maximum display width in character cells.
181 *
182 * @return string The truncated text.
183 */
184 public static function truncate_display( string $value, int $width ): string {
185 $result = '';
186 $used = 0;
187 foreach ( self::split_chars( $value ) as $char ) {
188 $next = self::is_full_width( $char ) ? 2 : 1;
189 if ( $used + $next > $width ) {
190 break;
191 }
192 $result .= $char;
193 $used += $next;
194 }
195
196 return $result;
197 }
198
199 /**
200 * Split a UTF-8 string into an array of characters.
201 *
202 * @param string $value The input text.
203 *
204 * @return array The characters.
205 */
206 public static function split_chars( string $value ): array {
207 if ( '' === $value ) {
208 return array();
209 }
210 if ( \function_exists( 'mb_str_split' ) ) {
211 return mb_str_split( $value, 1, 'UTF-8' );
212 }
213 $chars = preg_split( '//u', $value, -1, PREG_SPLIT_NO_EMPTY );
214
215 return false === $chars ? array() : $chars;
216 }
217
218 /**
219 * Whether a single character occupies two character cells (full-width / CJK).
220 *
221 * @param string $char The single UTF-8 character.
222 *
223 * @return bool True when the character is full-width.
224 */
225 public static function is_full_width( string $char ): bool {
226 $code = self::code_point( $char );
227 if ( $code < 0 ) {
228 return false;
229 }
230
231 return ( $code >= 0x1100 && $code <= 0x115f )
232 // Angle brackets: East Asian Wide by UAX #11, unlike the ASCII pair.
233 || 0x2329 === $code
234 || 0x232a === $code
235 || ( $code >= 0x2e80 && $code <= 0xa4cf )
236 || ( $code >= 0xac00 && $code <= 0xd7a3 )
237 || ( $code >= 0xf900 && $code <= 0xfaff )
238 // Vertical forms and CJK compatibility forms.
239 || ( $code >= 0xfe10 && $code <= 0xfe19 )
240 || ( $code >= 0xfe30 && $code <= 0xfe6f )
241 || ( $code >= 0xff00 && $code <= 0xff60 )
242 // Fullwidth currency signs, U+FFE5 FULLWIDTH YEN among them.
243 || ( $code >= 0xffe0 && $code <= 0xffe6 )
244 // CJK Extension B and beyond: rare, but a single one of these
245 // mis-measured throws a whole row's column padding out.
246 || ( $code >= 0x20000 && $code <= 0x2fffd )
247 || ( $code >= 0x30000 && $code <= 0x3fffd );
248 }
249
250 /**
251 * Resolve the Unicode code point of a single character.
252 *
253 * The plugin does not require ext-mbstring, so the fallback decodes the UTF-8
254 * byte sequence by hand rather than reaching for another mb_ function: a host
255 * missing mb_ord() is missing the whole extension, so mb_convert_encoding()
256 * is not available to fall back onto either.
257 *
258 * @param string $char The single UTF-8 character.
259 *
260 * @return int The code point, or -1 when undetermined.
261 */
262 public static function code_point( string $char ): int {
263 if ( \function_exists( 'mb_ord' ) ) {
264 // mb_ord() is typed int by stubs; cast guards a theoretical false (invalid
265 // char) to 0, which is_full_width() treats as not full-width.
266 return (int) mb_ord( $char, 'UTF-8' );
267 }
268
269 $length = \strlen( $char );
270 if ( 1 === $length ) {
271 return \ord( $char );
272 }
273 if ( 2 === $length ) {
274 return ( ( \ord( $char[0] ) & 0x1f ) << 6 ) | ( \ord( $char[1] ) & 0x3f );
275 }
276 if ( 3 === $length ) {
277 return ( ( \ord( $char[0] ) & 0x0f ) << 12 ) | ( ( \ord( $char[1] ) & 0x3f ) << 6 ) | ( \ord( $char[2] ) & 0x3f );
278 }
279 if ( 4 === $length ) {
280 return ( ( \ord( $char[0] ) & 0x07 ) << 18 ) | ( ( \ord( $char[1] ) & 0x3f ) << 12 ) | ( ( \ord( $char[2] ) & 0x3f ) << 6 ) | ( \ord( $char[3] ) & 0x3f );
281 }
282
283 return -1;
284 }
285
286 /**
287 * Extract the concatenated raw text of a node subtree.
288 *
289 * @param array $nodes The AST nodes.
290 *
291 * @return string The concatenated text.
292 */
293 public static function extract_text( array $nodes ): string {
294 $text = '';
295 foreach ( $nodes as $node ) {
296 if ( ! \is_array( $node ) ) {
297 continue;
298 }
299 if ( isset( $node['type'] ) && 'raw-text' === $node['type'] ) {
300 $text .= isset( $node['value'] ) ? (string) $node['value'] : '';
301 } elseif ( isset( $node['children'] ) && \is_array( $node['children'] ) ) {
302 $text .= self::extract_text( $node['children'] );
303 }
304 }
305
306 return $text;
307 }
308
309 /**
310 * Resolve concrete column widths for a row, splitting star columns.
311 *
312 * Fixed widths are honoured first; whatever cells are left over are shared
313 * evenly between the star columns, with any remainder going to the last one.
314 *
315 * @param array $cols The column AST nodes.
316 * @param int $columns The paper width in character cells.
317 *
318 * @return array The resolved integer widths, indexed by column.
319 */
320 public static function resolve_row_widths( array $cols, int $columns ): array {
321 $fixed_total = 0;
322 $star_count = 0;
323 foreach ( $cols as $col ) {
324 if ( isset( $col['width'] ) && '*' === $col['width'] ) {
325 ++$star_count;
326 } else {
327 $fixed_total += self::fixed_col_width( $col );
328 }
329 }
330
331 $remaining = max( 0, $columns - $fixed_total );
332 $star_width = $star_count > 0 ? (int) floor( $remaining / $star_count ) : 0;
333 $star_remainder = $star_count > 0 ? $remaining - ( $star_width * $star_count ) : 0;
334
335 $widths = array();
336 $star_index = 0;
337 foreach ( $cols as $index => $col ) {
338 if ( isset( $col['width'] ) && '*' === $col['width'] ) {
339 ++$star_index;
340 $extra = ( $star_index === $star_count ) ? $star_remainder : 0;
341 $widths[ $index ] = max( 1, $star_width + $extra );
342 } else {
343 $widths[ $index ] = self::fixed_col_width( $col );
344 }
345 }
346
347 return $widths;
348 }
349
350 /**
351 * Compute the leading-space padding that aligns a line of the given width.
352 *
353 * The padding is emitted as literal spaces INSIDE the run it indents, so under a `<size>`
354 * multiplier each one is $scale cells wide -- as is each character of the text. Callers that
355 * emit bytes to a printer must pass the multiplier in force; a count taken at scale 1 lays
356 * down $scale times the margin asked for and wraps the line. Callers that place glyphs at
357 * computed cell positions (the raster emitter) already fold the multiplier into $text_width
358 * and leave $scale at 1.
359 *
360 * @param string $align The alignment mode (left|center|right).
361 * @param int $text_width The display width of the line's plain text, in unscaled cells.
362 * @param int $columns The paper width in character cells.
363 * @param int $scale The text width multiplier in force. Default 1.
364 *
365 * @return int The number of leading spaces (clamped at 0).
366 */
367 public static function alignment_padding( string $align, int $text_width, int $columns, int $scale = 1 ): int {
368 $scale = max( 1, $scale );
369 $remaining = $columns - ( $text_width * $scale );
370 if ( $remaining <= 0 ) {
371 return 0;
372 }
373 if ( 'center' === $align ) {
374 return (int) floor( (int) floor( $remaining / 2 ) / $scale );
375 }
376 if ( 'right' === $align ) {
377 return (int) floor( $remaining / $scale );
378 }
379
380 return 0;
381 }
382
383 /**
384 * A fixed column's width, bounded to what a printer can actually lay out.
385 *
386 * Both the parser and the preview bound `<col width>`; without the same bound
387 * here a hand-built AST -- or a column wider than the paper -- would pad past
388 * the row and wrap onto another physical line, which is the preview/print
389 * divergence this bound exists to close. Star columns are resolved from the
390 * remaining space and never come through here.
391 *
392 * @param array $col The column AST node.
393 *
394 * @return int The bounded width, or 0 when the column declares none.
395 */
396 private static function fixed_col_width( array $col ): int {
397 if ( ! isset( $col['width'] ) ) {
398 return 0;
399 }
400
401 return Thermal_Bounds::clamp_int(
402 $col['width'],
403 0,
404 Thermal_Bounds::COL_WIDTH_MIN,
405 Thermal_Bounds::COL_WIDTH_MAX
406 );
407 }
408 }
409