[TrueTypeFont, used glyphs (gid => code point)]. * * @var array */ private $fonts = array(); /** * Page content streams. * * @var string[] */ private $pages = array(); /** * Link annotations per page: [x, y, w, h, url]. * * @var array */ private $links = array(); /** * Images: key => Image::load() result. * * @var array */ private $images = array(); /** * Images used on each page. * * @var array */ private $page_images = array(); /** * Current page index. * * @var int */ private $page = -1; /** * Document title. * * @var string */ private $title = ''; /** * Whether zlib compression is available. * * @var bool */ private $deflate; /** * Constructor. */ public function __construct() { $this->deflate = function_exists( 'gzcompress' ) && function_exists( 'gzuncompress' ); } /** * Registers a font. * * @param string $key Name used in text(). * @param TrueTypeFont $font Font. * @return void */ public function add_font( $key, TrueTypeFont $font ) { $this->fonts[ $key ] = array( $font, array() ); } /** * The font registered under a key. * * @param string $key Font key. * @return TrueTypeFont * @throws \InvalidArgumentException For an unknown key. */ public function font( $key ) { if ( ! isset( $this->fonts[ $key ] ) ) { throw new \InvalidArgumentException( 'Unknown PDF font.' ); } return $this->fonts[ $key ][0]; } /** * Sets the document title. * * @param string $title Title. * @return void */ public function set_title( $title ) { $this->title = (string) $title; } /** * Starts a new page. * * @return int Page index. */ public function add_page() { $this->pages[] = ''; $this->page = count( $this->pages ) - 1; $this->links[ $this->page ] = array(); $this->page_images[ $this->page ] = array(); return $this->page; } /** * Number of pages. * * @return int */ public function page_count() { return count( $this->pages ); } /** * Switches to an existing page, e.g. to add page numbers at the end. * * @param int $index Page index. * @return void */ public function go_to( $index ) { if ( isset( $this->pages[ $index ] ) ) { $this->page = (int) $index; } } /** * Appends raw content to the current page. * * @param string $ops PDF operators. * @return void */ private function out( $ops ) { if ( $this->page < 0 ) { $this->add_page(); } $this->pages[ $this->page ] .= $ops . "\n"; } /** * Formats a number for PDF output. * * @param float $n Number. * @return string */ private static function n( $n ) { $s = number_format( (float) $n, 2, '.', '' ); $s = rtrim( rtrim( $s, '0' ), '.' ); return '' !== $s ? $s : '0'; } /** * Converts "#rrggbb" to PDF colour components. * * @param string $hex Colour. * @return string "r g b" */ 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 self::n( hexdec( substr( $hex, 0, 2 ) ) / 255 ) . ' ' . self::n( hexdec( substr( $hex, 2, 2 ) ) / 255 ) . ' ' . self::n( hexdec( substr( $hex, 4, 2 ) ) / 255 ); } /** * Mixes a colour with white: 0 keeps it, 1 is white. Used for tints, since * pages are white and this avoids transparency. * * @param string $hex Colour. * @param float $amount How much white. * @return string "#rrggbb" */ public static function tint( $hex, $amount ) { $hex = ltrim( (string) $hex, '#' ); if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) { return '#ffffff'; } $out = '#'; for ( $i = 0; $i < 3; $i++ ) { $v = hexdec( substr( $hex, 2 * $i, 2 ) ); $out .= sprintf( '%02x', (int) round( $v + ( 255 - $v ) * max( 0, min( 1, $amount ) ) ) ); } return $out; } /** * Whether white text is readable on a colour (WCAG relative luminance). * * @param string $hex Background colour. * @return bool */ public static function is_dark( $hex ) { $hex = ltrim( (string) $hex, '#' ); if ( ! preg_match( '/^[0-9a-fA-F]{6}$/', $hex ) ) { return true; } $lum = 0; foreach ( array( 0.2126, 0.7152, 0.0722 ) as $i => $w ) { $c = hexdec( substr( $hex, 2 * $i, 2 ) ) / 255; $c = $c <= 0.03928 ? $c / 12.92 : pow( ( $c + 0.055 ) / 1.055, 2.4 ); $lum += $w * $c; } return $lum < 0.4; } /** * Draws a rectangle, optionally with rounded corners. * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param float $h Height. * @param string|null $fill Fill colour or null. * @param string|null $stroke Stroke colour or null. * @param float $radius Corner radius. * @param float $lw Line width. * @return void */ public function rect( $x, $y, $w, $h, $fill = null, $stroke = null, $radius = 0.0, $lw = 0.75 ) { if ( $w <= 0 || $h <= 0 || ( null === $fill && null === $stroke ) ) { return; } $ops = 'q '; if ( null !== $fill ) { $ops .= self::rgb( $fill ) . ' rg '; } if ( null !== $stroke ) { $ops .= self::rgb( $stroke ) . ' RG ' . self::n( $lw ) . ' w '; } $r = min( (float) $radius, $w / 2, $h / 2 ); $by = self::HEIGHT - $y - $h; if ( $r <= 0 ) { $ops .= self::n( $x ) . ' ' . self::n( $by ) . ' ' . self::n( $w ) . ' ' . self::n( $h ) . ' re '; } else { $k = 0.5523 * $r; $x1 = $x + $w; $y1 = $by + $h; $ops .= self::n( $x + $r ) . ' ' . self::n( $by ) . ' m '; $ops .= self::n( $x1 - $r ) . ' ' . self::n( $by ) . ' l '; $ops .= self::n( $x1 - $r + $k ) . ' ' . self::n( $by ) . ' ' . self::n( $x1 ) . ' ' . self::n( $by + $r - $k ) . ' ' . self::n( $x1 ) . ' ' . self::n( $by + $r ) . ' c '; $ops .= self::n( $x1 ) . ' ' . self::n( $y1 - $r ) . ' l '; $ops .= self::n( $x1 ) . ' ' . self::n( $y1 - $r + $k ) . ' ' . self::n( $x1 - $r + $k ) . ' ' . self::n( $y1 ) . ' ' . self::n( $x1 - $r ) . ' ' . self::n( $y1 ) . ' c '; $ops .= self::n( $x + $r ) . ' ' . self::n( $y1 ) . ' l '; $ops .= self::n( $x + $r - $k ) . ' ' . self::n( $y1 ) . ' ' . self::n( $x ) . ' ' . self::n( $y1 - $r + $k ) . ' ' . self::n( $x ) . ' ' . self::n( $y1 - $r ) . ' c '; $ops .= self::n( $x ) . ' ' . self::n( $by + $r ) . ' l '; $ops .= self::n( $x ) . ' ' . self::n( $by + $r - $k ) . ' ' . self::n( $x + $r - $k ) . ' ' . self::n( $by ) . ' ' . self::n( $x + $r ) . ' ' . self::n( $by ) . ' c h '; } $ops .= ( null !== $fill && null !== $stroke ? 'B' : ( null !== $fill ? 'f' : 'S' ) ) . ' Q'; $this->out( $ops ); } /** * Draws a straight line. * * @param float $x1 Start x. * @param float $y1 Start y. * @param float $x2 End x. * @param float $y2 End y. * @param string $color Colour. * @param float $lw Line width. * @param bool $dash Dashed. * @return void */ public function line( $x1, $y1, $x2, $y2, $color, $lw = 0.75, $dash = false ) { $this->out( 'q ' . self::rgb( $color ) . ' RG ' . self::n( $lw ) . ' w ' . ( $dash ? '[2 2] 0 d ' : '' ) . self::n( $x1 ) . ' ' . self::n( self::HEIGHT - $y1 ) . ' m ' . self::n( $x2 ) . ' ' . self::n( self::HEIGHT - $y2 ) . ' l S Q' ); } /** * Draws connected line segments, or a filled shape. * * @param array[] $points Points as [x, y]. * @param string|null $stroke Stroke colour. * @param string|null $fill Fill colour (closes the shape). * @param float $lw Line width. * @return void */ public function path( array $points, $stroke = null, $fill = null, $lw = 1.5 ) { if ( count( $points ) < 2 ) { return; } $ops = 'q 1 j 1 J '; if ( null !== $stroke ) { $ops .= self::rgb( $stroke ) . ' RG ' . self::n( $lw ) . ' w '; } if ( null !== $fill ) { $ops .= self::rgb( $fill ) . ' rg '; } foreach ( array_values( $points ) as $i => $p ) { $ops .= self::n( $p[0] ) . ' ' . self::n( self::HEIGHT - $p[1] ) . ( $i ? ' l ' : ' m ' ); } if ( null !== $fill ) { $ops .= 'h ' . ( null !== $stroke ? 'B' : 'f' ); } else { $ops .= 'S'; } $this->out( $ops . ' Q' ); } /** * Draws a filled circle. * * @param float $cx Centre x. * @param float $cy Centre y. * @param float $r Radius. * @param string $color Fill colour. * @return void */ public function circle( $cx, $cy, $r, $color ) { $this->rect( $cx - $r, $cy - $r, 2 * $r, 2 * $r, $color, null, $r ); } /** * Width of text in points. * * @param string $text UTF-8 text. * @param string $font Font key. * @param float $size Font size. * @return float */ public function width( $text, $font, $size ) { $f = $this->font( $font ); $w = 0.0; foreach ( Text::codepoints( $text ) as $cp ) { $w += $f->width( $f->glyph( $cp ) ); } return $w * $size / 1000; } /** * Writes one line of text. * * @param float $x Left (or right/centre point, see $align). * @param float $y Baseline. * @param string $text UTF-8 text. * @param string $font Font key. * @param float $size Font size in points. * @param string $color Colour. * @param string $align left, right or center. * @return float Width written. */ public function text( $x, $y, $text, $font, $size, $color = '#000000', $align = 'left' ) { $text = (string) $text; if ( '' === $text ) { return 0.0; } $w = $this->width( $text, $font, $size ); if ( 'right' === $align ) { $x -= $w; } elseif ( 'center' === $align ) { $x -= $w / 2; } $f = $this->font( $font ); $hex = ''; foreach ( Text::codepoints( $text ) as $cp ) { $gid = $f->glyph( $cp ); if ( ! isset( $this->fonts[ $font ][1][ $gid ] ) ) { $this->fonts[ $font ][1][ $gid ] = $gid ? $cp : 0xFFFD; } $hex .= sprintf( '%04X', $gid ); } $this->out( 'BT ' . self::rgb( $color ) . ' rg /F' . $this->font_index( $font ) . ' ' . self::n( $size ) . ' Tf ' . self::n( $x ) . ' ' . self::n( self::HEIGHT - $y ) . ' Td <' . $hex . '> Tj ET' ); return $w; } /** * Writes text cut to a width, ending with an ellipsis when cut. * * @param float $x Left. * @param float $y Baseline. * @param string $text Text. * @param string $font Font key. * @param float $size Size. * @param string $color Colour. * @param float $max Most width. * @param string $align Alignment. * @return void */ public function text_fit( $x, $y, $text, $font, $size, $color, $max, $align = 'left' ) { $this->text( $x, $y, $this->fit( $text, $font, $size, $max ), $font, $size, $color, $align ); } /** * Text cut to fit a width. * * @param string $text Text. * @param string $font Font key. * @param float $size Size. * @param float $max Width. * @return string */ public function fit( $text, $font, $size, $max ) { $text = (string) $text; if ( $this->width( $text, $font, $size ) <= $max ) { return $text; } $chars = Text::chars( $text ); $lo = 0; $hi = count( $chars ); while ( $lo < $hi ) { $mid = (int) ceil( ( $lo + $hi ) / 2 ); if ( $this->width( implode( '', array_slice( $chars, 0, $mid ) ) . '…', $font, $size ) <= $max ) { $lo = $mid; } else { $hi = $mid - 1; } } return rtrim( implode( '', array_slice( $chars, 0, $lo ) ) ) . '…'; } /** * Breaks text into lines that fit a width. * * @param string $text Text (may contain line breaks). * @param string $font Font key. * @param float $size Size. * @param float $width Line width. * @return string[] */ public function wrap( $text, $font, $size, $width ) { $lines = array(); foreach ( preg_split( '/\r\n|\r|\n/', (string) $text ) as $para ) { $words = preg_split( '/[ \t]+/u', trim( $para ) ); $line = ''; foreach ( $words as $word ) { if ( '' === $word ) { continue; } $try = '' === $line ? $word : $line . ' ' . $word; if ( $this->width( $try, $font, $size ) <= $width ) { $line = $try; continue; } if ( '' !== $line ) { $lines[] = $line; } // A single word longer than the line is split by characters. while ( $this->width( $word, $font, $size ) > $width ) { $chars = Text::chars( $word ); $take = 1; $count = count( $chars ); while ( $take < $count && $this->width( implode( '', array_slice( $chars, 0, $take + 1 ) ), $font, $size ) <= $width ) { ++$take; } $lines[] = implode( '', array_slice( $chars, 0, $take ) ); $word = implode( '', array_slice( $chars, $take ) ); } $line = $word; } $lines[] = $line; } return $lines; } /** * Adds a clickable link area. * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param float $h Height. * @param string $url http(s) URL. * @return void */ public function link( $x, $y, $w, $h, $url ) { if ( preg_match( '#^https?://#i', (string) $url ) && $this->page >= 0 ) { $this->links[ $this->page ][] = array( $x, $y, $w, $h, (string) $url ); } } /** * Draws an image from a file, keeping its aspect ratio inside a box. * * @param string $path Image file (JPEG or PNG; others need GD). * @param float $x Left. * @param float $y Top. * @param float $max_w Most width. * @param float $max_h Most height. * @param string $align left, right or center inside the box. * @return float[]|null [width, height] drawn, or null when the image can't be used. */ public function image( $path, $x, $y, $max_w, $max_h, $align = 'left' ) { $key = 'I' . md5( (string) $path ); if ( ! isset( $this->images[ $key ] ) ) { $image = Image::load( (string) $path, $this->deflate ); if ( ! $image ) { return null; } $this->images[ $key ] = $image; } $img = $this->images[ $key ]; $scale = min( $max_w / $img['w'], $max_h / $img['h'] ); $w = $img['w'] * $scale; $h = $img['h'] * $scale; if ( 'right' === $align ) { $x += $max_w - $w; } elseif ( 'center' === $align ) { $x += ( $max_w - $w ) / 2; } $y += ( $max_h - $h ) / 2; $this->out( 'q ' . self::n( $w ) . ' 0 0 ' . self::n( $h ) . ' ' . self::n( $x ) . ' ' . self::n( self::HEIGHT - $y - $h ) . ' cm /' . $key . ' Do Q' ); $this->page_images[ $this->page ][ $key ] = $key; return array( $w, $h ); } /** * Index of a font in the resources. * * @param string $key Font key. * @return int */ private function font_index( $key ) { return (int) array_search( $key, array_keys( $this->fonts ), true ) + 1; } /** * Compresses a stream when possible. * * @param string $data Data. * @param string $extra Extra dictionary entries. * @return string Stream object body (without "N 0 obj"). */ private function stream( $data, $extra = '' ) { if ( $this->deflate ) { $data = gzcompress( $data, 6 ); $extra .= ' /Filter /FlateDecode'; } return '<< /Length ' . strlen( $data ) . $extra . " >>\nstream\n" . $data . "\nendstream"; } /** * UTF-16BE hex for one code point. * * @param int $cp Code point. * @return string */ private static function utf16_hex( $cp ) { $cp = (int) $cp; if ( $cp > 0xFFFF ) { $cp -= 0x10000; return sprintf( '%04X%04X', 0xD800 | ( $cp >> 10 ), 0xDC00 | ( $cp & 0x3FF ) ); } return sprintf( '%04X', $cp ); } /** * Text string in a PDF dictionary (UTF-16BE with BOM). * * @param string $text UTF-8 text. * @return string */ private static function pdf_string( $text ) { $hex = 'FEFF'; foreach ( Text::codepoints( $text ) as $cp ) { $hex .= self::utf16_hex( $cp ); } return '<' . $hex . '>'; } /** * Escapes a literal PDF string. * * @param string $s ASCII text. * @return string */ private static function literal( $s ) { return '(' . strtr( (string) $s, array( '\\' => '\\\\', '(' => '\\(', ')' => '\\)', "\r" => '\\r', "\n" => '\\n', ) ) . ')'; } /** * Builds the PDF file. * * @return string */ public function output() { if ( ! $this->pages ) { $this->add_page(); } $objects = array(); $next = 3; // 1 = catalog, 2 = pages. $alloc = static function () use ( &$next ) { return $next++; }; // Fonts. $font_refs = array(); $i = 0; foreach ( $this->fonts as $key => $pair ) { ++$i; list( $font, $used ) = $pair; $used[0] = isset( $used[0] ) ? $used[0] : 0xFFFD; ksort( $used ); $tag = ''; $hash = md5( $key . implode( ',', array_keys( $used ) ) ); for ( $c = 0; $c < 6; $c++ ) { $tag .= chr( 65 + hexdec( $hash[ $c ] ) % 26 ); } $base = $tag . '+' . $font->name; $file = $font->subset( array_keys( $used ) ); $o_file = $alloc(); $o_desc = $alloc(); $o_cid = $alloc(); $o_uni = $alloc(); $o_font = $alloc(); $widths = ''; foreach ( array_keys( $used ) as $gid ) { $widths .= $gid . ' [' . (int) round( $font->width( $gid ) ) . '] '; } $cmap = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n1 begincodespacerange\n<0000> \nendcodespacerange\n"; foreach ( array_chunk( $used, 100, true ) as $chunk ) { $cmap .= count( $chunk ) . " beginbfchar\n"; foreach ( $chunk as $gid => $cp ) { $cmap .= sprintf( '<%04X> <%s>', $gid, self::utf16_hex( $cp ) ) . "\n"; } $cmap .= "endbfchar\n"; } $cmap .= "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend"; $objects[ $o_file ] = $this->stream( $file, ' /Length1 ' . strlen( $file ) ); $objects[ $o_desc ] = '<< /Type /FontDescriptor /FontName /' . $base . ' /Flags 32 /FontBBox [' . $font->scale( $font->bbox[0] ) . ' ' . $font->scale( $font->bbox[1] ) . ' ' . $font->scale( $font->bbox[2] ) . ' ' . $font->scale( $font->bbox[3] ) . '] /ItalicAngle ' . self::n( $font->italic_angle ) . ' /Ascent ' . $font->scale( $font->ascent ) . ' /Descent ' . $font->scale( $font->descent ) . ' /CapHeight ' . $font->scale( $font->cap_height ) . ' /StemV ' . ( $font->weight >= 600 ? 120 : 80 ) . ' /FontFile2 ' . $o_file . ' 0 R >>'; $objects[ $o_cid ] = '<< /Type /Font /Subtype /CIDFontType2 /BaseFont /' . $base . ' /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor ' . $o_desc . ' 0 R /DW 600 /W [' . trim( $widths ) . '] /CIDToGIDMap /Identity >>'; $objects[ $o_uni ] = $this->stream( $cmap ); $objects[ $o_font ] = '<< /Type /Font /Subtype /Type0 /BaseFont /' . $base . ' /Encoding /Identity-H /DescendantFonts [' . $o_cid . ' 0 R] /ToUnicode ' . $o_uni . ' 0 R >>'; $font_refs[ $i ] = $o_font; } // Images. $image_refs = array(); foreach ( $this->images as $key => $img ) { $smask = ''; if ( null !== $img['smask'] ) { $o_mask = $alloc(); $objects[ $o_mask ] = '<< /Type /XObject /Subtype /Image /Width ' . $img['w'] . ' /Height ' . $img['h'] . ' /ColorSpace /DeviceGray /BitsPerComponent 8' . $img['smask_params'] . ' /Length ' . strlen( $img['smask'] ) . " >>\nstream\n" . $img['smask'] . "\nendstream"; $smask = ' /SMask ' . $o_mask . ' 0 R'; } $o_img = $alloc(); $objects[ $o_img ] = '<< /Type /XObject /Subtype /Image /Width ' . $img['w'] . ' /Height ' . $img['h'] . ' ' . $img['dict'] . $smask . ' /Length ' . strlen( $img['data'] ) . " >>\nstream\n" . $img['data'] . "\nendstream"; $image_refs[ $key ] = $o_img; } $font_res = ''; foreach ( $font_refs as $n => $ref ) { $font_res .= '/F' . $n . ' ' . $ref . ' 0 R '; } // Pages. $kids = array(); foreach ( $this->pages as $index => $content ) { $o_content = $alloc(); $o_page = $alloc(); $objects[ $o_content ] = $this->stream( $content ); $xobjects = ''; foreach ( $this->page_images[ $index ] as $key ) { $xobjects .= '/' . $key . ' ' . $image_refs[ $key ] . ' 0 R '; } $annots = ''; foreach ( $this->links[ $index ] as $l ) { $o_annot = $alloc(); $objects[ $o_annot ] = '<< /Type /Annot /Subtype /Link /Rect [' . self::n( $l[0] ) . ' ' . self::n( self::HEIGHT - $l[1] - $l[3] ) . ' ' . self::n( $l[0] + $l[2] ) . ' ' . self::n( self::HEIGHT - $l[1] ) . '] /Border [0 0 0] /A << /S /URI /URI ' . self::literal( $l[4] ) . ' >> >>'; $annots .= $o_annot . ' 0 R '; } $objects[ $o_page ] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' . self::n( self::WIDTH ) . ' ' . self::n( self::HEIGHT ) . '] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC /ImageI] /Font << ' . $font_res . '>>' . ( '' !== $xobjects ? ' /XObject << ' . $xobjects . '>>' : '' ) . ' >> /Contents ' . $o_content . ' 0 R' . ( '' !== $annots ? ' /Annots [' . trim( $annots ) . ']' : '' ) . ' >>'; $kids[] = $o_page . ' 0 R'; } $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; $objects[2] = '<< /Type /Pages /Kids [' . implode( ' ', $kids ) . '] /Count ' . count( $kids ) . ' >>'; $o_info = $alloc(); $objects[ $o_info ] = '<< /Producer (TurtleReport) /CreationDate ' . self::literal( 'D:' . gmdate( 'YmdHis' ) . 'Z' ) . ( '' !== $this->title ? ' /Title ' . self::pdf_string( $this->title ) : '' ) . ' >>'; ksort( $objects ); $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; $offsets = array(); foreach ( $objects as $n => $body ) { $offsets[ $n ] = strlen( $pdf ); $pdf .= $n . " 0 obj\n" . $body . "\nendobj\n"; } $xref = strlen( $pdf ); $size = max( array_keys( $objects ) ) + 1; $pdf .= "xref\n0 " . $size . "\n0000000000 65535 f \n"; for ( $n = 1; $n < $size; $n++ ) { $pdf .= isset( $offsets[ $n ] ) ? sprintf( "%010d 00000 n \n", $offsets[ $n ] ) : "0000000000 65535 f \n"; } $id = md5( $pdf ); $pdf .= "trailer\n<< /Size " . $size . ' /Root 1 0 R /Info ' . $o_info . ' 0 R /ID [<' . $id . '> <' . $id . ">] >>\nstartxref\n" . $xref . "\n%%EOF\n"; return $pdf; } }