*
* @see http://wcpos.com
* @package WCPOS\WooCommercePOS\Templates
*/
namespace WCPOS\WooCommercePOS\Templates;
use Throwable;
use WCPOS\Vendor\chillerlan\QRCode\Output\QROutputInterface;
use WCPOS\Vendor\chillerlan\QRCode\QRCode;
use WCPOS\Vendor\chillerlan\QRCode\QROptions;
use WCPOS\Vendor\Picqer\Barcode\BarcodeGeneratorPNG;
/**
* Barcode_Image class.
*/
class Barcode_Image {
/**
* Build an `
` tag for a 1D barcode, or an empty string on failure.
*
* @param string $type The barcode symbology (e.g. code128, ean13).
* @param string $value The barcode value.
* @param int $height The barcode height in pixels.
*
* @return string The `
` tag, or '' when the value is empty or generation fails.
*/
public static function barcode_img( string $type, string $value, int $height = 40 ): string {
$png = self::barcode_png( $type, $value, $height );
if ( '' === $png ) {
return '';
}
// bwip-js renders the human-readable value under the bars in the
// client-side previews; mirror it so the PDF matches on-screen.
return self::img_from_png( $png ) . self::barcode_text( trim( $value ) );
}
/**
* Rasterize a barcode to opaque PNG bytes.
*
* The receipt raster emitter composites these straight onto its canvas, so it
* needs the bytes rather than the `
` wrapper the HTML/PDF path uses.
*
* @param string $type The barcode symbology (e.g. code128, ean13).
* @param string $value The barcode value.
* @param int $height The barcode height in pixels.
*
* @return string The PNG bytes, or '' when the value is empty or generation fails.
*/
public static function barcode_png( string $type, string $value, int $height = 40 ): string {
$text = trim( $value );
if ( '' === $text ) {
return '';
}
try {
$png = self::without_vendor_deprecations(
static function () use ( $text, $type, $height ): string {
$generator = new BarcodeGeneratorPNG();
// picqer defaults to Imagick when the extension is loaded, but a
// PNG-less Imagick build (seen on some hosts) throws. Prefer GD,
// which is near-universal and the recommended WordPress image lib.
if ( \function_exists( 'imagecreate' ) ) {
$generator->useGd();
}
// Clamp the height so a malformed template dimension cannot
// allocate a huge raster.
return $generator->getBarcode( $text, Barcode_Symbology::picqer_type( $type ), 2, max( 8, min( 600, $height ) ) );
}
);
return self::flatten_png( $png );
} catch ( Throwable $error ) {
return '';
}
}
/**
* Build the human-readable text line shown beneath a 1D barcode.
*
* @param string $value The barcode value.
*
* @return string The HTML fragment.
*/
private static function barcode_text( string $value ): string {
return ''
. esc_html( $value ) . '
';
}
/**
* Build an `
` tag for a QR code, or an empty string on failure.
*
* @param string $value The QR code value.
* @param int $size The QR module scale (pixels per module).
*
* @return string The `
` tag, or '' when the value is empty or generation fails.
*/
public static function qrcode_img( string $value, int $size = 4 ): string {
$png = self::qrcode_png( $value, $size );
return '' === $png ? '' : self::img_from_png( $png );
}
/**
* Rasterize a QR code to opaque PNG bytes.
*
* @param string $value The QR code value.
* @param int $size The QR module scale (pixels per module).
*
* @return string The PNG bytes, or '' when the value is empty or generation fails.
*/
public static function qrcode_png( string $value, int $size = 4 ): string {
$text = trim( $value );
if ( '' === $text ) {
return '';
}
try {
$png = self::without_vendor_deprecations(
static function () use ( $text, $size ): string {
$options = new QROptions(
array(
'outputType' => QROutputInterface::GDIMAGE_PNG,
'imageBase64' => false,
// Clamp the scale so a malformed template dimension cannot
// allocate a huge raster.
'scale' => max( 2, min( 40, $size ) ),
)
);
return ( new QRCode( $options ) )->render( $text );
}
);
return self::flatten_png( $png );
} catch ( Throwable $error ) {
return '';
}
}
/**
* Replace `` / `` markup with opaque placeholder tokens.
*
* Each element that rasterizes successfully is swapped for a plain-text token,
* and its `
` tag is stored in `$images` keyed by that token. The caller is
* expected to sanitize the returned HTML and then splice the images back in
* (e.g. `strtr( wp_kses_post( $html ), $images )`). This keeps the `data:` image
* URI out of the sanitizer entirely, so no `data:` protocol allowance — which
* would widen the protocol allow-list for every URI attribute — is needed.
* Elements whose value is empty, or which fail to rasterize, are left untouched
* so the sanitizer can drop them.
*
* @param string $html The rendered template HTML.
* @param array $images Filled with token => `
` HTML for the caller to splice in.
*
* @return string The HTML with barcode markup replaced by placeholder tokens.
*/
public static function replace_markup( string $html, array &$images ): string {
if ( false === stripos( $html, ']*)>(.*?)\1>#is',
static function ( array $match ) use ( &$images ): string {
$img = self::element_to_img( strtolower( $match[1] ), $match[2], $match[3] );
if ( '' === $img ) {
return $match[0];
}
$token = 'WCPOSBARCODEPLACEHOLDER' . \count( $images ) . 'X';
$images[ $token ] = $img;
return $token;
},
$html
);
}
/**
* Rasterize a single `` / `` element to an `
` tag.
*
* Mirrors the client-side logicless preview renderer: the value is the
* `data-value` attribute or the text content; the symbology is the
* `data-barcode` or `type` attribute (a `` tag, a qr/qrcode type, or a
* typeless element all render a QR code, matching the preview).
*
* @param string $tag The lowercased tag name (barcode|qrcode).
* @param string $attrs The raw attribute string.
* @param string $inner The element's inner content.
*
* @return string The `
` tag, or '' when empty or rasterization fails.
*/
private static function element_to_img( string $tag, string $attrs, string $inner ): string {
$value = self::attr( $attrs, 'data-value' );
$value = '' !== $value ? $value : trim( wp_strip_all_tags( $inner ) );
$value = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
if ( '' === trim( $value ) ) {
return '';
}
if ( 'qrcode' === $tag ) {
$raw_type = 'qrcode';
} else {
$raw_type = self::attr( $attrs, 'data-barcode' );
$raw_type = '' !== $raw_type ? $raw_type : self::attr( $attrs, 'type' );
$raw_type = '' !== $raw_type ? $raw_type : 'qr';
}
$type = strtolower( trim( $raw_type ) );
if ( Barcode_Symbology::is_qr( $type ) ) {
$size = (int) self::attr( $attrs, 'size' );
return self::qrcode_img( $value, $size > 0 ? $size : 4 );
}
$height = (int) self::attr( $attrs, 'height' );
return self::barcode_img( $type, $value, $height > 0 ? $height : 40 );
}
/**
* Wrap raw PNG bytes in an `
` data-URI tag constrained to the receipt width.
*
* @param string $png The raw PNG image bytes.
*
* @return string The `
` tag.
*/
private static function img_from_png( string $png ): string {
// Callers hand over already-flattened bytes; flatten_png() is idempotent, so
// the second pass is a no-op that keeps this safe for any future caller.
return '
';
}
/**
* Flatten a PNG onto an opaque white background, dropping any alpha channel.
*
* The picqer barcode generator emits PNGs with a transparent background (some
* QR builds do too). Dompdf routes transparent PNGs through an Imagick
* alpha-extraction path that fails
* on hosts whose Imagick build lacks a PNG delegate (`Unable to set format`).
* An opaque PNG takes Dompdf's plain GD path instead, which is what receipts
* need anyway (black on white). Returns the input unchanged if GD is missing
* or the image cannot be decoded.
*
* @param string $png The raw PNG image bytes.
*
* @return string The opaque PNG bytes.
*/
private static function flatten_png( string $png ): string {
if ( ! \function_exists( 'imagecreatefromstring' ) ) {
return $png;
}
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Invalid image data returns false rather than warning.
$source = @imagecreatefromstring( $png );
if ( false === $source ) {
return $png;
}
$width = imagesx( $source );
$height = imagesy( $source );
$canvas = imagecreatetruecolor( $width, $height );
$white = (int) imagecolorallocate( $canvas, 255, 255, 255 );
imagefilledrectangle( $canvas, 0, 0, $width, $height, $white );
imagealphablending( $canvas, true );
imagecopy( $canvas, $source, 0, 0, 0, 0, $width, $height );
imagesavealpha( $canvas, false );
ob_start();
imagepng( $canvas );
$flat = (string) ob_get_clean();
return '' !== $flat ? $flat : $png;
}
/**
* Read a single HTML attribute value out of an attribute string.
*
* @param string $attrs The raw attribute string (e.g. ` type="code128" height="40"`).
* @param string $name The attribute name.
*
* @return string The attribute value, or '' when absent.
*/
private static function attr( string $attrs, string $name ): string {
if ( preg_match( '#\b' . preg_quote( $name, '#' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $attrs, $m ) ) {
return ( $m[2] ?? '' ) . ( $m[3] ?? '' ) . ( $m[4] ?? '' );
}
return '';
}
/**
* Run a callable with E_DEPRECATED notices from the vendored libraries silenced.
*
* The prefixed picqer PNG generator calls imagedestroy(), deprecated on PHP
* 8.5; the notice is harmless but would otherwise spam logs on each receipt.
*
* @param callable $callback The callback to execute.
*
* @return string The callback's return value.
*/
private static function without_vendor_deprecations( callable $callback ): string {
$previous_handler = null;
$previous_handler = set_error_handler(
static function ( int $errno, string $errstr, string $errfile = '', int $errline = 0 ) use ( &$previous_handler ): bool {
if (
0 !== ( $errno & ( E_DEPRECATED | E_USER_DEPRECATED ) )
&& false !== strpos( str_replace( '\\', '/', $errfile ), '/vendor_prefixed/' )
) {
return true;
}
if ( \is_callable( $previous_handler ) ) {
return (bool) \call_user_func( $previous_handler, $errno, $errstr, $errfile, $errline );
}
return false;
}
);
try {
return (string) $callback();
} finally {
restore_error_handler();
}
}
}