PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.6
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.6
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.6, at includes/Templates/Thermal/Thermal_Text_Layout.php

309 lines 9.9 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 * Deliberately stateless and static rather than a trait. Four private copies that
20 * *looked* locally defined are how the drift happened in the first place;
21 * `display_width()` called on `$this` is indistinguishable from a method the
22 * emitter owns, whereas `Thermal_Text_Layout::display_width()` names its source
23 * at every call.
24 * Being static also keeps `columns` an argument instead of an implicit property
25 * contract, and lets the layout be exercised on its own.
26 *
27 * Text emission itself stays with each emitter: only the measuring moved.
28 *
29 * @author Paul Kilmurray <paul@kilbot.com>
30 *
31 * @see http://wcpos.com
32 * @package WCPOS\WooCommercePOS
33 */
34
35 namespace WCPOS\WooCommercePOS\Templates\Thermal;
36
37 /**
38 * Thermal_Text_Layout class.
39 */
40 final class Thermal_Text_Layout {
41
42 /**
43 * Not instantiable: every member is a pure static function.
44 */
45 private function __construct() {}
46
47 /**
48 * Normalize text by replacing non-ASCII typographic characters.
49 *
50 * @param string $value The input text.
51 *
52 * @return string The normalized text.
53 */
54 public static function normalize_text( string $value ): string {
55 $search = array( "\u{2010}", "\u{2011}", "\u{2012}", "\u{2013}", "\u{2014}", "\u{2212}" );
56 $value = str_replace( $search, '-', $value );
57 $value = str_replace( array( "\u{2018}", "\u{2019}" ), "'", $value );
58 $value = str_replace( array( "\u{201C}", "\u{201D}" ), '"', $value );
59 // CLDR time patterns separate the hour from the day period with a narrow
60 // or thin no-break space; neither survives a printer character table.
61 $value = str_replace( array( "\u{00A0}", "\u{202F}", "\u{2009}" ), ' ', $value );
62
63 return $value;
64 }
65
66 /**
67 * Compute the display width of a string (full-width chars count as 2 cells).
68 *
69 * @param string $value The input text.
70 *
71 * @return int The display width in character cells.
72 */
73 public static function display_width( string $value ): int {
74 $width = 0;
75 foreach ( self::split_chars( $value ) as $char ) {
76 $width += self::is_full_width( $char ) ? 2 : 1;
77 }
78
79 return $width;
80 }
81
82 /**
83 * Truncate a string to a maximum display width.
84 *
85 * A full-width character that would straddle the limit is dropped whole
86 * rather than half-printed.
87 *
88 * @param string $value The input text.
89 * @param int $width The maximum display width in character cells.
90 *
91 * @return string The truncated text.
92 */
93 public static function truncate_display( string $value, int $width ): string {
94 $result = '';
95 $used = 0;
96 foreach ( self::split_chars( $value ) as $char ) {
97 $next = self::is_full_width( $char ) ? 2 : 1;
98 if ( $used + $next > $width ) {
99 break;
100 }
101 $result .= $char;
102 $used += $next;
103 }
104
105 return $result;
106 }
107
108 /**
109 * Split a UTF-8 string into an array of characters.
110 *
111 * @param string $value The input text.
112 *
113 * @return array The characters.
114 */
115 public static function split_chars( string $value ): array {
116 if ( '' === $value ) {
117 return array();
118 }
119 if ( \function_exists( 'mb_str_split' ) ) {
120 return mb_str_split( $value, 1, 'UTF-8' );
121 }
122 $chars = preg_split( '//u', $value, -1, PREG_SPLIT_NO_EMPTY );
123
124 return false === $chars ? array() : $chars;
125 }
126
127 /**
128 * Whether a single character occupies two character cells (full-width / CJK).
129 *
130 * @param string $char The single UTF-8 character.
131 *
132 * @return bool True when the character is full-width.
133 */
134 public static function is_full_width( string $char ): bool {
135 $code = self::code_point( $char );
136 if ( $code < 0 ) {
137 return false;
138 }
139
140 return ( $code >= 0x1100 && $code <= 0x115f )
141 // Angle brackets: East Asian Wide by UAX #11, unlike the ASCII pair.
142 || 0x2329 === $code
143 || 0x232a === $code
144 || ( $code >= 0x2e80 && $code <= 0xa4cf )
145 || ( $code >= 0xac00 && $code <= 0xd7a3 )
146 || ( $code >= 0xf900 && $code <= 0xfaff )
147 // Vertical forms and CJK compatibility forms.
148 || ( $code >= 0xfe10 && $code <= 0xfe19 )
149 || ( $code >= 0xfe30 && $code <= 0xfe6f )
150 || ( $code >= 0xff00 && $code <= 0xff60 )
151 // Fullwidth currency signs, U+FFE5 FULLWIDTH YEN among them.
152 || ( $code >= 0xffe0 && $code <= 0xffe6 )
153 // CJK Extension B and beyond: rare, but a single one of these
154 // mis-measured throws a whole row's column padding out.
155 || ( $code >= 0x20000 && $code <= 0x2fffd )
156 || ( $code >= 0x30000 && $code <= 0x3fffd );
157 }
158
159 /**
160 * Resolve the Unicode code point of a single character.
161 *
162 * The plugin does not require ext-mbstring, so the fallback decodes the UTF-8
163 * byte sequence by hand rather than reaching for another mb_ function: a host
164 * missing mb_ord() is missing the whole extension, so mb_convert_encoding()
165 * is not available to fall back onto either.
166 *
167 * @param string $char The single UTF-8 character.
168 *
169 * @return int The code point, or -1 when undetermined.
170 */
171 public static function code_point( string $char ): int {
172 if ( \function_exists( 'mb_ord' ) ) {
173 // mb_ord() is typed int by stubs; cast guards a theoretical false (invalid
174 // char) to 0, which is_full_width() treats as not full-width.
175 return (int) mb_ord( $char, 'UTF-8' );
176 }
177
178 $length = \strlen( $char );
179 if ( 1 === $length ) {
180 return \ord( $char );
181 }
182 if ( 2 === $length ) {
183 return ( ( \ord( $char[0] ) & 0x1f ) << 6 ) | ( \ord( $char[1] ) & 0x3f );
184 }
185 if ( 3 === $length ) {
186 return ( ( \ord( $char[0] ) & 0x0f ) << 12 ) | ( ( \ord( $char[1] ) & 0x3f ) << 6 ) | ( \ord( $char[2] ) & 0x3f );
187 }
188 if ( 4 === $length ) {
189 return ( ( \ord( $char[0] ) & 0x07 ) << 18 ) | ( ( \ord( $char[1] ) & 0x3f ) << 12 ) | ( ( \ord( $char[2] ) & 0x3f ) << 6 ) | ( \ord( $char[3] ) & 0x3f );
190 }
191
192 return -1;
193 }
194
195 /**
196 * Extract the concatenated raw text of a node subtree.
197 *
198 * @param array $nodes The AST nodes.
199 *
200 * @return string The concatenated text.
201 */
202 public static function extract_text( array $nodes ): string {
203 $text = '';
204 foreach ( $nodes as $node ) {
205 if ( ! \is_array( $node ) ) {
206 continue;
207 }
208 if ( isset( $node['type'] ) && 'raw-text' === $node['type'] ) {
209 $text .= isset( $node['value'] ) ? (string) $node['value'] : '';
210 } elseif ( isset( $node['children'] ) && \is_array( $node['children'] ) ) {
211 $text .= self::extract_text( $node['children'] );
212 }
213 }
214
215 return $text;
216 }
217
218 /**
219 * Resolve concrete column widths for a row, splitting star columns.
220 *
221 * Fixed widths are honoured first; whatever cells are left over are shared
222 * evenly between the star columns, with any remainder going to the last one.
223 *
224 * @param array $cols The column AST nodes.
225 * @param int $columns The paper width in character cells.
226 *
227 * @return array The resolved integer widths, indexed by column.
228 */
229 public static function resolve_row_widths( array $cols, int $columns ): array {
230 $fixed_total = 0;
231 $star_count = 0;
232 foreach ( $cols as $col ) {
233 if ( isset( $col['width'] ) && '*' === $col['width'] ) {
234 ++$star_count;
235 } else {
236 $fixed_total += self::fixed_col_width( $col );
237 }
238 }
239
240 $remaining = max( 0, $columns - $fixed_total );
241 $star_width = $star_count > 0 ? (int) floor( $remaining / $star_count ) : 0;
242 $star_remainder = $star_count > 0 ? $remaining - ( $star_width * $star_count ) : 0;
243
244 $widths = array();
245 $star_index = 0;
246 foreach ( $cols as $index => $col ) {
247 if ( isset( $col['width'] ) && '*' === $col['width'] ) {
248 ++$star_index;
249 $extra = ( $star_index === $star_count ) ? $star_remainder : 0;
250 $widths[ $index ] = max( 1, $star_width + $extra );
251 } else {
252 $widths[ $index ] = self::fixed_col_width( $col );
253 }
254 }
255
256 return $widths;
257 }
258
259 /**
260 * Compute the leading-space padding that aligns a line of the given width.
261 *
262 * @param string $align The alignment mode (left|center|right).
263 * @param int $text_width The display width of the line's plain text.
264 * @param int $columns The paper width in character cells.
265 *
266 * @return int The number of leading spaces (clamped at 0).
267 */
268 public static function alignment_padding( string $align, int $text_width, int $columns ): int {
269 $remaining = $columns - $text_width;
270 if ( $remaining <= 0 ) {
271 return 0;
272 }
273 if ( 'center' === $align ) {
274 return (int) floor( $remaining / 2 );
275 }
276 if ( 'right' === $align ) {
277 return $remaining;
278 }
279
280 return 0;
281 }
282
283 /**
284 * A fixed column's width, bounded to what a printer can actually lay out.
285 *
286 * Both the parser and the preview bound `<col width>`; without the same bound
287 * here a hand-built AST -- or a column wider than the paper -- would pad past
288 * the row and wrap onto another physical line, which is the preview/print
289 * divergence this bound exists to close. Star columns are resolved from the
290 * remaining space and never come through here.
291 *
292 * @param array $col The column AST node.
293 *
294 * @return int The bounded width, or 0 when the column declares none.
295 */
296 private static function fixed_col_width( array $col ): int {
297 if ( ! isset( $col['width'] ) ) {
298 return 0;
299 }
300
301 return Thermal_Bounds::clamp_int(
302 $col['width'],
303 0,
304 Thermal_Bounds::COL_WIDTH_MIN,
305 Thermal_Bounds::COL_WIDTH_MAX
306 );
307 }
308 }
309