| 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 |
|