*/ private const CUSTOM_UNICODE_TO_BYTE = array( 0x0106 => 1, // Ć. 0x0107 => 2, // ć. 0x010C => 3, // Č. 0x010D => 4, // č. 0x0110 => 5, // Đ. 0x0111 => 6, // đ. ); /** @var array */ private const CP1252_SPECIALS = array( 0x20AC => 0x80, 0x201A => 0x82, 0x0192 => 0x83, 0x201E => 0x84, 0x2026 => 0x85, 0x2020 => 0x86, 0x2021 => 0x87, 0x02C6 => 0x88, 0x2030 => 0x89, 0x0160 => 0x8A, 0x2039 => 0x8B, 0x0152 => 0x8C, 0x017D => 0x8E, 0x2018 => 0x91, 0x2019 => 0x92, 0x201C => 0x93, 0x201D => 0x94, 0x2022 => 0x95, 0x2013 => 0x96, 0x2014 => 0x97, 0x02DC => 0x98, 0x2122 => 0x99, 0x0161 => 0x9A, 0x203A => 0x9B, 0x0153 => 0x9C, 0x017E => 0x9E, 0x0178 => 0x9F, ); public function __construct( private readonly BasicDocumentTextCatalog $texts ) {} public function render( BasicOrderDocument $document ): string { $pages = $this->layout( $document ); if ( empty( $pages ) ) { throw new \RuntimeException( 'The Basic PDF renderer produced no pages.' ); } return $this->build_pdf( $pages, $document ); } /** * @return string[] Page content streams. */ private function layout( BasicOrderDocument $document ): array { $pages = array(); $commands = array(); $page = 1; $y = self::PAGE_HEIGHT - self::MARGIN; $start_page = function ( bool $continuation = false ) use ( &$commands, &$y, $document ): void { $commands = array(); $y = self::PAGE_HEIGHT - self::MARGIN; if ( ! $continuation ) { $headline = $this->texts->get( $document->language(), 'document_title' ) . ' ' . $document->order_number(); foreach ( $this->wrap_text( $headline, 58 ) as $line ) { $this->text( $commands, self::MARGIN, $y, 16, true, $line ); $y -= 19; } $y -= 5; if ( '' !== $document->preview_label() ) { $this->text( $commands, self::MARGIN, $y, 10, true, $document->preview_label() ); $y -= 17; } $this->text( $commands, self::MARGIN, $y, 9, false, $this->texts->get( $document->language(), 'order_date' ) . ': ' . $document->order_date() ); $y -= 14; $this->line( $commands, self::MARGIN, $y, self::PAGE_WIDTH - self::MARGIN, $y ); $y -= 18; } elseif ( '' !== $document->preview_label() ) { $this->text( $commands, self::MARGIN, $y, 9, true, $document->preview_label() ); $y -= 16; $this->line( $commands, self::MARGIN, $y, self::PAGE_WIDTH - self::MARGIN, $y ); $y -= 18; } }; $finish_page = function () use ( &$pages, &$commands, &$page ): void { $commands[] = '__LMCC_PAGE_FOOTER_' . $page . '__'; $pages[] = implode( "\n", $commands ); ++$page; }; $start_page(); $merchant_lines = $this->wrap_lines( $this->merchant_lines( $document ), 41 ); $billing_lines = $this->wrap_lines( $this->customer_lines( $document->billing() ), 41 ); $merchant_count = count( $merchant_lines ); $billing_count = count( $billing_lines ); $merchant_index = 0; $billing_index = 0; $left_x = self::MARGIN; $right_x = 307.0; $column_width = 246.0; do { $capacity = max( 1, (int) floor( ( $y - 112 ) / 11 ) - 3 ); $merchant_chunk = array_slice( $merchant_lines, $merchant_index, $capacity ); $billing_chunk = array_slice( $billing_lines, $billing_index, $capacity ); $line_count = max( 1, count( $merchant_chunk ), count( $billing_chunk ) ); $row_height = 29 + ( $line_count * 11 ); $this->rect_fill( $commands, $left_x, $y - $row_height, $column_width, $row_height, 0.97 ); $this->rect_fill( $commands, $right_x, $y - $row_height, $column_width, $row_height, 0.97 ); $this->text( $commands, $left_x + 8, $y - 16, 10, true, $this->texts->get( $document->language(), 'seller' ) ); $this->text( $commands, $right_x + 8, $y - 16, 10, true, $this->texts->get( $document->language(), 'customer' ) ); foreach ( $merchant_chunk as $index => $line ) { $this->text( $commands, $left_x + 8, $y - 31 - ( $index * 11 ), 8.3, false, $line ); } foreach ( $billing_chunk as $index => $line ) { $this->text( $commands, $right_x + 8, $y - 31 - ( $index * 11 ), 8.3, false, $line ); } $merchant_index += count( $merchant_chunk ); $billing_index += count( $billing_chunk ); $y -= $row_height + 10; if ( $merchant_index < $merchant_count || $billing_index < $billing_count ) { $finish_page(); $start_page( true ); } } while ( $merchant_index < $merchant_count || $billing_index < $billing_count ); $shipping_lines = $this->wrap_lines( $this->customer_lines( $document->shipping() ), 88 ); if ( ! empty( $shipping_lines ) ) { $shipping_height = 20 + ( count( $shipping_lines ) * 11 ); if ( $y - $shipping_height < 105 ) { $finish_page(); $start_page( true ); } $this->identity_section( $commands, $y, $this->texts->get( $document->language(), 'shipping' ), $shipping_lines ); } $y -= 4; if ( $y < 145 ) { $finish_page(); $start_page( true ); } $this->table_header( $commands, $y, $document ); foreach ( $document->items() as $item ) { $name_lines = $this->wrap_text( (string) ( $item['name'] ?? '' ), 37 ); $row_height = max( 18, 12 * count( $name_lines ) + 6 ); if ( $y - $row_height < 92 ) { $finish_page(); $start_page( true ); $this->table_header( $commands, $y, $document ); } $row_top = $y; foreach ( $name_lines as $index => $line ) { $this->text( $commands, self::MARGIN + 4, $row_top - 13 - ( $index * 11 ), 8.5, false, $line ); } $this->text( $commands, 300, $row_top - 13, 8.0, false, (string) ( $item['sku'] ?? '' ) ); $this->text( $commands, 380, $row_top - 13, 8.5, false, (string) ( $item['quantity'] ?? 0 ) ); $this->text( $commands, 420, $row_top - 13, 8.5, false, (string) ( $item['unit_price'] ?? '' ) ); $this->text( $commands, 500, $row_top - 13, 8.5, false, (string) ( $item['line_total'] ?? '' ) ); $y -= $row_height; $this->line( $commands, self::MARGIN, $y, self::PAGE_WIDTH - self::MARGIN, $y, 0.5 ); } if ( $y < 260 ) { $finish_page(); $start_page( true ); } $y -= 16; $this->summary( $commands, $y, $document ); $y -= 18; $this->labeled_value( $commands, $y, $this->texts->get( $document->language(), 'payment_method' ), $document->payment_method(), $finish_page, $start_page ); $this->labeled_value( $commands, $y, $this->texts->get( $document->language(), 'shipping_method' ), $document->shipping_method(), $finish_page, $start_page ); $y -= 8; foreach ( $this->wrap_measured( $this->texts->get( $document->language(), 'notice' ), self::PAGE_WIDTH - ( 2 * self::MARGIN ), 8 ) as $line ) { if ( $y < 72 ) { $finish_page(); $start_page( true ); } $this->text( $commands, self::MARGIN, $y, 8, false, $line ); $y -= 10; } $finish_page(); $total_pages = count( $pages ); foreach ( $pages as $index => $content ) { $page_number = $index + 1; $footer_text = $this->texts->get( $document->language(), 'page' ) . ' ' . $page_number . '/' . $total_pages; $footer = $this->gray_text_command( self::PAGE_WIDTH - 100, self::FOOTER_Y, 7.5, false, $footer_text ); $pages[ $index ] = str_replace( '__LMCC_PAGE_FOOTER_' . $page_number . '__', $footer, $content ); } return $pages; } /** @return string[] */ private function merchant_lines( BasicOrderDocument $document ): array { $m = $document->merchant(); $lines = array(); if ( '' !== (string) ( $m['name'] ?? '' ) ) { $lines[] = (string) $m['name']; } if ( '' !== (string) ( $m['address'] ?? '' ) ) { $lines[] = (string) $m['address']; } if ( '' !== (string) ( $m['registration_number'] ?? '' ) ) { $lines[] = $this->texts->get( $document->language(), 'registration' ) . ': ' . (string) $m['registration_number']; } if ( '' !== (string) ( $m['tax_id'] ?? '' ) ) { $lines[] = $this->texts->get( $document->language(), 'tax_id' ) . ': ' . (string) $m['tax_id']; } if ( '' !== (string) ( $m['vat_number'] ?? '' ) ) { $lines[] = $this->texts->get( $document->language(), 'vat_number' ) . ': ' . (string) $m['vat_number']; } if ( '' !== (string) ( $m['email'] ?? '' ) ) { $lines[] = $this->texts->get( $document->language(), 'email' ) . ': ' . (string) $m['email']; } if ( '' !== (string) ( $m['phone'] ?? '' ) ) { $lines[] = $this->texts->get( $document->language(), 'phone' ) . ': ' . (string) $m['phone']; } return $lines; } /** @param array $identity @return string[] */ private function customer_lines( array $identity ): array { $lines = array(); foreach ( array( 'name', 'company', 'address', 'email', 'phone' ) as $key ) { $value = trim( (string) ( $identity[ $key ] ?? '' ) ); if ( '' !== $value ) { $lines[] = $value; } } return $lines; } /** @param string[] $lines */ private function identity_section( array &$commands, float &$y, string $heading, array $lines ): void { $this->text( $commands, self::MARGIN, $y, 10, true, $heading ); $y -= 13; foreach ( $lines as $line ) { $this->text( $commands, self::MARGIN + 8, $y, 8.5, false, $line ); $y -= 11; } $y -= 7; } /** @param string[] $lines @return string[] */ private function wrap_lines( array $lines, int $max_chars ): array { $wrapped = array(); foreach ( $lines as $line ) { foreach ( $this->wrap_text( $line, $max_chars ) as $part ) { $wrapped[] = $part; } } return $wrapped; } private function table_header( array &$commands, float &$y, BasicOrderDocument $document ): void { $this->rect_fill( $commands, self::MARGIN, $y - 18, self::PAGE_WIDTH - ( 2 * self::MARGIN ), 18, 0.94 ); $this->text( $commands, self::MARGIN + 4, $y - 12, 8.3, true, $this->texts->get( $document->language(), 'product' ) ); $this->text( $commands, 296, $y - 12, 8.0, true, $this->texts->get( $document->language(), 'sku' ) ); $this->text( $commands, 374, $y - 12, 8.3, true, $this->texts->get( $document->language(), 'quantity' ) ); $this->text( $commands, 414, $y - 12, 8.3, true, $this->texts->get( $document->language(), 'unit_price' ) ); $this->text( $commands, 494, $y - 12, 8.3, true, $this->texts->get( $document->language(), 'line_total' ) ); $y -= 18; } private function summary( array &$commands, float &$y, BasicOrderDocument $document ): void { $rows = array( array( $this->texts->get( $document->language(), 'subtotal' ), $document->subtotal(), false ), array( $this->texts->get( $document->language(), 'discount' ), $document->discount(), false ), array( $this->texts->get( $document->language(), 'shipping_total' ), $document->shipping_total(), false ), array( $this->texts->get( $document->language(), 'tax' ), $document->tax_total(), false ), array( $this->texts->get( $document->language(), 'grand_total' ), $document->total(), true ), ); foreach ( $rows as $row ) { $this->text( $commands, 340, $y, 9, (bool) $row[2], (string) $row[0] ); $this->text( $commands, 470, $y, 9, (bool) $row[2], (string) $row[1] ); $y -= 13; } } private function labeled_value( array &$commands, float &$y, string $label, string $value, callable $finish_page, callable $start_page ): void { if ( '' === trim( $value ) ) { return; } foreach ( $this->wrap_measured( $value, self::PAGE_WIDTH - self::MARGIN - 155, 8.5 ) as $index => $line ) { if ( $y < 84 ) { $finish_page(); $start_page( true ); } if ( 0 === $index ) { $this->text( $commands, self::MARGIN, $y, 8.5, true, $label . ':' ); } $this->text( $commands, 155, $y, 8.5, false, $line ); $y -= 12; } } /** @return string[] Greedy wrapping by the actual font advances, including unbroken words. */ private function wrap_measured( string $text, float $width, float $size ): array { $text = trim( preg_replace( '/\s+/u', ' ', $text ) ?? $text ); $words = preg_split( '/\s+/u', $text ); $words = false !== $words ? $words : array( $text ); $lines = array(); $line = ''; foreach ( $words as $word ) { $candidate = '' === $line ? $word : $line . ' ' . $word; if ( $this->text_width( $candidate, $size ) <= $width ) { $line = $candidate; continue; } if ( '' !== $line ) { $lines[] = $line; } $line = ''; $characters = preg_split( '//u', $word, -1, PREG_SPLIT_NO_EMPTY ); foreach ( false !== $characters ? $characters : array() as $character ) { if ( '' !== $line && $this->text_width( $line . $character, $size ) > $width ) { $lines[] = $line; $line = ''; } $line .= $character; } } if ( '' !== $line ) { $lines[] = $line; } return $lines; } /** @return string[] */ private function wrap_text( string $text, int $max_chars ): array { $text = trim( preg_replace( '/\s+/u', ' ', $text ) ?? $text ); if ( '' === $text ) { return array( '' ); } $words = preg_split( '/\s+/u', $text ); $words = false !== $words ? $words : array( $text ); $lines = array(); $line = ''; foreach ( $words as $word ) { while ( $this->unicode_length( $word ) > $max_chars ) { if ( '' !== $line ) { $lines[] = $line; $line = ''; } $parts = preg_split( '//u', $word, -1, PREG_SPLIT_NO_EMPTY ); $parts = false !== $parts ? $parts : str_split( $word ); $lines[] = implode( '', array_slice( $parts, 0, $max_chars ) ); $word = implode( '', array_slice( $parts, $max_chars ) ); } $candidate = '' === $line ? $word : $line . ' ' . $word; if ( $this->unicode_length( $candidate ) <= $max_chars ) { $line = $candidate; continue; } if ( '' !== $line ) { $lines[] = $line; } $line = $word; } if ( '' !== $line ) { $lines[] = $line; } return $lines; } private function unicode_length( string $text ): int { $count = preg_match_all( '/./us', $text, $matches ); return false === $count ? strlen( $text ) : $count; } private function text( array &$commands, float $x, float $y, float $size, bool $bold, string $text ): void { $commands[] = $this->text_command( $x, $y, $size, $bold, $text ); } private function text_command( float $x, float $y, float $size, bool $bold, string $text ): string { $font = $bold ? 'F2' : 'F1'; return sprintf( 'BT /%s %.2F Tf %.2F %.2F Td (%s) Tj ET', $font, $size, $x, $y, $this->pdf_text( $text ) ); } private function gray_text_command( float $x, float $y, float $size, bool $bold, string $text ): string { return 'q 0.42 g ' . $this->text_command( $x, $y, $size, $bold, $text ) . ' Q'; } private function text_width( string $text, float $size ): float { $units = 0; foreach ( $this->utf8_codepoints( $text ) as $codepoint ) { $byte = $this->codepoint_to_byte( $codepoint ); if ( null === $byte ) { throw new \RuntimeException( 'Unsupported Basic PDF font character.' ); } $units += self::FONT_WIDTHS[ $byte ]; } return $units * $size / 1000; } private function line( array &$commands, float $x1, float $y1, float $x2, float $y2, float $width = 0.8 ): void { $commands[] = sprintf( 'q %.2F w 0.70 G %.2F %.2F m %.2F %.2F l S Q', $width, $x1, $y1, $x2, $y2 ); } private function rect_fill( array &$commands, float $x, float $y, float $w, float $h, float $gray ): void { $commands[] = sprintf( 'q %.2F g %.2F %.2F %.2F %.2F re f Q', $gray, $x, $y, $w, $h ); } private function pdf_text( string $text ): string { $encoded = ''; foreach ( $this->utf8_codepoints( $text ) as $codepoint ) { $byte = $this->codepoint_to_byte( $codepoint ); if ( null === $byte ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; codepoint is an integer. throw new \RuntimeException( sprintf( 'Basic PDF fixed-font renderer cannot safely encode Unicode code point U+%04X.', $codepoint ) ); } if ( 0x5C === $byte || 0x28 === $byte || 0x29 === $byte ) { $encoded .= '\\' . chr( $byte ); } elseif ( $byte < 32 || $byte > 126 ) { $encoded .= sprintf( '\\%03o', $byte ); } else { $encoded .= chr( $byte ); } } return $encoded; } private function codepoint_to_byte( int $codepoint ): ?int { if ( isset( self::CUSTOM_UNICODE_TO_BYTE[ $codepoint ] ) ) { return self::CUSTOM_UNICODE_TO_BYTE[ $codepoint ]; } if ( $codepoint >= 0x20 && $codepoint <= 0x7E ) { return $codepoint; } if ( isset( self::CP1252_SPECIALS[ $codepoint ] ) ) { return self::CP1252_SPECIALS[ $codepoint ]; } if ( $codepoint >= 0x00A0 && $codepoint <= 0x00FF ) { return $codepoint; } return null; } /** @return int[] */ private function utf8_codepoints( string $text ): array { $unpacked = unpack( 'C*', $text ); $bytes = array_values( false !== $unpacked ? $unpacked : array() ); $result = array(); $count = count( $bytes ); for ( $i = 0; $i < $count; ++$i ) { $b1 = $bytes[ $i ]; if ( $b1 < 0x80 ) { $result[] = $b1; continue; } if ( ( $b1 & 0xE0 ) === 0xC0 && isset( $bytes[ $i + 1 ] ) ) { $result[] = ( ( $b1 & 0x1F ) << 6 ) | ( $bytes[ ++$i ] & 0x3F ); continue; } if ( ( $b1 & 0xF0 ) === 0xE0 && isset( $bytes[ $i + 2 ] ) ) { $b2 = $bytes[ ++$i ]; $b3 = $bytes[ ++$i ]; $result[] = ( ( $b1 & 0x0F ) << 12 ) | ( ( $b2 & 0x3F ) << 6 ) | ( $b3 & 0x3F ); continue; } if ( ( $b1 & 0xF8 ) === 0xF0 && isset( $bytes[ $i + 3 ] ) ) { $b2 = $bytes[ ++$i ]; $b3 = $bytes[ ++$i ]; $b4 = $bytes[ ++$i ]; $result[] = ( ( $b1 & 0x07 ) << 18 ) | ( ( $b2 & 0x3F ) << 12 ) | ( ( $b3 & 0x3F ) << 6 ) | ( $b4 & 0x3F ); continue; } throw new \RuntimeException( 'Invalid UTF-8 input in Basic PDF data.' ); } return $result; } /** @param string[] $page_streams */ private function build_pdf( array $page_streams, BasicOrderDocument $document ): string { $objects = array(); $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; $objects[3] = '<< /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [1 /Cacute /cacute /Ccaron /ccaron /Dcroat /dcroat] >>'; $objects[4] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding 3 0 R >>'; $objects[5] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding 3 0 R >>'; $kids = array(); foreach ( $page_streams as $index => $stream ) { $content_id = 6 + ( $index * 2 ); $page_id = 7 + ( $index * 2 ); $kids[] = $page_id . ' 0 R'; $objects[ $content_id ] = '<< /Length ' . strlen( $stream ) . ">>\nstream\n" . $stream . "\nendstream"; $objects[ $page_id ] = sprintf( '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2F %.2F] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents %d 0 R >>', self::PAGE_WIDTH, self::PAGE_HEIGHT, $content_id ); } $objects[2] = '<< /Type /Pages /Kids [' . implode( ' ', $kids ) . '] /Count ' . count( $kids ) . ' >>'; ksort( $objects ); $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; $offsets = array( 0 => 0 ); foreach ( $objects as $id => $body ) { $offsets[ $id ] = strlen( $pdf ); $pdf .= $id . " 0 obj\n" . $body . "\nendobj\n"; } $xref_offset = strlen( $pdf ); $max_id = max( array_keys( $objects ) ); $pdf .= "xref\n0 " . ( $max_id + 1 ) . "\n"; $pdf .= "0000000000 65535 f \n"; for ( $id = 1; $id <= $max_id; ++$id ) { $offset = $offsets[ $id ] ?? 0; $pdf .= sprintf( "%010d 00000 n \n", $offset ); } $pdf .= "trailer\n<< /Size " . ( $max_id + 1 ) . " /Root 1 0 R >>\nstartxref\n" . $xref_offset . "\n%%EOF\n"; return $pdf; } }