[data, w, h, colorspace, bpc, decode]. */ private $images = array(); /** @var array Image cache keyed by source hash. */ private $image_keys = array(); private $page = -1; private $font = 'F1'; private $size = 10.0; /** @var string */ private $title = ''; public function __construct( $paper = 'letter' ) { if ( 'a4' === $paper ) { $this->w = 595.28; $this->h = 841.89; } else { $this->w = 612.0; $this->h = 792.0; } if ( null === self::$metrics ) { self::$metrics = include __DIR__ . '/pdf/font-metrics.php'; } } public function width() { return $this->w; } public function height() { return $this->h; } public function set_title( $title ) { $this->title = (string) $title; } /* --------------------------------------------------------------------- * Pages * ------------------------------------------------------------------- */ public function add_page() { $this->pages[] = ''; $this->page = count( $this->pages ) - 1; return $this->page; } public function page_count() { return count( $this->pages ); } public function current_page() { return $this->page; } /** Go back to an earlier page (e.g. to add "Page 1 of 3" footers). */ public function set_page( $index ) { if ( isset( $this->pages[ $index ] ) ) { $this->page = $index; } } private function out( $ops ) { if ( $this->page < 0 ) { $this->add_page(); } $this->pages[ $this->page ] .= $ops . "\n"; } /* --------------------------------------------------------------------- * Text * ------------------------------------------------------------------- */ public function set_font( $bold = false, $size = 10 ) { $this->font = $bold ? 'F2' : 'F1'; $this->size = (float) $size; } public function font_size() { return $this->size; } /** * UTF-8 → Windows-1252. */ public static function encode( $text ) { $text = (string) $text; $text = str_replace( array( "\xC2\xA0", "\xE2\x80\xAF" ), ' ', $text ); // Non-breaking spaces. if ( function_exists( 'iconv' ) ) { $out = @iconv( 'UTF-8', 'CP1252//TRANSLIT//IGNORE', $text ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( false !== $out ) { return $out; } } if ( function_exists( 'mb_convert_encoding' ) ) { return mb_convert_encoding( $text, 'Windows-1252', 'UTF-8' ); } return preg_replace( '/[^\x20-\x7E]/', '?', $text ); } /** Width in points of UTF-8 text in the current (or given) font. */ public function text_width( $text, $bold = null, $size = null ) { $font = null === $bold ? $this->font : ( $bold ? 'F2' : 'F1' ); $size = null === $size ? $this->size : (float) $size; $bytes = self::encode( $text ); $units = 0; $len = strlen( $bytes ); for ( $i = 0; $i < $len; $i++ ) { $units += self::$metrics[ $font ][ ord( $bytes[ $i ] ) ]; } return $units * $size / 1000; } /** * Break text into lines that fit $max_width in the current font. * * @return string[] */ public function wrap( $text, $max_width ) { $lines = array(); foreach ( preg_split( "/\r\n|\r|\n/", (string) $text ) as $para ) { $words = preg_split( '/ +/', trim( $para ) ); $line = ''; foreach ( $words as $word ) { $try = '' === $line ? $word : $line . ' ' . $word; if ( $this->text_width( $try ) <= $max_width ) { $line = $try; continue; } if ( '' !== $line ) { $lines[] = $line; } // A single word longer than the line: hard-break it. while ( $this->text_width( $word ) > $max_width && strlen( $word ) > 1 ) { $cut = strlen( $word ); while ( $cut > 1 && $this->text_width( substr( $word, 0, $cut ) ) > $max_width ) { --$cut; } $lines[] = substr( $word, 0, $cut ); $word = substr( $word, $cut ); } $line = $word; } $lines[] = $line; } return $lines; } private static function esc( $bytes ) { return str_replace( array( '\\', '(', ')', "\r" ), array( '\\\\', '\\(', '\\)', '\\r' ), $bytes ); } private static function rgb( $hex ) { $hex = ltrim( (string) $hex, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) { $hex = '000000'; } return sprintf( '%.3F %.3F %.3F', hexdec( substr( $hex, 0, 2 ) ) / 255, hexdec( substr( $hex, 2, 2 ) ) / 255, hexdec( substr( $hex, 4, 2 ) ) / 255 ); } /** * Draw one line of text. $y is the TOP of the line box. * * @param array $opts color (hex), align (left|right|center), width (box width for right/center). */ public function text( $x, $y, $text, $opts = array() ) { $opts = array_merge( array( 'color' => '#1d2327', 'align' => 'left', 'width' => 0, ), $opts ); if ( 'right' === $opts['align'] ) { $x += $opts['width'] - $this->text_width( $text ); } elseif ( 'center' === $opts['align'] ) { $x += ( $opts['width'] - $this->text_width( $text ) ) / 2; } $baseline = $y + $this->size * 0.8; $this->out( sprintf( 'BT /%s %.2F Tf %s rg %.2F %.2F Td (%s) Tj ET', $this->font, $this->size, self::rgb( $opts['color'] ), $x, $this->h - $baseline, self::esc( self::encode( $text ) ) ) ); } /** * Wrapped paragraph. Returns the height used. */ public function paragraph( $x, $y, $width, $text, $opts = array(), $line_height = 1.3 ) { $lh = $this->size * $line_height; $lines = $this->wrap( $text, $width ); foreach ( $lines as $i => $line ) { $this->text( $x, $y + $i * $lh, $line, array_merge( $opts, array( 'width' => $width ) ) ); } return count( $lines ) * $lh; } /* --------------------------------------------------------------------- * Shapes * ------------------------------------------------------------------- */ public function line( $x1, $y1, $x2, $y2, $width = 0.5, $color = '#c3c4c7' ) { $this->out( sprintf( 'q %s RG %.2F w %.2F %.2F m %.2F %.2F l S Q', self::rgb( $color ), $width, $x1, $this->h - $y1, $x2, $this->h - $y2 ) ); } public function rect( $x, $y, $w, $h, $fill = '#f0f0f1', $stroke = null, $stroke_width = 0.5 ) { $ops = 'q '; if ( $fill ) { $ops .= self::rgb( $fill ) . ' rg '; } if ( $stroke ) { $ops .= self::rgb( $stroke ) . ' RG ' . sprintf( '%.2F w ', $stroke_width ); } $op = $fill && $stroke ? 'B' : ( $fill ? 'f' : 'S' ); $ops .= sprintf( '%.2F %.2F %.2F %.2F re %s Q', $x, $this->h - $y - $h, $w, $h, $op ); $this->out( $ops ); } /* --------------------------------------------------------------------- * Images * ------------------------------------------------------------------- */ /** * Draw an image scaled to fit inside $max_w × $max_h (top-left at $x,$y). * * @param string $source File path or raw image bytes. * @return array|false [width, height] drawn, or false if the image can't be used. */ public function image( $source, $x, $y, $max_w, $max_h ) { $name = $this->load_image( $source ); if ( ! $name ) { return false; } list( , $iw, $ih ) = $this->images[ $name ]; $scale = min( $max_w / $iw, $max_h / $ih ); $w = $iw * $scale; $h = $ih * $scale; $this->out( sprintf( 'q %.2F 0 0 %.2F %.2F %.2F cm /%s Do Q', $w, $h, $x, $this->h - $y - $h, $name ) ); return array( $w, $h ); } private function load_image( $source ) { if ( ! is_string( $source ) || '' === $source ) { return false; } $bytes = ( strlen( $source ) < 4096 && @is_file( $source ) ) ? file_get_contents( $source ) : $source; // phpcs:ignore if ( ! $bytes ) { return false; } $key = md5( $bytes ); if ( isset( $this->image_keys[ $key ] ) ) { return $this->image_keys[ $key ]; } $info = @getimagesizefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $info ) { return false; } if ( IMAGETYPE_JPEG !== $info[2] ) { $bytes = self::to_jpeg( $bytes ); if ( ! $bytes ) { return false; } $info = getimagesizefromstring( $bytes ); } $channels = isset( $info['channels'] ) ? (int) $info['channels'] : 3; $cs = 1 === $channels ? 'DeviceGray' : ( 4 === $channels ? 'DeviceCMYK' : 'DeviceRGB' ); $name = 'I' . ( count( $this->images ) + 1 ); $this->images[ $name ] = array( $bytes, (int) $info[0], (int) $info[1], $cs, 4 === $channels ); $this->image_keys[ $key ] = $name; return $name; } private static function to_jpeg( $bytes ) { if ( ! function_exists( 'imagecreatefromstring' ) ) { return false; } $src = @imagecreatefromstring( $bytes ); // phpcs:ignore WordPress.PHP.NoSilencedErrors if ( ! $src ) { return false; } $w = imagesx( $src ); $h = imagesy( $src ); $dst = imagecreatetruecolor( $w, $h ); imagefill( $dst, 0, 0, imagecolorallocate( $dst, 255, 255, 255 ) ); imagealphablending( $dst, true ); imagecopy( $dst, $src, 0, 0, 0, 0, $w, $h ); ob_start(); imagejpeg( $dst, null, 92 ); $out = ob_get_clean(); imagedestroy( $src ); imagedestroy( $dst ); return $out; } /* --------------------------------------------------------------------- * Output * ------------------------------------------------------------------- */ /** @return string The PDF file contents. */ public function output() { if ( ! $this->pages ) { $this->add_page(); } $objects = array(); // index (1-based) => body. $n = 2; // 1 = catalog, 2 = pages. $font_ids = array(); foreach ( array( 'F1' => 'Helvetica', 'F2' => 'Helvetica-Bold' ) as $key => $base ) { $font_ids[ $key ] = ++$n; $objects[ $n ] = "<< /Type /Font /Subtype /Type1 /BaseFont /{$base} /Encoding /WinAnsiEncoding >>"; } $image_ids = array(); foreach ( $this->images as $name => $img ) { list( $data, $iw, $ih, $cs, $invert ) = $img; $image_ids[ $name ] = ++$n; $decode = $invert ? ' /Decode [1 0 1 0 1 0 1 0]' : ''; $objects[ $n ] = array( "<< /Type /XObject /Subtype /Image /Width {$iw} /Height {$ih} /ColorSpace /{$cs} /BitsPerComponent 8 /Filter /DCTDecode{$decode} /Length " . strlen( $data ) . ' >>', $data, ); } $resources = '<< /Font << '; foreach ( $font_ids as $key => $id ) { $resources .= "/{$key} {$id} 0 R "; } $resources .= '>>'; if ( $image_ids ) { $resources .= ' /XObject << '; foreach ( $image_ids as $name => $id ) { $resources .= "/{$name} {$id} 0 R "; } $resources .= '>>'; } $resources .= ' >>'; $kids = array(); $compress = function_exists( 'gzcompress' ); foreach ( $this->pages as $content ) { $page_id = ++$n; $content_id = ++$n; $kids[] = "{$page_id} 0 R"; $objects[ $page_id ] = sprintf( '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources %s /Contents %d 0 R >>', $this->w, $this->h, $resources, $content_id ); $stream = $compress ? gzcompress( $content ) : $content; $objects[ $content_id ] = array( '<< ' . ( $compress ? '/Filter /FlateDecode ' : '' ) . '/Length ' . strlen( $stream ) . ' >>', $stream, ); } $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; $objects[2] = '<< /Type /Pages /Kids [' . implode( ' ', $kids ) . '] /Count ' . count( $kids ) . ' >>'; $info_id = ++$n; $objects[ $info_id ] = '<< /Producer (' . self::esc( 'BYLTCommerce Toolkit' ) . ')' . ( $this->title ? ' /Title (' . self::esc( self::encode( $this->title ) ) . ')' : '' ) . ' /CreationDate (D:' . gmdate( 'YmdHis' ) . 'Z) >>'; ksort( $objects ); $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; $offsets = array(); foreach ( $objects as $id => $body ) { $offsets[ $id ] = strlen( $pdf ); if ( is_array( $body ) ) { $pdf .= "{$id} 0 obj\n{$body[0]}\nstream\n{$body[1]}\nendstream\nendobj\n"; } else { $pdf .= "{$id} 0 obj\n{$body}\nendobj\n"; } } $xref = strlen( $pdf ); $pdf .= "xref\n0 " . ( $n + 1 ) . "\n0000000000 65535 f \n"; for ( $i = 1; $i <= $n; $i++ ) { $pdf .= sprintf( "%010d 00000 n \n", $offsets[ $i ] ); } $pdf .= "trailer\n<< /Size " . ( $n + 1 ) . " /Root 1 0 R /Info {$info_id} 0 R >>\nstartxref\n{$xref}\n%%EOF"; return $pdf; } }