# woocommerce-pos/1.10.13/includes/Templates/Thermal/Escpos_Thermal_Emitter.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.13. 804 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.13/code/includes/Templates/Thermal/Escpos_Thermal_Emitter.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.13/raw/includes/Templates/Thermal/Escpos_Thermal_Emitter.php
- Modified: 2026-09-13T08:01:26+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.13/code/includes/Templates/Thermal/Escpos_Thermal_Emitter.php#L10-L20`.

```php
<?php
/**
 * ESC/POS Thermal Emitter Class.
 *
 * Emits raw ESC/POS command bytes from a thermal AST (produced by
 * Thermal_Markup_Parser). This is the ONLY ESC/POS emitter in the project —
 * there is no JavaScript counterpart to keep in step with, so this class is the
 * reference for what a template actually prints. Parity with the npm escpos
 * encoder is defined as matching command sequences and visual text layout, NOT
 * byte-identity.
 *
 * This is the template-driven counterpart to
 * `WCPOS\WooCommercePOS\Templates\Adapters\Escpos_Output_Adapter`, which emits a
 * fixed, non-template layout from canonical receipt data.
 *
 * Deliberate deviations from the npm escpos encoder:
 *  - Double rules (`<line style="double"/>`) are emitted as ASCII `=` repeated
 *    across the paper width instead of the CP437 box-drawing byte 0xCD. This
 *    keeps the output codepage-independent so it renders correctly regardless of
 *    the printer's active character table.
 *  - Images (`<image>`) are thresholded to 1-bit dots by Thermal_Bitmap and sent
 *    as a `GS v 0` raster bit image.
 *  - CP932 / Japanese kanji-mode byte sequences are out of scope; text is
 *    emitted as plain UTF-8.
 *
 * @author   Paul Kilmurray <paul@kilbot.com>
 *
 * @see     http://wcpos.com
 * @package WCPOS\WooCommercePOS
 */

namespace WCPOS\WooCommercePOS\Templates\Thermal;

use WCPOS\WooCommercePOS\Templates\Barcode_Symbology;

/**
 * Escpos_Thermal_Emitter class.
 */
class Escpos_Thermal_Emitter {

	/**
	 * Render options.
	 *
	 * @var array
	 */
	private $options = array();

	/**
	 * Accumulated output bytes.
	 *
	 * @var string
	 */
	private $buffer = '';

	/**
	 * The paper width in character columns.
	 *
	 * @var int
	 */
	private $columns = 48;

	/**
	 * The current alignment mode (left|center|right).
	 *
	 * @var string
	 */
	private $align = 'left';

	/**
	 * Whether bold is currently active.
	 *
	 * @var bool
	 */
	private $bold = false;

	/**
	 * Whether underline is currently active.
	 *
	 * @var bool
	 */
	private $underline = false;

	/**
	 * Whether invert is currently active.
	 *
	 * @var bool
	 */
	private $invert = false;

	/**
	 * The current text width multiplier.
	 *
	 * @var int
	 */
	private $width = 1;

	/**
	 * The current text height multiplier.
	 *
	 * @var int
	 */
	private $height = 1;

	/**
	 * The active scaled line-spacing height, or 0 when none is active.
	 *
	 * @var int
	 */
	private $active_scaled_spacing = 0;

	/**
	 * Whether unterminated text is sitting in the printer's line buffer.
	 *
	 * `GS v 0`, `GS k` and `GS ( k` are only executed at the beginning of a line
	 * in standard mode; issued mid-line the printer discards them and reports
	 * nothing. Bare text in a template (`<receipt>Total<image/></receipt>`) parses
	 * to a `raw-text` node, which prints without a terminator, so the emitter has
	 * to know whether a line is open before it sends one of those commands.
	 *
	 * @var bool
	 */
	private $line_open = false;

	/**
	 * Constructor.
	 *
	 * @param array $options Render options.
	 */
	public function __construct( array $options = array() ) {
		$this->options = $options;
	}

	/**
	 * Emit raw ESC/POS bytes from a thermal AST.
	 *
	 * @param array $ast The thermal AST root (a receipt node).
	 *
	 * @return string The raw ESC/POS bytes.
	 */
	public function emit( array $ast ): string {
		$this->buffer                = '';
		$this->align                 = 'left';
		$this->bold                  = false;
		$this->underline             = false;
		$this->invert                = false;
		$this->width                 = 1;
		$this->height                = 1;
		$this->active_scaled_spacing = 0;
		$this->line_open             = false;

		$this->columns = isset( $ast['paper_width'] ) ? (int) $ast['paper_width'] : 48;

		// ESC @ — initialize the printer (once, at the very start).
		$this->raw( array( 0x1b, 0x40 ) );

		$children = isset( $ast['children'] ) && \is_array( $ast['children'] ) ? $ast['children'] : array();
		$this->walk_nodes( $this->nodes_with_auto_drawer( $children ) );

		return $this->buffer;
	}

	/**
	 * Walk a list of AST nodes.
	 *
	 * @param array $nodes The AST nodes.
	 *
	 * @return void
	 */
	private function walk_nodes( array $nodes ): void {
		foreach ( $nodes as $node ) {
			if ( \is_array( $node ) ) {
				$this->walk_node( $node );
			}
		}
	}

	/**
	 * Insert an auto drawer node before the first trailing cut when enabled.
	 *
	 * @param array $nodes AST nodes.
	 *
	 * @return array
	 */
	private function nodes_with_auto_drawer( array $nodes ): array {
		if ( empty( $this->options['auto_open_drawer'] ) || $this->nodes_contain_drawer( $nodes ) ) {
			return $nodes;
		}

		$drawer = array(
			'type'      => 'drawer',
			'connector' => \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( (string) ( $this->options['drawer_connector'] ?? 'pin2' ) ),
		);

		for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
			$type = isset( $nodes[ $i ]['type'] ) ? (string) $nodes[ $i ]['type'] : '';
			if ( 'cut' === $type ) {
				array_splice( $nodes, $i, 0, array( $drawer ) );
				return $nodes;
			}
			if ( in_array( $type, array( 'feed' ), true ) ) {
				continue;
			}
			break;
		}

		$nodes[] = $drawer;
		return $nodes;
	}

	/**
	 * Whether a node list contains an explicit drawer node.
	 *
	 * @param array $nodes AST nodes.
	 *
	 * @return bool
	 */
	private function nodes_contain_drawer( array $nodes ): bool {
		foreach ( $nodes as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			if ( 'drawer' === ( $node['type'] ?? '' ) ) {
				return true;
			}
			if ( ! empty( $node['children'] ) && is_array( $node['children'] ) && $this->nodes_contain_drawer( $node['children'] ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Emit ESC/POS drawer pulse bytes.
	 *
	 * @param string $connector Drawer connector.
	 */
	private function emit_drawer_pulse( string $connector ): void {
		$connector = \WCPOS\WooCommercePOS\Services\Print_Job_Service::normalize_drawer_connector( $connector );
		$pin       = 'pin5' === $connector ? 0x01 : 0x00;

		$this->raw( array( 0x1b, 0x70, $pin, 0x19, 0xfa ) );
	}

	/**
	 * Walk a single AST node.
	 *
	 * @param array $node The AST node.
	 *
	 * @return void
	 */
	private function walk_node( array $node ): void {
		$type = isset( $node['type'] ) ? $node['type'] : '';

		switch ( $type ) {
			case 'raw-text':
				$this->emit_inline_text( isset( $node['value'] ) ? (string) $node['value'] : '' );
				break;
			case 'text':
				$this->emit_text_line( isset( $node['children'] ) ? $node['children'] : array() );
				break;
			case 'bold':
				$this->emit_bold( $node );
				break;
			case 'underline':
				$this->emit_underline( $node );
				break;
			case 'invert':
				$this->emit_invert( $node );
				break;
			case 'size':
				$this->emit_size( $node );
				break;
			case 'align':
				$this->emit_align( $node );
				break;
			case 'row':
				$this->emit_row( $node );
				break;
			case 'line':
				$this->emit_line( $node );
				break;
			case 'barcode':
				$this->emit_barcode( $node );
				break;
			case 'qrcode':
				$this->emit_qrcode( $node );
				break;
			case 'image':
				$this->emit_image( $node );
				break;
			case 'cut':
				$this->emit_cut( $node );
				break;
			case 'feed':
				$this->emit_feed( $node );
				break;
			case 'drawer':
				$this->emit_drawer_pulse( isset( $node['connector'] ) ? (string) $node['connector'] : 'pin2' );
				break;
			case 'receipt':
				$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
				break;
		}
	}

	/**
	 * Emit inline (styled) text bytes for the current line.
	 *
	 * @param string $value The raw text value.
	 *
	 * @return void
	 */
	private function emit_inline_text( string $value ): void {
		$text = Thermal_Text_Layout::normalize_text( $value );
		if ( '' === $text ) {
			return;
		}

		$this->raw_string( $text );

		// The parser preserves a text node verbatim, newlines included, so this
		// may have ended the line itself — `<receipt>Total\n<image/></receipt>`
		// leaves the printer at column zero. Reading the state off the bytes just
		// written keeps close_open_line() from spending a second line feed there.
		$this->line_open = "\n" !== substr( $text, -1 );
	}

	/**
	 * Close an open line so a beginning-of-line command can execute.
	 *
	 * @return void
	 */
	private function close_open_line(): void {
		if ( $this->line_open ) {
			$this->newline();
		}
	}

	/**
	 * Emit a single printed text line (the children, padding, then a newline).
	 *
	 * @param array $children The child nodes of the text node.
	 *
	 * @return void
	 */
	private function emit_text_line( array $children ): void {
		if ( 'left' !== $this->align ) {
			$plain = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( $children ) );
			$pad   = Thermal_Text_Layout::alignment_padding( $this->align, Thermal_Text_Layout::display_width( $plain ), $this->columns, $this->width );
			if ( $pad > 0 ) {
				$this->raw_string( str_repeat( ' ', $pad ) );
			}
		}
		$this->walk_nodes( $children );
		$this->newline();
	}

	/**
	 * Emit a bold-wrapped block.
	 *
	 * @param array $node The bold AST node.
	 *
	 * @return void
	 */
	private function emit_bold( array $node ): void {
		$previous = $this->bold;
		$this->raw( array( 0x1b, 0x45, 0x01 ) );
		$this->bold = true;
		$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
		$this->raw( array( 0x1b, 0x45, $previous ? 0x01 : 0x00 ) );
		$this->bold = $previous;
	}

	/**
	 * Emit an underline-wrapped block.
	 *
	 * @param array $node The underline AST node.
	 *
	 * @return void
	 */
	private function emit_underline( array $node ): void {
		$previous = $this->underline;
		$this->raw( array( 0x1b, 0x2d, 0x01 ) );
		$this->underline = true;
		$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
		$this->raw( array( 0x1b, 0x2d, $previous ? 0x01 : 0x00 ) );
		$this->underline = $previous;
	}

	/**
	 * Emit an invert-wrapped block.
	 *
	 * @param array $node The invert AST node.
	 *
	 * @return void
	 */
	private function emit_invert( array $node ): void {
		$previous = $this->invert;
		$this->raw( array( 0x1d, 0x42, 0x01 ) );
		$this->invert = true;
		$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );
		$this->raw( array( 0x1d, 0x42, $previous ? 0x01 : 0x00 ) );
		$this->invert = $previous;
	}

	/**
	 * Emit a size-wrapped block, including scaled line spacing.
	 *
	 * @param array $node The size AST node.
	 *
	 * @return void
	 */
	private function emit_size( array $node ): void {
		$previous_width  = $this->width;
		$previous_height = $this->height;
		$width           = Thermal_Bounds::clamp_int( isset( $node['width'] ) ? $node['width'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );
		$height          = Thermal_Bounds::clamp_int( isset( $node['height'] ) ? $node['height'] : null, 1, Thermal_Bounds::SIZE_MULTIPLIER_MIN, Thermal_Bounds::SIZE_MULTIPLIER_MAX );

		if ( $height > 1 ) {
			$this->active_scaled_spacing = max( $this->active_scaled_spacing, $height );
			$this->raw( array( 0x1b, 0x33, min( 255, $height * 30 ) ) );
		}

		$this->raw( array( 0x1d, 0x21, $this->size_byte( $width, $height ) ) );
		$this->width  = $width;
		$this->height = $height;

		$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );

		$this->raw( array( 0x1d, 0x21, $this->size_byte( $previous_width, $previous_height ) ) );
		$this->width  = $previous_width;
		$this->height = $previous_height;
	}

	/**
	 * Compute the GS ! size byte for a width/height multiplier.
	 *
	 * `GS ! n` puts the WIDTH magnification in bits 4-7 and the HEIGHT in bits 0-3, each as
	 * multiplier - 1 over 1x-8x: `n = (width - 1) << 4 | (height - 1)`. These were the wrong way
	 * round, so every non-square `<size>` printed transposed -- a heading asked to be double-wide
	 * came out double-high. Square sizes are bit-symmetric, which is why the 2x2 case everything
	 * uses looked right and hid it.
	 *
	 * @param int $width  The width multiplier.
	 * @param int $height The height multiplier.
	 *
	 * @return int The GS ! parameter byte.
	 */
	private function size_byte( int $width, int $height ): int {
		return ( self::size_nibble( $width ) << 4 ) | self::size_nibble( $height );
	}

	/**
	 * One magnification nibble: multiplier - 1, bounded to the 1x-8x the command can express.
	 *
	 * Nothing bounds `<size>` on the way in, and a multiplier of 9 unbounded would carry into the
	 * neighbouring field and silently resize the other axis.
	 *
	 * @param int $multiplier The width or height multiplier.
	 *
	 * @return int The nibble value (0-7).
	 */
	private static function size_nibble( int $multiplier ): int {
		return max( 1, min( 8, $multiplier ) ) - 1;
	}

	/**
	 * Emit an alignment-wrapped block.
	 *
	 * @param array $node The align AST node.
	 *
	 * @return void
	 */
	private function emit_align( array $node ): void {
		$previous = $this->align;
		$mode     = isset( $node['mode'] ) ? $node['mode'] : 'left';
		$this->raw( array( 0x1b, 0x61, $this->align_byte( $mode ) ) );
		$this->align = $mode;

		$this->walk_nodes( isset( $node['children'] ) ? $node['children'] : array() );

		$this->raw( array( 0x1b, 0x61, $this->align_byte( $previous ) ) );
		$this->align = $previous;
	}

	/**
	 * Map an alignment mode to its ESC a parameter byte.
	 *
	 * @param string $mode The alignment mode.
	 *
	 * @return int The ESC a parameter byte.
	 */
	private function align_byte( string $mode ): int {
		if ( 'center' === $mode ) {
			return 0x01;
		}
		if ( 'right' === $mode ) {
			return 0x02;
		}

		return 0x00;
	}

	/**
	 * Emit a row as one physical line followed by a newline.
	 *
	 * @param array $node The row AST node.
	 *
	 * @return void
	 */
	private function emit_row( array $node ): void {
		$cols   = isset( $node['children'] ) && \is_array( $node['children'] ) ? $node['children'] : array();
		$widths = Thermal_Text_Layout::resolve_row_widths( $cols, $this->columns );

		$line = '';
		foreach ( $cols as $index => $col ) {
			$width = isset( $widths[ $index ] ) ? $widths[ $index ] : 1;
			$text  = Thermal_Text_Layout::normalize_text( Thermal_Text_Layout::extract_text( isset( $col['children'] ) ? $col['children'] : array() ) );
			$text  = Thermal_Text_Layout::truncate_display( $text, $width );
			$pad   = max( 0, $width - Thermal_Text_Layout::display_width( $text ) );
			$align = isset( $col['align'] ) ? $col['align'] : 'left';
			if ( 'right' === $align ) {
				$line .= str_repeat( ' ', $pad ) . $text;
			} else {
				$line .= $text . str_repeat( ' ', $pad );
			}
		}

		$this->raw_string( $line );
		$this->newline();
	}

	/**
	 * Emit a horizontal rule line.
	 *
	 * @param array $node The line AST node.
	 *
	 * @return void
	 */
	private function emit_line( array $node ): void {
		$style = isset( $node['style'] ) ? $node['style'] : 'single';

		if ( 'dotted' === $style ) {
			$pattern = '. ';
			$repeat  = (int) ceil( $this->columns / \strlen( $pattern ) );
			$text    = substr( str_repeat( $pattern, $repeat ), 0, $this->columns );
		} elseif ( 'double' === $style ) {
			$text = str_repeat( '=', $this->columns );
		} else {
			// single and dashed both render as '-' across the width.
			$text = str_repeat( '-', $this->columns );
		}

		$this->raw_string( $text );
		$this->newline();
	}

	/**
	 * Emit a 1D barcode using the native `GS k` function-B command.
	 *
	 * The symbology selector and the data encoding both come from
	 * Barcode_Symbology (Epson ESC/POS Command Reference, `GS k`). An ESC/POS
	 * printer handed data its symbology cannot encode prints nothing and reports
	 * no error, so an unencodable value is printed as text instead — never as an
	 * error string, which a cashier would have to read off the receipt.
	 *
	 * @param array $node The barcode AST node.
	 *
	 * @return void
	 */
	private function emit_barcode( array $node ): void {
		$value = isset( $node['value'] ) ? (string) $node['value'] : '';
		if ( '' === trim( $value ) ) {
			return;
		}

		// Before the validation branch, not after: GS k only executes at the
		// beginning of a line, and the rescue below centres its text against the
		// full paper width, so both outcomes need the line closed first.
		$this->close_open_line();

		$type   = isset( $node['barcode_type'] ) ? (string) $node['barcode_type'] : 'code128';
		$height = isset( $node['height'] ) ? (int) $node['height'] : 40;
		$height = max( Thermal_Bounds::BARCODE_HEIGHT_MIN, min( Thermal_Bounds::BARCODE_HEIGHT_MAX, $height ) );

		if ( ! Barcode_Symbology::is_valid_value( $type, $value, Barcode_Symbology::LANE_ESCPOS ) ) {
			$this->emit_centered_text( $value );

			return;
		}

		$this->raw( array( 0x1d, 0x68, $height ) ); // GS h — barcode height.
		$this->raw( array( 0x1d, 0x77, 0x02 ) );    // GS w — module width.
		// GS H 2 — HRI below the bars, matching the preview, the PDF and the
		// raster lane. With HRI off the merchant designs against a receipt that
		// carries the order number and the printer hands over one that does not.
		$this->raw( array( 0x1d, 0x48, 0x02 ) );

		$data = Barcode_Symbology::escpos_payload( $type, $value );
		// GS k m n d1..dn — function B, length-prefixed.
		$this->raw( array( 0x1d, 0x6b, Barcode_Symbology::escpos_id( $type ), \strlen( $data ) ) );
		$this->raw_string( $data );
	}

	/**
	 * Print a template `<image>` (in practice, the store logo).
	 *
	 * `GS v 0 m xL xH yL yH d1..dk` — the raster bit image, whose data layout is
	 * exactly what Thermal_Bitmap produces: row-major, MSB first, a set bit being
	 * a black dot. xL/xH count BYTES per row, not dots, which is why the bitmap
	 * pads its width to a whole byte.
	 *
	 * The image is centred unconditionally, ignoring any enclosing `<align>`.
	 * That is the contract the other three renderers already keep — the preview
	 * (thermal-renderer.ts), the PDF (Html_Thermal_Emitter::render_image()) and
	 * the raster lane (Raster_Thermal_Emitter::draw_image()) all hard-centre an
	 * `<image>` — and inheriting the `ESC a` state instead would left-align the
	 * bare `<image>` the template editor inserts, which all three show centred.
	 *
	 * No trailing line feed: `GS v 0` leaves the printer "at the beginning of the
	 * line" (ESC/POS Command Reference), so one here would open a blank line the
	 * preview does not have.
	 *
	 * A src that resolves to nothing (a remote URL, a missing file) prints
	 * nothing rather than a stray line feed.
	 *
	 * @param array $node The image AST node.
	 *
	 * @return void
	 */
	private function emit_image( array $node ): void {
		$bitmap = Thermal_Bitmap::from_node( $node, Thermal_Bounds::paper_dots( $this->columns ) );
		if ( null === $bitmap ) {
			return;
		}

		// GS v 0 only executes at the beginning of a line in standard mode.
		$this->close_open_line();

		$bytes_per_row = $bitmap->bytes_per_row();
		$height        = $bitmap->height();

		$this->raw( array( 0x1b, 0x61, $this->align_byte( 'center' ) ) );
		$this->raw(
			array(
				0x1d,
				0x76,
				0x30,
				0x00,
				$bytes_per_row & 0xff,
				( $bytes_per_row >> 8 ) & 0xff,
				$height & 0xff,
				( $height >> 8 ) & 0xff,
			)
		);
		$this->raw_string( $bitmap->raster() );
		$this->raw( array( 0x1b, 0x61, $this->align_byte( $this->align ) ) );
	}

	/**
	 * Print a value as a centered plain-text line.
	 *
	 * Mirrors the rescue in Html_Thermal_Emitter::render_barcode_fallback(): when
	 * the symbol cannot be produced, the value itself is still readable.
	 *
	 * Control bytes are folded to spaces first. This is the one path that routes
	 * a barcode value into the text stream, and a barcode value is exactly where
	 * a stray tab, LF or CR turns up — Code 128 validation rejects them on the
	 * ESC/POS lane precisely because code set B cannot encode them, which sends
	 * them here. Emitted raw they would break the line the rescue is centering.
	 *
	 * @param string $value The value to print.
	 *
	 * @return void
	 */
	private function emit_centered_text( string $value ): void {
		$text = Thermal_Text_Layout::normalize_text( $this->strip_control_bytes( $value ) );
		$pad  = (int) floor( max( 0, $this->columns - Thermal_Text_Layout::display_width( $text ) ) / 2 );
		if ( $pad > 0 ) {
			$this->raw_string( str_repeat( ' ', $pad ) );
		}
		$this->raw_string( $text );
		$this->newline();
	}

	/**
	 * Replace control bytes with spaces so they cannot reach the print stream.
	 *
	 * @param string $value The value to clean.
	 *
	 * @return string The value with control bytes folded to spaces.
	 */
	private function strip_control_bytes( string $value ): string {
		$cleaned = preg_replace( '/[\x00-\x1f\x7f]/', ' ', $value );

		return null === $cleaned ? $value : $cleaned;
	}

	/**
	 * Emit a model-2 QR code using native GS ( k commands.
	 *
	 * @param array $node The qrcode AST node.
	 *
	 * @return void
	 */
	private function emit_qrcode( array $node ): void {
		$value = isset( $node['value'] ) ? (string) $node['value'] : '';
		$size  = isset( $node['size'] ) ? (int) $node['size'] : 4;
		$size  = max( Thermal_Bounds::QRCODE_SIZE_MIN, min( Thermal_Bounds::QRCODE_SIZE_MAX, $size ) );

		// GS ( k only executes at the beginning of a line in standard mode.
		$this->close_open_line();

		// Select model 2.
		$this->raw( array( 0x1d, 0x28, 0x6b, 0x04, 0x00, 0x31, 0x41, 0x32, 0x00 ) );
		// Set module size.
		$this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x43, $size ) );
		// Set error correction level (M).
		$this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x45, 0x31 ) );

		// Store data.
		$data    = substr( $value, 0, 0xffff - 3 );
		$payload = \strlen( $data ) + 3;
		$p_l     = $payload & 0xff;
		$p_h     = ( $payload >> 8 ) & 0xff;
		$this->raw( array( 0x1d, 0x28, 0x6b, $p_l, $p_h, 0x31, 0x50, 0x30 ) );
		$this->raw_string( $data );

		// Print the stored symbol.
		$this->raw( array( 0x1d, 0x28, 0x6b, 0x03, 0x00, 0x31, 0x51, 0x30 ) );
	}

	/**
	 * Feed to the cutting position and emit a paper cut command.
	 *
	 * @param array $node The cut AST node.
	 *
	 * @return void
	 */
	private function emit_cut( array $node ): void {
		$cut_type = isset( $node['cut_type'] ) ? $node['cut_type'] : 'partial';
		$this->raw( array( 0x1d, 0x56, 'full' === $cut_type ? 0x41 : 0x42, 0x00 ) );
	}

	/**
	 * Emit a paper feed of N lines.
	 *
	 * @param array $node The feed AST node.
	 *
	 * @return void
	 */
	private function emit_feed( array $node ): void {
		$lines = Thermal_Bounds::clamp_int(
			isset( $node['lines'] ) ? $node['lines'] : null,
			Thermal_Bounds::FEED_LINES_MIN,
			Thermal_Bounds::FEED_LINES_MIN,
			Thermal_Bounds::FEED_LINES_MAX
		);
		for ( $index = 0; $index < $lines; $index++ ) {
			$this->raw( array( 0x0a ) );
		}
		$this->line_open = false;
	}

	/**
	 * Emit a single newline and restore scaled line spacing if active.
	 *
	 * @return void
	 */
	private function newline(): void {
		$this->raw( array( 0x0a ) );
		$this->line_open = false;
		if ( $this->active_scaled_spacing > 0 ) {
			$this->raw( array( 0x1b, 0x32 ) );
			$this->active_scaled_spacing = 0;
		}
	}

	/**
	 * Append a list of ordinal bytes to the output buffer.
	 *
	 * @param array $bytes The ordinal bytes.
	 *
	 * @return void
	 */
	private function raw( array $bytes ): void {
		foreach ( $bytes as $byte ) {
			$this->buffer .= \chr( $byte & 0xff );
		}
	}

	/**
	 * Append a raw string to the output buffer.
	 *
	 * @param string $value The string to append.
	 *
	 * @return void
	 */
	private function raw_string( string $value ): void {
		$this->buffer .= $value;
	}
}

```
