`), * but that name has to be translated for every render lane we ship: raw ESC/POS * bytes, raw StarPRNT bytes, Star Document Markup, ePOS-Print XML, and the * picqer rasterizer used by the HTML/PDF path. Each lane spells the same nine * symbologies differently, and two of them disagree about the *order* of the UPC * pair, so keeping the maps next to the emitters that use them guarantees they * drift. They live here instead, with a matrix test pinning every cell. * * Vendor references: * - ESC/POS: Epson ESC/POS Command Reference, `GS k` (barcode, function B). * - StarPRNT: StarPRNT Command Specifications Ver 1.3E, section 10 (barcode). * - ePOS-Print XML: Epson ePOS-Print XML reference, `` element. * * @author Paul Kilmurray * * @see http://wcpos.com * @package WCPOS\WooCommercePOS\Templates */ namespace WCPOS\WooCommercePOS\Templates; use WCPOS\Vendor\Picqer\Barcode\BarcodeGeneratorPNG; /** * Barcode_Symbology class. * * All-static and stateless: a pure translation table. * * Shape, and when to change it. The per-lane accessors come in pairs named for * the vendor (escpos_*, starprnt_*) rather than hanging off a lane-descriptor * object, because the lanes are not uniform: two want an integer selector, two * want a string name, and one wants a picqer constant. A descriptor would make * that return type a union and hand the XML and markup lanes members they never * use. Revisit when either becomes true: a third vendor needs its own payload * encoder, or the matrix test's provider grows past roughly seven columns. * * Known duplication this class does NOT address: emit_centered_text() is * identical in Escpos_Thermal_Emitter and Starprnt_Thermal_Emitter, alongside * eleven text-metric helpers those two files already duplicated before this * class existed. Extracting one of the twelve into a trait would fragment the * set rather than fix it; they belong together in a shared text-layout module, * which is scoped separately. Do not treat this class as the pattern for that * work — a static lookup owner suits a translation table keyed by lane, not * shared behaviour that needs emitter state. */ class Barcode_Symbology { /** * The 1D symbologies WCPOS supports, in canonical (parser) spelling. * * Every lane accessor below is total over this list. * * @var array */ public const SYMBOLOGIES = array( 'code128', 'code39', 'code93', 'ean13', 'ean8', 'upca', 'upce', 'codabar', 'itf', ); /** * Canonical name for the 2D QR symbology, which is not a 1D barcode. * * @var string */ public const QRCODE = 'qrcode'; /** * Symbology used when a template names one we do not support. * * Code 128 encodes any printable ASCII, so it is the only symbology that can * carry an arbitrary value without a data constraint of its own. * * @var string */ public const DEFAULT_SYMBOLOGY = 'code128'; /** * Lane discriminator for the ESC/POS byte emitter. * * @var string */ public const LANE_ESCPOS = 'escpos'; /** * Lane discriminator for the StarPRNT byte emitter. * * @var string */ public const LANE_STARPRNT = 'starprnt'; /** * Maximum barcode data bytes either byte lane will transmit. * * ESC/POS function B carries the length in a single byte (`GS k m n`), and * the StarPRNT barcode data field is capped at the same 255 for every * symbology, so one clamp covers both. * * @var int */ public const MAX_DATA_BYTES = 255; /** * ESC/POS `GS k` function-B symbology selector (the `m` parameter). * * @var array */ private const ESCPOS_IDS = array( 'upca' => 65, 'upce' => 66, 'ean13' => 67, 'ean8' => 68, 'code39' => 69, 'itf' => 70, 'codabar' => 71, 'code93' => 72, 'code128' => 73, ); /** * StarPRNT `ESC b n1` symbology selector. * * NOTE: the UPC pair is the inverse of the ESC/POS table above — Star numbers * UPC-E before UPC-A. Do not transcribe one table from the other. * * @var array */ private const STARPRNT_IDS = array( 'upce' => 0, 'upca' => 1, 'ean8' => 2, 'ean13' => 3, 'code39' => 4, 'itf' => 5, 'code128' => 6, 'code93' => 7, 'codabar' => 8, ); /** * Attribute values for the ePOS-Print XML `` element. * * Only the UPC pair differs from the canonical spelling: ePOS-Print * underscores them, and rejects the unseparated form outright. * * @var array */ private const EPOS_XML_NAMES = array( 'upca' => 'upc_a', 'upce' => 'upc_e', ); /** * Symbologies ePOS-Print accepts that WCPOS does not model itself. * * Source: Epson ePOS-Print XML reference, `` element. These are * accepted by the printer but absent from self::SYMBOLOGIES, so they are * passed through untranslated instead of being folded to Code 128. * * @var array */ private const EPOS_XML_ONLY_TYPES = array( 'jan13', 'jan8', 'code128_auto', 'gs1_128', 'gs1_databar_omnidirectional', 'gs1_databar_truncated', 'gs1_databar_limited', 'gs1_databar_expanded', ); /** * Star Document Markup `[barcode: type ...]` names. * * Star markup is alone in calling Codabar "NW-7". * * @var array */ private const STAR_MARKUP_NAMES = array( 'codabar' => 'nw7', ); /** * ESC/POS Code 128 code-set selector prefixed to the data. * * Function B requires the data to open with a code-set selector; without one * the printer prints nothing and reports no error. Set B carries the full * printable ASCII range, which is what receipt values use. * * @var string */ private const ESCPOS_CODE128_SELECTOR = '{B'; /** * Reduce a template's barcode type to a canonical name. * * @param string $type The barcode type attribute value. * * @return string A member of self::SYMBOLOGIES, or self::QRCODE. */ public static function normalize( string $type ): string { $normalized = strtolower( trim( $type ) ); if ( 'qr' === $normalized ) { $normalized = self::QRCODE; } // Star markup's spelling of Codabar leaks back in through templates // written against a Star printer; accept it as an alias. if ( 'nw7' === $normalized ) { $normalized = 'codabar'; } if ( self::QRCODE === $normalized ) { return self::QRCODE; } return \in_array( $normalized, self::SYMBOLOGIES, true ) ? $normalized : self::DEFAULT_SYMBOLOGY; } /** * Determine whether a barcode type should be rendered as a QR code. * * @param string $type The barcode type attribute value. * * @return bool True when the type is a QR variant. */ public static function is_qr( string $type ): bool { return self::QRCODE === self::normalize( $type ); } /** * Determine whether a value satisfies a symbology's data constraints. * * A printer given data it cannot encode prints nothing and reports no error, * so callers on the byte lanes check this first and print the value as text * instead of handing the printer a symbol it will silently drop. The same * rule governs the constraints below: where a value would print as a symbol * that scans back as something other than the value asked for, it is * rejected here, because a silently wrong barcode is worse than no barcode. * * The lane is required, not optional. Two symbologies differ between lanes: * UPC-E (ESC/POS accepts the 6-8 digit short form, StarPRNT requires the * full 11-12 digit payload) and Code 128 (the lanes escape and select code * sets differently, so both the alphabet and the encoded length differ). * Defaulting either to one vendor would dress that printer's rules up as a * neutral answer. * * @param string $type The barcode type attribute value. * @param string $value The barcode value. * @param string $lane Lane discriminator: self::LANE_ESCPOS or self::LANE_STARPRNT. * * @return bool True when the value can be encoded. */ public static function is_valid_value( string $type, string $value, string $lane ): bool { $symbology = self::normalize_linear( $type ); $length = \strlen( $value ); switch ( $symbology ) { case 'upca': if ( ! self::is_digits( $value ) ) { return false; } // 11 digits: the printer computes the check digit. 12: the last // digit is the check digit and must already be right. return 11 === $length || ( 12 === $length && self::has_valid_gtin_check_digit( $value ) ); case 'upce': // ESC/POS accepts the 6-8 digit short form as well as the full // 11-12 digit form; StarPRNT accepts only the full form. The full // form is a UPC-A payload the printer compresses, so its check // digit is the UPC-A one. The short form's check digit is derived // from an expansion only the printer performs, so it is left to // the printer rather than half-checked here. if ( ! self::is_digits( $value ) ) { return false; } if ( 12 === $length ) { return self::has_valid_gtin_check_digit( $value ); } if ( 11 === $length ) { return true; } return self::LANE_ESCPOS === $lane && $length >= 6 && $length <= 8; case 'ean13': if ( ! self::is_digits( $value ) ) { return false; } return 12 === $length || ( 13 === $length && self::has_valid_gtin_check_digit( $value ) ); case 'ean8': if ( ! self::is_digits( $value ) ) { return false; } return 7 === $length || ( 8 === $length && self::has_valid_gtin_check_digit( $value ) ); case 'code39': return self::is_valid_code39( $value ); case 'itf': // Interleaved 2 of 5 encodes digits in pairs, so an odd-length // value cannot be encoded at all. return self::is_digits( $value ) && $length >= 2 && $length <= 254 && 0 === $length % 2; case 'codabar': // The start and stop characters travel in the data itself. return $length >= 2 && $length <= 255 && 1 === preg_match( '/\A[A-Da-d][0-9\$\+\-\.\/:]*[A-Da-d]\z/', $value ); case 'code93': return $length >= 1 && $length <= 255 && self::is_ascii( $value ) && ( self::LANE_STARPRNT !== $lane || 0 === preg_match( '/[\x00-\x1f\x7f]/', $value ) ); case 'code128': default: return self::is_valid_code128( $value, $lane ); } } /** * Map a symbology to its ESC/POS `GS k` function-B selector. * * @param string $type The barcode type attribute value. * * @return int The `m` parameter byte. */ public static function escpos_id( string $type ): int { return self::ESCPOS_IDS[ self::normalize_linear( $type ) ] ?? self::ESCPOS_IDS[ self::DEFAULT_SYMBOLOGY ]; } /** * Build the ESC/POS data bytes for a barcode value. * * Code 128 alone needs a code-set selector prefixed to the data, and needs a * literal `{` doubled so it is not read as one. The prefix counts toward the * transmitted length, so the clamp is applied after it is added. * * @param string $type The barcode type attribute value. * @param string $value The barcode value. * * @return string The data bytes to transmit. */ public static function escpos_payload( string $type, string $value ): string { if ( self::DEFAULT_SYMBOLOGY !== self::normalize_linear( $type ) ) { return substr( $value, 0, self::MAX_DATA_BYTES ); } $payload = substr( self::escpos_code128_data( $value ), 0, self::MAX_DATA_BYTES ); // Clamping can split an escaped `{{` pair; a lone trailing `{` would be // read as the start of a code-set selector, so drop it. $trailing_braces = \strlen( $payload ) - \strlen( rtrim( $payload, '{' ) ); if ( 1 === $trailing_braces % 2 ) { $payload = substr( $payload, 0, -1 ); } return $payload; } /** * Build the ePOS-Print XML payload for a barcode value. * * Code 128 uses the same `{B` selector and `{{` escaping as the ESC/POS * lane — the ePOS-Print spec defines the data identically. The branch is * keyed on the name ePOS will receive, not on normalize_linear(): that * folds every unmodelled type to Code 128, which would hand `{B` to the * pass-through symbologies in EPOS_XML_ONLY_TYPES (`jan13`, `gs1_128`, * `code128_auto`, …) and silently blank them. * * ePOS barcode data also has its own escape layer on top of the XML text: * `\xnn` is a control byte and `\\` a literal backslash (ePOS-Print XML * manual, "escape sequences"). A raw backslash in a value would * therefore be decoded by the printer — `A\x41` scans as `AA` — so every * backslash is doubled first, for every symbology. * * Unlike escpos_payload() there is no MAX_DATA_BYTES clamp here: the * emitter validates the value up front with the ESC/POS rules and falls * back to text, and the data travels as XML rather than a length-prefixed * byte command, so a clamp could only ever produce a wrong-but-scannable * symbol. * * @param string $type The barcode type attribute value. * @param string $value The barcode value. * * @return string The XML barcode payload. */ public static function epos_xml_payload( string $type, string $value ): string { $value = self::epos_xml_escape_data( $value ); if ( self::DEFAULT_SYMBOLOGY === self::epos_xml_name( $type ) ) { return self::escpos_code128_data( $value ); } return $value; } /** * Apply the ePOS-Print barcode/symbol data escape layer to a value. * * Shared by and (QR) data, which use the same * `\xnn` / `\\` escape sequences. * * @param string $value The raw value. * * @return string The value with literal backslashes doubled. */ public static function epos_xml_escape_data( string $value ): string { return str_replace( '\\', '\\\\', $value ); } /** * Map a symbology to its StarPRNT `ESC b n1` selector. * * @param string $type The barcode type attribute value. * * @return int The `n1` parameter byte. */ public static function starprnt_id( string $type ): int { return self::STARPRNT_IDS[ self::normalize_linear( $type ) ] ?? self::STARPRNT_IDS[ self::DEFAULT_SYMBOLOGY ]; } /** * Build the StarPRNT data bytes for a barcode value. * * StarPRNT escapes Code 128 with `%`, not `{`: `%0` is a literal `%`, and * `%6`/`%7`/`%8` select code sets A/B/C. Omitting the start code is legal — * the printer auto-selects — so, unlike ESC/POS, no selector is prefixed. * * @param string $type The barcode type attribute value. * @param string $value The barcode value. * * @return string The data bytes to transmit. */ public static function starprnt_payload( string $type, string $value ): string { if ( self::DEFAULT_SYMBOLOGY !== self::normalize_linear( $type ) ) { return substr( $value, 0, self::MAX_DATA_BYTES ); } $payload = substr( self::starprnt_code128_data( $value ), 0, self::MAX_DATA_BYTES ); // Clamping can split an escaped `%0` pair. Star reads `%` plus the byte // after it as an escape, and the next byte on the wire is the RS that // terminates the barcode — so a lone trailing `%` eats the terminator and // the printer keeps consuming the rest of the receipt as barcode data. // Unlike an unprintable symbol, that failure is not self-limiting. $trailing_percents = \strlen( $payload ) - \strlen( rtrim( $payload, '%' ) ); if ( 1 === $trailing_percents % 2 ) { $payload = substr( $payload, 0, -1 ); } return $payload; } /** * Map a symbology to its Star Document Markup name. * * @param string $type The barcode type attribute value. * * @return string The `[barcode: type ...]` name. */ public static function star_markup_name( string $type ): string { $symbology = self::normalize_linear( $type ); return self::STAR_MARKUP_NAMES[ $symbology ] ?? $symbology; } /** * Map a symbology to its ePOS-Print XML `` value. * * @param string $type The barcode type attribute value. * * @return string The attribute value. */ public static function epos_xml_name( string $type ): string { $requested = strtolower( trim( $type ) ); // ePOS-Print accepts symbologies WCPOS does not model, and the `type` // attribute is free-form, so a template may legitimately ask for one. // Folding those to Code 128 would silently downgrade a working GS1-128 // to a scannable-but-wrong symbol, so anything ePOS itself accepts is // passed straight through. A name neither we nor ePOS recognise still // falls back to Code 128 rather than being handed to the printer, which // would reject the element and print nothing at all. if ( \in_array( $requested, self::EPOS_XML_ONLY_TYPES, true ) ) { return $requested; } $symbology = self::normalize_linear( $type ); return self::EPOS_XML_NAMES[ $symbology ] ?? $symbology; } /** * Map a symbology to its picqer generator constant. * * @param string $type The barcode type attribute value. * * @return string The picqer TYPE_* constant value. */ public static function picqer_type( string $type ): string { $map = array( 'code128' => BarcodeGeneratorPNG::TYPE_CODE_128, 'code39' => BarcodeGeneratorPNG::TYPE_CODE_39, 'code93' => BarcodeGeneratorPNG::TYPE_CODE_93, 'ean13' => BarcodeGeneratorPNG::TYPE_EAN_13, 'ean8' => BarcodeGeneratorPNG::TYPE_EAN_8, 'upca' => BarcodeGeneratorPNG::TYPE_UPC_A, 'upce' => BarcodeGeneratorPNG::TYPE_UPC_E, 'codabar' => BarcodeGeneratorPNG::TYPE_CODABAR, 'itf' => BarcodeGeneratorPNG::TYPE_INTERLEAVED_2_5, ); return $map[ self::normalize_linear( $type ) ] ?? $map[ self::DEFAULT_SYMBOLOGY ]; } /** * Build the unclamped ESC/POS Code 128 data bytes for a value. * * Split out from escpos_payload() so is_valid_value() can measure the * *encoded* length. The selector and the doubled braces both count toward * the 255-byte limit, so a value that fits before escaping can overflow * after it — and the clamp would then print a shortened barcode that scans * cleanly as the wrong value. * * @param string $value The barcode value. * * @return string The encoded data, before any length clamp. */ private static function escpos_code128_data( string $value ): string { return self::ESCPOS_CODE128_SELECTOR . str_replace( '{', '{{', $value ); } /** * Build the unclamped StarPRNT Code 128 data bytes for a value. * * @param string $value The barcode value. * * @return string The encoded data, before any length clamp. */ private static function starprnt_code128_data( string $value ): string { return str_replace( '%', '%0', $value ); } /** * Whether a value can be encoded as Code 128 on a given lane. * * Two lane-specific constraints, both of which fail silently on the printer: * * - Alphabet. escpos_payload() always selects code set B, which encodes * ASCII 32-126 only; a tab, LF or CR needs set A, so an ESC/POS printer * drops the symbol. StarPRNT auto-selects its code set, but its barcode * data is RS-terminated, so control bytes cannot safely travel in it. * - Length. The limit applies to the encoded bytes, not the merchant's * value: ESC/POS adds a two-byte selector and doubles every `{`, StarPRNT * doubles every `%`. A value that only overflows once escaped would be * clamped into a shorter barcode that still scans — as the wrong value — * so it is rejected here and printed as text instead. * * Epson's minimum of n >= 2 counts the `{B` selector, so a one-character * value is legal on the wire (n = 3). * * @param string $value The barcode value. * @param string $lane Lane discriminator: self::LANE_ESCPOS or self::LANE_STARPRNT. * * @return bool True when the value can be encoded. */ private static function is_valid_code128( string $value, string $lane ): bool { if ( self::LANE_ESCPOS === $lane ) { if ( 1 !== preg_match( '/\A[\x20-\x7e]+\z/', $value ) ) { return false; } return \strlen( self::escpos_code128_data( $value ) ) <= self::MAX_DATA_BYTES; } if ( '' === $value || ! self::is_ascii( $value ) || 1 === preg_match( '/[\x00-\x1f\x7f]/', $value ) ) { return false; } return \strlen( self::starprnt_code128_data( $value ) ) <= self::MAX_DATA_BYTES; } /** * Whether a value can be encoded as Code 39. * * `*` is the start/stop sentinel, not data. A printer that finds one in the * middle of the value ends the symbol there, so `AB*CD` scans back as `AB`. * A matching leading and trailing pair is accepted because that is how a * value copied off another system's barcode is usually written; anything * else is rejected and printed as text. * * @param string $value The barcode value. * * @return bool True when the value can be encoded. */ private static function is_valid_code39( string $value ): bool { $length = \strlen( $value ); if ( $length < 1 || $length > 255 ) { return false; } $body = $value; if ( $length >= 2 && '*' === $value[0] && '*' === $value[ $length - 1 ] ) { $body = substr( $value, 1, -1 ); } return 1 === preg_match( '/\A[0-9A-Z \$%\+\-\.\/]+\z/', $body ); } /** * Whether a GTIN's trailing digit is the correct mod-10 check digit. * * EAN-13, EAN-8 and UPC-A share one rule: weight the digits 3 and 1 * alternately from the one immediately left of the check digit, then the * check digit is whatever brings the total to a multiple of ten. UPC-E's * full 12-digit form is a UPC-A payload, so it uses the same rule. * * A wrong check digit is not a harmless typo. The printer either drops the * symbol or prints one encoding a different number from the human-readable * value beside it, and both outcomes are worse than the text fallback. * * @param string $digits An all-digit value whose last character is the check digit. * * @return bool True when the check digit is correct. */ private static function has_valid_gtin_check_digit( string $digits ): bool { $length = \strlen( $digits ); if ( $length < 2 ) { return false; } $sum = 0; for ( $index = $length - 2; $index >= 0; $index-- ) { $weight = 0 === ( $length - 2 - $index ) % 2 ? 3 : 1; $sum += (int) $digits[ $index ] * $weight; } return ( ( 10 - ( $sum % 10 ) ) % 10 ) === (int) $digits[ $length - 1 ]; } /** * Normalize to a 1D symbology, folding QR onto the default. * * The lane accessors are only ever reached for `barcode` AST nodes; a QR type * arriving here means a caller routed a node wrongly, and Code 128 keeps the * value printable rather than emitting an unencodable symbology id. * * @param string $type The barcode type attribute value. * * @return string A member of self::SYMBOLOGIES. */ private static function normalize_linear( string $type ): string { $symbology = self::normalize( $type ); return self::QRCODE === $symbology ? self::DEFAULT_SYMBOLOGY : $symbology; } /** * Whether a value is a non-empty run of ASCII digits. * * @param string $value The value to test. * * @return bool True when the value is all digits. */ private static function is_digits( string $value ): bool { return 1 === preg_match( '/\A[0-9]+\z/', $value ); } /** * Whether every byte of a value is in the 0-127 range printers accept. * * @param string $value The value to test. * * @return bool True when the value is 7-bit clean. */ private static function is_ascii( string $value ): bool { return 1 !== preg_match( '/[\x80-\xff]/', $value ); } }