`, plain-text extraction, and typographic normalization * for characters the printer's codepage cannot render. * * None of this has anything to do with ESC/POS, StarPRNT, ePOS-XML or Star * Document Markup — a column is a column on all of them, and a receipt that lays * out differently per protocol is a bug. These primitives lived as private copies * in each emitter and drifted apart: the Star Markup copy measured U+FFE5 * FULLWIDTH YEN as one cell while the others measured two, and two copies of the * no-mbstring fallback called mb_convert_encoding() — itself an mbstring * function — so a host without the extension took a fatal. * * A per-lane instance owns the paper columns and nested applied magnification. * The existing static primitives remain available to raster and other emitters. * * Text emission itself stays with each emitter: only the measuring moved. * * @author Paul Kilmurray * * @see http://wcpos.com * @package WCPOS\WooCommercePOS */ namespace WCPOS\WooCommercePOS\Templates\Thermal; /** * Thermal_Text_Layout class. */ final class Thermal_Text_Layout { /** * Paper width in character columns. * * @var int */ private $columns; /** * Command-specific magnification ceiling (8 for ESC/POS, 6 for StarPRNT). * * @var int */ private $max_magnification; /** * Applied sizes; the base entry is normal size. * * @var array */ private $sizes = array( array( 'width' => 1, 'height' => 1, ), ); /** * Construct per-lane metrics. * * @param int $columns Paper width in character columns. * @param int $max_magnification Largest multiplier the lane can encode. */ public function __construct( int $columns, int $max_magnification ) { $this->columns = $columns; $this->max_magnification = $max_magnification; } /** * Return the paper width in character columns. * * @return int */ public function columns(): int { return $this->columns; } /** * Enter a size wrapper, replacing (not multiplying) the parent scale. * * @param int $width Requested width multiplier. * @param int $height Requested height multiplier. * @return void */ public function enter_size( int $width, int $height ): void { $this->sizes[] = array( 'width' => max( 1, min( $this->max_magnification, $width ) ), 'height' => max( 1, min( $this->max_magnification, $height ) ), ); } /** * Leave a size wrapper and restore its parent. * * @return void */ public function leave_size(): void { if ( count( $this->sizes ) > 1 ) { array_pop( $this->sizes ); } } /** * Return the magnification actually encoded by the lane. * * @return array{width: int, height: int} */ public function applied_scale(): array { return $this->sizes[ count( $this->sizes ) - 1 ]; } /** * Count leading spaces using printed columns and the applied width scale. * * @param string $align Alignment mode (left|center|right). * @param string $text Normalized plain text. * @return int Number of literal spaces, each occupying the applied width. */ public function measure_padding( string $align, string $text ): int { return self::alignment_padding( $align, self::display_width( $text ), $this->columns, $this->applied_scale()['width'] ); } /** * Resolve row widths using the existing unscaled paper-column contract. * * @param array $cols Column AST nodes. * @return array */ public function measure_row_widths( array $cols ): array { return self::resolve_row_widths( $cols, $this->columns ); } /** * Normalize text by replacing non-ASCII typographic characters. * * @param string $value The input text. * * @return string The normalized text. */ public static function normalize_text( string $value ): string { $search = array( "\u{2010}", "\u{2011}", "\u{2012}", "\u{2013}", "\u{2014}", "\u{2212}" ); $value = str_replace( $search, '-', $value ); $value = str_replace( array( "\u{2018}", "\u{2019}" ), "'", $value ); $value = str_replace( array( "\u{201C}", "\u{201D}" ), '"', $value ); // CLDR time patterns separate the hour from the day period with a narrow // or thin no-break space; neither survives a printer character table. $value = str_replace( array( "\u{00A0}", "\u{202F}", "\u{2009}" ), ' ', $value ); return $value; } /** * Compute the display width of a string (full-width chars count as 2 cells). * * @param string $value The input text. * * @return int The display width in character cells. */ public static function display_width( string $value ): int { $width = 0; foreach ( self::split_chars( $value ) as $char ) { $width += self::is_full_width( $char ) ? 2 : 1; } return $width; } /** * Truncate a string to a maximum display width. * * A full-width character that would straddle the limit is dropped whole * rather than half-printed. * * @param string $value The input text. * @param int $width The maximum display width in character cells. * * @return string The truncated text. */ public static function truncate_display( string $value, int $width ): string { $result = ''; $used = 0; foreach ( self::split_chars( $value ) as $char ) { $next = self::is_full_width( $char ) ? 2 : 1; if ( $used + $next > $width ) { break; } $result .= $char; $used += $next; } return $result; } /** * Split a UTF-8 string into an array of characters. * * @param string $value The input text. * * @return array The characters. */ public static function split_chars( string $value ): array { if ( '' === $value ) { return array(); } if ( \function_exists( 'mb_str_split' ) ) { return mb_str_split( $value, 1, 'UTF-8' ); } $chars = preg_split( '//u', $value, -1, PREG_SPLIT_NO_EMPTY ); return false === $chars ? array() : $chars; } /** * Whether a single character occupies two character cells (full-width / CJK). * * @param string $char The single UTF-8 character. * * @return bool True when the character is full-width. */ public static function is_full_width( string $char ): bool { $code = self::code_point( $char ); if ( $code < 0 ) { return false; } return ( $code >= 0x1100 && $code <= 0x115f ) // Angle brackets: East Asian Wide by UAX #11, unlike the ASCII pair. || 0x2329 === $code || 0x232a === $code || ( $code >= 0x2e80 && $code <= 0xa4cf ) || ( $code >= 0xac00 && $code <= 0xd7a3 ) || ( $code >= 0xf900 && $code <= 0xfaff ) // Vertical forms and CJK compatibility forms. || ( $code >= 0xfe10 && $code <= 0xfe19 ) || ( $code >= 0xfe30 && $code <= 0xfe6f ) || ( $code >= 0xff00 && $code <= 0xff60 ) // Fullwidth currency signs, U+FFE5 FULLWIDTH YEN among them. || ( $code >= 0xffe0 && $code <= 0xffe6 ) // CJK Extension B and beyond: rare, but a single one of these // mis-measured throws a whole row's column padding out. || ( $code >= 0x20000 && $code <= 0x2fffd ) || ( $code >= 0x30000 && $code <= 0x3fffd ); } /** * Resolve the Unicode code point of a single character. * * The plugin does not require ext-mbstring, so the fallback decodes the UTF-8 * byte sequence by hand rather than reaching for another mb_ function: a host * missing mb_ord() is missing the whole extension, so mb_convert_encoding() * is not available to fall back onto either. * * @param string $char The single UTF-8 character. * * @return int The code point, or -1 when undetermined. */ public static function code_point( string $char ): int { if ( \function_exists( 'mb_ord' ) ) { // mb_ord() is typed int by stubs; cast guards a theoretical false (invalid // char) to 0, which is_full_width() treats as not full-width. return (int) mb_ord( $char, 'UTF-8' ); } $length = \strlen( $char ); if ( 1 === $length ) { return \ord( $char ); } if ( 2 === $length ) { return ( ( \ord( $char[0] ) & 0x1f ) << 6 ) | ( \ord( $char[1] ) & 0x3f ); } if ( 3 === $length ) { return ( ( \ord( $char[0] ) & 0x0f ) << 12 ) | ( ( \ord( $char[1] ) & 0x3f ) << 6 ) | ( \ord( $char[2] ) & 0x3f ); } if ( 4 === $length ) { return ( ( \ord( $char[0] ) & 0x07 ) << 18 ) | ( ( \ord( $char[1] ) & 0x3f ) << 12 ) | ( ( \ord( $char[2] ) & 0x3f ) << 6 ) | ( \ord( $char[3] ) & 0x3f ); } return -1; } /** * Extract the concatenated raw text of a node subtree. * * @param array $nodes The AST nodes. * * @return string The concatenated text. */ public static function extract_text( array $nodes ): string { $text = ''; foreach ( $nodes as $node ) { if ( ! \is_array( $node ) ) { continue; } if ( isset( $node['type'] ) && 'raw-text' === $node['type'] ) { $text .= isset( $node['value'] ) ? (string) $node['value'] : ''; } elseif ( isset( $node['children'] ) && \is_array( $node['children'] ) ) { $text .= self::extract_text( $node['children'] ); } } return $text; } /** * Resolve concrete column widths for a row, splitting star columns. * * Fixed widths are honoured first; whatever cells are left over are shared * evenly between the star columns, with any remainder going to the last one. * * @param array $cols The column AST nodes. * @param int $columns The paper width in character cells. * * @return array The resolved integer widths, indexed by column. */ public static function resolve_row_widths( array $cols, int $columns ): array { $fixed_total = 0; $star_count = 0; foreach ( $cols as $col ) { if ( isset( $col['width'] ) && '*' === $col['width'] ) { ++$star_count; } else { $fixed_total += self::fixed_col_width( $col ); } } $remaining = max( 0, $columns - $fixed_total ); $star_width = $star_count > 0 ? (int) floor( $remaining / $star_count ) : 0; $star_remainder = $star_count > 0 ? $remaining - ( $star_width * $star_count ) : 0; $widths = array(); $star_index = 0; foreach ( $cols as $index => $col ) { if ( isset( $col['width'] ) && '*' === $col['width'] ) { ++$star_index; $extra = ( $star_index === $star_count ) ? $star_remainder : 0; $widths[ $index ] = max( 1, $star_width + $extra ); } else { $widths[ $index ] = self::fixed_col_width( $col ); } } return $widths; } /** * Compute the leading-space padding that aligns a line of the given width. * * The padding is emitted as literal spaces INSIDE the run it indents, so under a `` * multiplier each one is $scale cells wide -- as is each character of the text. Callers that * emit bytes to a printer must pass the multiplier in force; a count taken at scale 1 lays * down $scale times the margin asked for and wraps the line. Callers that place glyphs at * computed cell positions (the raster emitter) already fold the multiplier into $text_width * and leave $scale at 1. * * @param string $align The alignment mode (left|center|right). * @param int $text_width The display width of the line's plain text, in unscaled cells. * @param int $columns The paper width in character cells. * @param int $scale The text width multiplier in force. Default 1. * * @return int The number of leading spaces (clamped at 0). */ public static function alignment_padding( string $align, int $text_width, int $columns, int $scale = 1 ): int { $scale = max( 1, $scale ); $remaining = $columns - ( $text_width * $scale ); if ( $remaining <= 0 ) { return 0; } if ( 'center' === $align ) { return (int) floor( (int) floor( $remaining / 2 ) / $scale ); } if ( 'right' === $align ) { return (int) floor( $remaining / $scale ); } return 0; } /** * A fixed column's width, bounded to what a printer can actually lay out. * * Both the parser and the preview bound ``; without the same bound * here a hand-built AST -- or a column wider than the paper -- would pad past * the row and wrap onto another physical line, which is the preview/print * divergence this bound exists to close. Star columns are resolved from the * remaining space and never come through here. * * @param array $col The column AST node. * * @return int The bounded width, or 0 when the column declares none. */ private static function fixed_col_width( array $col ): int { if ( ! isset( $col['width'] ) ) { return 0; } return Thermal_Bounds::clamp_int( $col['width'], 0, Thermal_Bounds::COL_WIDTH_MIN, Thermal_Bounds::COL_WIDTH_MAX ); } }