<?php
/**
 * FS_PDF - Minimal PDF 1.4 Generator
 * 
 * Produces clean PDF files with no /OpenAction, /JS, /AcroForm or other
 * structures that email scanners (Gmail) flag as suspicious.
 * 
 * Uses only PDF core fonts (Helvetica, Times, Courier) - no external font files needed.
 * Supports: text cells, multi-line text, lines, rectangles, images (JPEG/PNG), colors, basic HTML.
 *
 * @package Freelance_Studio
 * @since 7.1.0
 */

if (!defined('ABSPATH')) {
    exit;
}

class FS_PDF {

    // Page dimensions (A4 in points: 595.28 x 841.89)
    private $page_width = 595.28;
    private $page_height = 841.89;
    
    // Margins (in points, converted from mm)
    private $margin_left = 56.69;   // 20mm
    private $margin_top = 56.69;    // 20mm
    private $margin_right = 56.69;  // 20mm
    private $margin_bottom = 56.69; // 20mm
    
    // Current position
    private $x = 0;
    private $y = 0;
    
    // Font state
    private $current_font = 'Helvetica';
    private $current_font_size = 10;
    private $current_font_style = '';  // '', 'B', 'I', 'BI'
    
    // Color state (RGB 0-1)
    private $text_color = '0 0 0';
    private $fill_color = '1 1 1';
    private $draw_color = '0 0 0';
    
    // Auto page break
    private $auto_page_break = true;
    private $page_break_trigger = 0;
    
    // PDF internals
    private $pages = array();
    private $current_page = -1;
    private $objects = array();
    private $object_count = 0;
    private $images = array();
    private $page_fonts = array();  // Track fonts per page
    private $all_fonts = array();   // All fonts used
    private $buffer = '';
    
    // Core font metrics (character widths for Helvetica at 1pt)
    private $font_widths = array();
    
    // Document info
    private $title = '';
    private $author = '';
    private $creator = 'Freelance Studio';

    /**
     * Constructor
     *
     * @param string $orientation 'P' portrait, 'L' landscape
     * @param string $unit        'mm' millimeters (default)
     * @param string $format      'A4' (default), 'Letter'
     */
    public function __construct($orientation = 'P', $unit = 'mm', $format = 'A4') {
        // Set page size
        if ($format === 'Letter') {
            $this->page_width = 612;
            $this->page_height = 792;
        }
        
        if (strtoupper($orientation) === 'L') {
            $tmp = $this->page_width;
            $this->page_width = $this->page_height;
            $this->page_height = $tmp;
        }
        
        // Initialize position
        $this->x = $this->margin_left;
        $this->y = $this->margin_top;
        $this->page_break_trigger = $this->page_height - $this->margin_bottom;
        
        // Load core font widths
        $this->_load_font_widths();
    }

    /**
     * Set document margins
     *
     * @param float $left   Left margin in mm
     * @param float $top    Top margin in mm
     * @param float $right  Right margin in mm
     * @param float $bottom Bottom margin in mm (optional, defaults to top)
     */
    public function SetMargins($left, $top, $right, $bottom = null) {
        $this->margin_left = $left * 2.835;
        $this->margin_top = $top * 2.835;
        $this->margin_right = $right * 2.835;
        $this->margin_bottom = ($bottom !== null ? $bottom : $top) * 2.835;
        $this->page_break_trigger = $this->page_height - $this->margin_bottom;
    }

    /**
     * Set auto page break
     */
    public function SetAutoPageBreak($auto, $margin_mm = 0) {
        $this->auto_page_break = $auto;
        if ($margin_mm > 0) {
            $this->margin_bottom = $margin_mm * 2.835;
            $this->page_break_trigger = $this->page_height - $this->margin_bottom;
        }
    }

    /**
     * Set document title
     */
    public function SetTitle($title) {
        $this->title = $this->_utf8_to_winAnsi($title);
    }

    /**
     * Set document author
     */
    public function SetAuthor($author) {
        $this->author = $this->_utf8_to_winAnsi($author);
    }

    /**
     * Set document creator
     */
    public function SetCreator($creator) {
        $this->creator = $this->_utf8_to_winAnsi($creator);
    }

    /**
     * Add a new page
     */
    public function AddPage() {
        $this->current_page++;
        $this->pages[$this->current_page] = '';
        $this->page_fonts[$this->current_page] = array();
        $this->x = $this->margin_left;
        $this->y = $this->margin_top;
    }

    /**
     * Set font
     *
     * @param string $family 'Helvetica', 'Times', 'Courier'
     * @param string $style  '', 'B', 'I', 'BI'
     * @param float  $size   Font size in points
     */
    public function SetFont($family, $style = '', $size = 0) {
        if ($family) {
            $this->current_font = $family;
        }
        $this->current_font_style = strtoupper($style);
        if ($size > 0) {
            $this->current_font_size = $size;
        }
        
        // Track font usage
        $font_key = $this->_get_font_key();
        $this->all_fonts[$font_key] = true;
        if ($this->current_page >= 0) {
            $this->page_fonts[$this->current_page][$font_key] = true;
        }
    }

    /**
     * Set font size only
     */
    public function SetFontSize($size) {
        $this->current_font_size = $size;
    }

    /**
     * Set text color (RGB 0-255)
     */
    public function SetTextColor($r, $g = 0, $b = 0) {
        $this->text_color = sprintf('%.3f %.3f %.3f', $r / 255, $g / 255, $b / 255);
    }

    /**
     * Set fill color (RGB 0-255)
     */
    public function SetFillColor($r, $g = 0, $b = 0) {
        $this->fill_color = sprintf('%.3f %.3f %.3f', $r / 255, $g / 255, $b / 255);
    }

    /**
     * Set draw/line color (RGB 0-255)
     */
    public function SetDrawColor($r, $g = 0, $b = 0) {
        $this->draw_color = sprintf('%.3f %.3f %.3f', $r / 255, $g / 255, $b / 255);
    }

    /**
     * Set line width
     *
     * @param float $width Line width in mm
     */
    public function SetLineWidth($width) {
        $w = $width * 2.835;
        $this->_out(sprintf('%.2f w', $w));
    }

    /**
     * Get current X position in mm
     */
    public function GetX() {
        return $this->x / 2.835;
    }

    /**
     * Get current Y position in mm
     */
    public function GetY() {
        return $this->y / 2.835;
    }

    /**
     * Set X position in mm
     */
    public function SetX($x) {
        $this->x = $x * 2.835;
    }

    /**
     * Set Y position in mm
     */
    public function SetY($y) {
        $this->y = $y * 2.835;
        $this->x = $this->margin_left;
    }

    /**
     * Set X and Y position in mm
     */
    public function SetXY($x, $y) {
        $this->x = $x * 2.835;
        $this->y = $y * 2.835;
    }

    /**
     * Get usable page width in mm
     */
    public function GetPageWidth() {
        return ($this->page_width - $this->margin_left - $this->margin_right) / 2.835;
    }

    /**
     * Get full page width in mm
     */
    public function GetFullPageWidth() {
        return $this->page_width / 2.835;
    }

    /**
     * Get string width in mm
     */
    public function GetStringWidth($text) {
        $text = (string)$text;
        $text = $this->_utf8_to_winAnsi($text);
        $font_key = $this->_get_base_font();
        $widths = $this->_get_char_widths($font_key);
        $w = 0;
        $len = strlen($text);
        for ($i = 0; $i < $len; $i++) {
            $c = ord($text[$i]);
            $w += isset($widths[$c]) ? $widths[$c] : 600;
        }
        return $w * $this->current_font_size / 1000 / 2.835;
    }

    /**
     * Draw a cell (text box)
     *
     * @param float  $w      Width in mm (0 = rest of page)
     * @param float  $h      Height in mm
     * @param string $txt    Text content
     * @param mixed  $border 0, 1, or string of sides 'LTRB'
     * @param int    $ln     Position after: 0=right, 1=next line, 2=below
     * @param string $align  'L', 'C', 'R'
     * @param bool   $fill   Fill background
     */
    public function Cell($w, $h, $txt = '', $border = 0, $ln = 0, $align = 'L', $fill = false) {
        $txt = (string)$txt;
        
        $w_pt = $w * 2.835;
        $h_pt = $h * 2.835;
        
        if ($w_pt == 0) {
            $w_pt = $this->page_width - $this->margin_right - $this->x;
        }
        
        // Check for page break
        if ($this->auto_page_break && ($this->y + $h_pt > $this->page_break_trigger) && $this->current_page >= 0) {
            $this->AddPage();
        }
        
        $x = $this->x;
        $y = $this->y;
        
        // Fill
        if ($fill) {
            $this->_out(sprintf('%.2f %.2f %.2f rg', ...explode(' ', $this->fill_color)));
            $this->_out(sprintf('%.2f %.2f %.2f %.2f re f', $x, $this->page_height - $y - $h_pt, $w_pt, $h_pt));
        }
        
        // Border
        if ($border) {
            $this->_out(sprintf('%.2f %.2f %.2f RG', ...explode(' ', $this->draw_color)));
            if ($border === 1 || $border === '1') {
                $this->_out(sprintf('%.2f %.2f %.2f %.2f re S', $x, $this->page_height - $y - $h_pt, $w_pt, $h_pt));
            } else {
                $border = strtoupper((string)$border);
                $bx = $x;
                $by = $this->page_height - $y;
                $bx2 = $x + $w_pt;
                $by2 = $this->page_height - $y - $h_pt;
                if (strpos($border, 'T') !== false) {
                    $this->_out(sprintf('%.2f %.2f m %.2f %.2f l S', $bx, $by, $bx2, $by));
                }
                if (strpos($border, 'B') !== false) {
                    $this->_out(sprintf('%.2f %.2f m %.2f %.2f l S', $bx, $by2, $bx2, $by2));
                }
                if (strpos($border, 'L') !== false) {
                    $this->_out(sprintf('%.2f %.2f m %.2f %.2f l S', $bx, $by, $bx, $by2));
                }
                if (strpos($border, 'R') !== false) {
                    $this->_out(sprintf('%.2f %.2f m %.2f %.2f l S', $bx2, $by, $bx2, $by2));
                }
            }
        }
        
        // Text
        if ($txt !== '') {
            $txt = $this->_utf8_to_winAnsi($txt);
            $font_key = $this->_get_font_key();
            $this->_out(sprintf('BT /F%s %.2f Tf', $font_key, $this->current_font_size));
            $this->_out(sprintf('%.3f %.3f %.3f rg', ...explode(' ', $this->text_color)));
            
            // Calculate text position
            $txt_width = $this->GetStringWidth($txt) * 2.835;
            $padding = 2; // small padding
            
            if ($align === 'R') {
                $tx = $x + $w_pt - $txt_width - $padding;
            } elseif ($align === 'C') {
                $tx = $x + ($w_pt - $txt_width) / 2;
            } else {
                $tx = $x + $padding;
            }
            
            $ty = $this->page_height - $y - ($h_pt / 2) - ($this->current_font_size * 0.3);
            
            $this->_out(sprintf('%.2f %.2f Td', $tx, $ty));
            $this->_out('(' . $this->_escape($txt) . ') Tj');
            $this->_out('ET');
        }
        
        // Update position
        if ($ln === 0) {
            $this->x = $x + $w_pt;
        } elseif ($ln === 1) {
            $this->x = $this->margin_left;
            $this->y = $y + $h_pt;
        } else {
            $this->y = $y + $h_pt;
        }
    }

    /**
     * Multi-line cell with auto word wrap
     *
     * @param float  $w      Width in mm (0 = rest of page)
     * @param float  $h      Line height in mm
     * @param string $txt    Text content
     * @param mixed  $border Border
     * @param string $align  Alignment
     * @param bool   $fill   Fill background
     * @return int Number of lines used
     */
    public function MultiCell($w, $h, $txt = '', $border = 0, $align = 'L', $fill = false) {
        $txt = (string)$txt;
        $txt = $this->_utf8_to_winAnsi($txt);
        
        $w_mm = $w ?: $this->GetPageWidth();
        $lines = $this->_wrap_text($txt, $w_mm);
        $num_lines = count($lines);
        $start_y = $this->y;
        
        foreach ($lines as $i => $line) {
            $b = 0;
            if ($border) {
                if ($border === 1 || $border === '1') {
                    $b = '';
                    if ($i === 0) $b .= 'T';
                    if ($i === $num_lines - 1) $b .= 'B';
                    $b .= 'LR';
                } else {
                    $b = $border;
                }
            }
            $this->Cell($w_mm, $h, $line, $b, 2, $align, $fill);
        }
        
        // Move to next line
        $this->x = $this->margin_left;
        
        return $num_lines;
    }

    /**
     * Draw a line
     *
     * @param float $x1 Start X in mm
     * @param float $y1 Start Y in mm
     * @param float $x2 End X in mm
     * @param float $y2 End Y in mm
     */
    public function Line($x1, $y1, $x2, $y2) {
        $this->_out(sprintf('%.2f %.2f %.2f RG', ...explode(' ', $this->draw_color)));
        $this->_out(sprintf('%.2f %.2f m %.2f %.2f l S',
            $x1 * 2.835, $this->page_height - $y1 * 2.835,
            $x2 * 2.835, $this->page_height - $y2 * 2.835
        ));
    }

    /**
     * Draw a filled rectangle
     *
     * @param float  $x X in mm
     * @param float  $y Y in mm
     * @param float  $w Width in mm
     * @param float  $h Height in mm
     * @param string $style 'F' fill, 'D' draw, 'DF' both
     */
    public function Rect($x, $y, $w, $h, $style = 'D') {
        $x_pt = $x * 2.835;
        $y_pt = $this->page_height - $y * 2.835;
        $w_pt = $w * 2.835;
        $h_pt = $h * 2.835;
        
        if (strpos($style, 'F') !== false) {
            $this->_out(sprintf('%.2f %.2f %.2f rg', ...explode(' ', $this->fill_color)));
        }
        if (strpos($style, 'D') !== false) {
            $this->_out(sprintf('%.2f %.2f %.2f RG', ...explode(' ', $this->draw_color)));
        }
        
        $op = 'S'; // stroke
        if ($style === 'F') $op = 'f';
        elseif ($style === 'DF' || $style === 'FD') $op = 'B';
        
        $this->_out(sprintf('%.2f %.2f %.2f %.2f re %s', $x_pt, $y_pt - $h_pt, $w_pt, $h_pt, $op));
    }

    /**
     * Insert a line break / vertical space
     *
     * @param float $h Height in mm (0 = current font height)
     */
    public function Ln($h = 0) {
        $this->x = $this->margin_left;
        if ($h > 0) {
            $this->y += $h * 2.835;
        } else {
            $this->y += $this->current_font_size * 1.2;
        }
    }

    /**
     * Insert an image (JPEG or PNG)
     *
     * @param string $file   File path
     * @param float  $x      X position in mm
     * @param float  $y      Y position in mm
     * @param float  $w      Width in mm (0 = auto)
     * @param float  $h      Height in mm (0 = auto)
     * @return bool Success
     */
    public function Image($file, $x = null, $y = null, $w = 0, $h = 0) {
        if (!file_exists($file)) {
            return false;
        }
        
        $info = $this->_parse_image($file);
        if (!$info) {
            return false;
        }
        
        // Calculate dimensions
        if ($w == 0 && $h == 0) {
            $w = $info['w'] / 96 * 25.4; // pixels to mm at 96dpi
            $h = $info['h'] / 96 * 25.4;
        } elseif ($w == 0) {
            $w = $h * $info['w'] / $info['h'];
        } elseif ($h == 0) {
            $h = $w * $info['h'] / $info['w'];
        }
        
        if ($x === null) $x = $this->GetX();
        if ($y === null) $y = $this->GetY();
        
        $img_key = 'Im' . count($this->images);
        $this->images[$img_key] = $info;
        
        // Place image on page
        $w_pt = $w * 2.835;
        $h_pt = $h * 2.835;
        $x_pt = $x * 2.835;
        $y_pt = $this->page_height - $y * 2.835 - $h_pt;
        
        $this->_out(sprintf('q %.2f 0 0 %.2f %.2f %.2f cm /%s Do Q', $w_pt, $h_pt, $x_pt, $y_pt, $img_key));
        
        return true;
    }

    /**
     * Insert an image from base64 data
     *
     * @param string $base64_data Base64 encoded image (with or without data URI prefix)
     * @param float  $x           X in mm
     * @param float  $y           Y in mm
     * @param float  $w           Width in mm
     * @param float  $h           Height in mm (0=auto)
     * @return bool Success
     */
    public function ImageFromBase64($base64_data, $x = null, $y = null, $w = 0, $h = 0) {
        if (empty($base64_data)) {
            return false;
        }
        
        // Extract base64 data
        $data = $base64_data;
        if (strpos($base64_data, 'data:image') === 0) {
            $comma_pos = strpos($base64_data, ',');
            if ($comma_pos !== false) {
                $data = substr($base64_data, $comma_pos + 1);
            }
        }
        
        // Decode and write to temp file
        $image_data = base64_decode($data);
        if ($image_data === false) {
            return false;
        }
        
        $tmp_file = tempnam(sys_get_temp_dir(), 'fspdf_');
        file_put_contents($tmp_file, $image_data);
        
        $result = $this->Image($tmp_file, $x, $y, $w, $h);
        wp_delete_file($tmp_file);
        
        return $result;
    }

    /**
     * Write basic HTML content as formatted text cells.
     * Supports: <b>, <i>, <strong>, <em>, <br>, <p>, <h1>-<h3>, <ul>, <ol>, <li>, <hr>
     * Does NOT support: tables, CSS, complex layouts (use Cell/MultiCell for those)
     *
     * @param string $html   HTML content
     * @param float  $width  Width in mm (0 = full page width)
     */
    public function WriteHTML($html, $width = 0) {
        if (empty($html)) return;
        
        $w = $width ?: $this->GetPageWidth();
        
        // Clean up HTML
        $html = str_replace(array("\r\n", "\r"), "\n", $html);
        $html = preg_replace('/\n\s+/', ' ', $html); // collapse whitespace
        
        // Convert common entities
        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
        
        // Process tags
        $html = preg_replace('/<br\s*\/?>/i', "\n", $html);
        $html = preg_replace('/<\/p>/i', "\n\n", $html);
        $html = preg_replace('/<p[^>]*>/i', '', $html);
        $html = preg_replace('/<\/div>/i', "\n", $html);
        $html = preg_replace('/<div[^>]*>/i', '', $html);
        
        // Handle headings
        $html = preg_replace_callback('/<h([1-3])[^>]*>(.*?)<\/h[1-3]>/si', function($m) {
            return '{{H' . $m[1] . '}}' . wp_strip_all_tags($m[2]) . '{{/H}}' . "\n";
        }, $html);
        
        // Handle lists
        $list_counter = 0;
        $html = preg_replace('/<ol[^>]*>/i', '{{OL_START}}', $html);
        $html = preg_replace('/<\/ol>/i', '{{OL_END}}', $html);
        $html = preg_replace('/<ul[^>]*>/i', '{{UL_START}}', $html);
        $html = preg_replace('/<\/ul>/i', '{{UL_END}}', $html);
        $html = preg_replace('/<li[^>]*>/i', '{{LI}}', $html);
        $html = preg_replace('/<\/li>/i', "\n", $html);
        
        // Handle horizontal rule
        $html = preg_replace('/<hr[^>]*>/i', '{{HR}}', $html);
        
        // Bold and italic markers
        $html = preg_replace('/<(b|strong)[^>]*>/i', '{{B}}', $html);
        $html = preg_replace('/<\/(b|strong)>/i', '{{/B}}', $html);
        $html = preg_replace('/<(i|em)[^>]*>/i', '{{I}}', $html);
        $html = preg_replace('/<\/(i|em)>/i', '{{/I}}', $html);
        
        // Strip remaining tags
        $html = wp_strip_all_tags($html);
        
        // Decode any remaining entities
        $html = html_entity_decode($html, ENT_QUOTES, 'UTF-8');
        
        // Save font state
        $orig_font = $this->current_font;
        $orig_style = $this->current_font_style;
        $orig_size = $this->current_font_size;
        
        $in_ol = false;
        $in_ul = false;
        $ol_counter = 0;
        $line_height = 4.5;
        
        // Process line by line
        $lines = explode("\n", $html);
        foreach ($lines as $line) {
            $line = trim($line);
            if (empty($line)) {
                $this->Ln(2);
                continue;
            }
            
            // Heading markers
            if (preg_match('/^\{\{H([1-3])\}\}(.*?)\{\{\/H\}\}$/', $line, $m)) {
                $sizes = array(1 => 16, 2 => 13, 3 => 11);
                $this->Ln(3);
                $this->SetFont($this->current_font, 'B', $sizes[(int)$m[1]]);
                $this->MultiCell($w, 5.5, $m[2], 0, 'L');
                $this->SetFont($orig_font, $orig_style, $orig_size);
                $this->Ln(1);
                continue;
            }
            
            // HR
            if (strpos($line, '{{HR}}') !== false) {
                $y_mm = $this->GetY();
                $this->SetDrawColor(200, 200, 200);
                $this->Line($this->GetX(), $y_mm, $this->GetX() + $w, $y_mm);
                $this->SetDrawColor(0, 0, 0);
                $this->Ln(3);
                continue;
            }
            
            // List controls
            if (strpos($line, '{{OL_START}}') !== false) { $in_ol = true; $ol_counter = 0; continue; }
            if (strpos($line, '{{OL_END}}') !== false) { $in_ol = false; $this->Ln(1); continue; }
            if (strpos($line, '{{UL_START}}') !== false) { $in_ul = true; continue; }
            if (strpos($line, '{{UL_END}}') !== false) { $in_ul = false; $this->Ln(1); continue; }
            
            // List item
            if (strpos($line, '{{LI}}') !== false) {
                $line = str_replace('{{LI}}', '', $line);
                $line = $this->_process_inline_styles($line);
                $indent = 5; // mm
                if ($in_ol) {
                    $ol_counter++;
                    $prefix = $ol_counter . '. ';
                } else {
                    $prefix = chr(149) . ' '; // bullet
                }
                $save_x = $this->x;
                $this->SetX($this->GetX() + $indent);
                $this->MultiCell($w - $indent, $line_height, $prefix . $line, 0, 'L');
                continue;
            }
            
            // Regular text with inline bold/italic
            $line = $this->_process_inline_styles($line);
            
            // Check if line has mixed styles (simplified: render as plain for now)
            $clean = str_replace(array('{{B}}', '{{/B}}', '{{I}}', '{{/I}}'), '', $line);
            
            // Detect if entire line is bold or italic
            if (preg_match('/^\{\{B\}\}(.*)\{\{\/B\}\}$/', $line, $m)) {
                $this->SetFont($this->current_font, 'B', $this->current_font_size);
                $this->MultiCell($w, $line_height, $m[1], 0, 'L');
                $this->SetFont($orig_font, $orig_style, $orig_size);
            } elseif (preg_match('/^\{\{I\}\}(.*)\{\{\/I\}\}$/', $line, $m)) {
                $this->SetFont($this->current_font, 'I', $this->current_font_size);
                $this->MultiCell($w, $line_height, $m[1], 0, 'L');
                $this->SetFont($orig_font, $orig_style, $orig_size);
            } else {
                // Strip any remaining markers and render plain
                $this->MultiCell($w, $line_height, $clean, 0, 'L');
            }
        }
        
        // Restore font
        $this->SetFont($orig_font, $orig_style, $orig_size);
    }

    /**
     * Output the PDF
     *
     * @param string $filename Filename
     * @param string $dest     'I' inline, 'D' download, 'F' save to file, 'S' return string
     * @return string|null PDF content for 'S' mode
     */
    public function Output($filename = 'document.pdf', $dest = 'I') {
        $pdf = $this->_build_pdf();
        
        if ($dest === 'S') {
            return $pdf;
        }
        
        if ($dest === 'F') {
            file_put_contents($filename, $pdf);
            return null;
        }
        
        // Clear ALL output buffers
        $max_levels = 20;
        while (ob_get_level() > 0 && $max_levels-- > 0) {
            ob_end_clean();
        }
        
        if (!headers_sent()) {
            header('Content-Type: application/pdf');
            header('Content-Length: ' . strlen($pdf));
            
            if ($dest === 'D') {
                header('Content-Disposition: attachment; filename="' . $filename . '"');
            } else {
                header('Content-Disposition: inline; filename="' . $filename . '"');
            }
            
            header('Cache-Control: private, max-age=0, must-revalidate');
            header('Pragma: public');
        }
        
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Raw PDF binary data, not HTML
        echo $pdf;
        return null;
    }

    // ==================== PRIVATE METHODS ====================

    /**
     * Write content to current page stream
     */
    private function _out($s) {
        if ($this->current_page >= 0) {
            $this->pages[$this->current_page] .= $s . "\n";
        }
    }

    /**
     * Get PDF font key for current style
     */
    private function _get_font_key() {
        $base = $this->_get_base_font();
        $key = str_replace(array('-Bold', '-Oblique', '-BoldOblique', '-Italic', '-BoldItalic'), '', $base);
        // Create a short unique key
        $map = array(
            'Helvetica' => '1',
            'Helvetica-Bold' => '2',
            'Helvetica-Oblique' => '3',
            'Helvetica-BoldOblique' => '4',
            'Times-Roman' => '5',
            'Times-Bold' => '6',
            'Times-Italic' => '7',
            'Times-BoldItalic' => '8',
            'Courier' => '9',
            'Courier-Bold' => '10',
            'Courier-Oblique' => '11',
            'Courier-BoldOblique' => '12',
        );
        return $map[$base] ?? '1';
    }

    /**
     * Get PDF base font name
     */
    private function _get_base_font() {
        $family = $this->current_font;
        $style = $this->current_font_style;
        
        $family_lower = strtolower($family);
        
        if (strpos($family_lower, 'times') !== false) {
            if ($style === 'BI') return 'Times-BoldItalic';
            if ($style === 'B') return 'Times-Bold';
            if ($style === 'I') return 'Times-Italic';
            return 'Times-Roman';
        }
        
        if (strpos($family_lower, 'courier') !== false) {
            if ($style === 'BI') return 'Courier-BoldOblique';
            if ($style === 'B') return 'Courier-Bold';
            if ($style === 'I') return 'Courier-Oblique';
            return 'Courier';
        }
        
        // Default: Helvetica
        if ($style === 'BI') return 'Helvetica-BoldOblique';
        if ($style === 'B') return 'Helvetica-Bold';
        if ($style === 'I') return 'Helvetica-Oblique';
        return 'Helvetica';
    }

    /**
     * Escape special PDF characters
     */
    private function _escape($s) {
        return str_replace(
            array('\\', '(', ')'),
            array('\\\\', '\\(', '\\)'),
            $s
        );
    }

    /**
     * Convert UTF-8 string to WinAnsi (Windows-1252)
     * Maps common Unicode characters to Latin-1 equivalents
     */
    private function _utf8_to_winAnsi($str) {
        if (empty($str)) return $str;
        
        // Common Unicode → WinAnsi/ASCII replacements
        $map = array(
            // Smart quotes
            "\xE2\x80\x98" => "'",   // left single quote
            "\xE2\x80\x99" => "'",   // right single quote
            "\xE2\x80\x9C" => '"',   // left double quote
            "\xE2\x80\x9D" => '"',   // right double quote
            // Dashes
            "\xE2\x80\x93" => '-',   // en dash
            "\xE2\x80\x94" => '--',  // em dash
            // Spaces
            "\xC2\xA0" => ' ',       // non-breaking space
            "\xE2\x80\xAF" => ' ',   // narrow no-break space
            // Symbols
            "\xE2\x80\xA2" => chr(149), // bullet
            "\xE2\x80\xA6" => '...',    // ellipsis
            "\xE2\x84\xA2" => '(TM)',   // trademark
            "\xC2\xA9" => chr(169),      // copyright
            "\xC2\xAE" => chr(174),      // registered
            "\xC2\xB0" => chr(176),      // degree
            // Arrows
            "\xE2\x86\x90" => '<-',
            "\xE2\x86\x92" => '->',
            // Common emoji/symbols to text
            "\xE2\x9C\x85" => '*',     // white heavy check mark
            "\xE2\x9C\x93" => '*',     // check mark
            "\xE2\x9C\x94" => '*',     // heavy check mark
            "\xE2\x9C\x97" => 'x',     // ballot x
            "\xE2\x9C\x98" => 'x',     // heavy ballot x
            "\xE2\x9D\x8C" => 'x',     // cross mark
            "\xE2\x9C\xA8" => '*',     // sparkles
            "\xE2\xAD\x90" => '*',     // star
            "\xF0\x9F\x92\xB0" => '$',  // money bag
            "\xF0\x9F\x93\x9E" => 'Tel:', // telephone
            "\xF0\x9F\x93\xA7" => 'Email:', // envelope
            "\xF0\x9F\x8F\xA0" => '',  // house
            "\xF0\x9F\x92\xBC" => '',  // briefcase
        );
        
        $str = strtr($str, $map);
        
        // Convert remaining UTF-8 to WinAnsi where possible, replace unknown with *
        $result = '';
        $len = strlen($str);
        $i = 0;
        while ($i < $len) {
            $byte = ord($str[$i]);
            
            if ($byte < 128) {
                // ASCII
                $result .= $str[$i];
                $i++;
            } elseif ($byte < 192) {
                // Continuation byte (shouldn't be first) - skip
                $i++;
            } elseif ($byte < 224) {
                // 2-byte sequence
                if ($i + 1 < $len) {
                    $cp = (($byte & 0x1F) << 6) | (ord($str[$i + 1]) & 0x3F);
                    if ($cp >= 128 && $cp <= 255) {
                        $result .= chr($cp); // Direct WinAnsi mapping
                    } else {
                        $result .= '*';
                    }
                }
                $i += 2;
            } elseif ($byte < 240) {
                // 3-byte sequence - already handled by map above, unknown → *
                $result .= '*';
                $i += 3;
            } else {
                // 4-byte sequence (emoji etc) - already handled by map above
                $result .= '*';
                $i += 4;
            }
        }
        
        return $result;
    }

    /**
     * Process inline style markers in text (simplify nested bold/italic for output)
     */
    private function _process_inline_styles($text) {
        // Already has markers - return as-is for the calling code to handle
        return $text;
    }

    /**
     * Word-wrap text to fit within given width
     *
     * @param string $text     Text (already WinAnsi converted)
     * @param float  $max_w_mm Maximum width in mm
     * @return array Array of lines
     */
    private function _wrap_text($text, $max_w_mm) {
        $lines = array();
        $paragraphs = explode("\n", $text);
        
        foreach ($paragraphs as $paragraph) {
            if ($paragraph === '') {
                $lines[] = '';
                continue;
            }
            
            $words = explode(' ', $paragraph);
            $current_line = '';
            
            foreach ($words as $word) {
                if ($current_line === '') {
                    $test_line = $word;
                } else {
                    $test_line = $current_line . ' ' . $word;
                }
                
                if ($this->GetStringWidth($test_line) > $max_w_mm && $current_line !== '') {
                    $lines[] = $current_line;
                    $current_line = $word;
                } else {
                    $current_line = $test_line;
                }
            }
            
            if ($current_line !== '') {
                $lines[] = $current_line;
            }
        }
        
        return $lines ?: array('');
    }

    /**
     * Parse an image file and return image data for PDF embedding
     *
     * @param string $file File path
     * @return array|false Image info or false
     */
    private function _parse_image($file) {
        $info = @getimagesize($file);
        if (!$info) return false;
        
        $type = $info[2]; // IMAGETYPE constant
        
        if ($type === IMAGETYPE_JPEG) {
            return $this->_parse_jpeg($file, $info);
        } elseif ($type === IMAGETYPE_PNG) {
            return $this->_parse_png($file, $info);
        }
        
        return false;
    }

    /**
     * Parse JPEG image
     */
    private function _parse_jpeg($file, $info) {
        return array(
            'w' => $info[0],
            'h' => $info[1],
            'cs' => ($info['channels'] ?? 3) == 4 ? 'DeviceCMYK' : 'DeviceRGB',
            'bpc' => $info['bits'] ?? 8,
            'data' => file_get_contents($file),
            'filter' => 'DCTDecode',
        );
    }

    /**
     * Parse PNG image - convert to raw pixels via GD
     */
    private function _parse_png($file, $info) {
        $img = @imagecreatefrompng($file);
        if (!$img) return false;
        
        $w = $info[0];
        $h = $info[1];
        
        // Create a new image with white background (handle transparency)
        $new_img = imagecreatetruecolor($w, $h);
        $white = imagecolorallocate($new_img, 255, 255, 255);
        imagefill($new_img, 0, 0, $white);
        imagealphablending($new_img, true);
        imagecopy($new_img, $img, 0, 0, 0, 0, $w, $h);
        
        // Output as JPEG for simplicity (avoids PNG filter complexity in PDF)
        ob_start();
        imagejpeg($new_img, null, 90);
        $data = ob_get_clean();
        
        imagedestroy($img);
        imagedestroy($new_img);
        
        return array(
            'w' => $w,
            'h' => $h,
            'cs' => 'DeviceRGB',
            'bpc' => 8,
            'data' => $data,
            'filter' => 'DCTDecode',
        );
    }

    /**
     * Build the complete PDF binary
     * Uses a collect-then-assemble approach to avoid offset calculation bugs.
     */
    private function _build_pdf() {
        // Step 1: Collect all objects as strings, then assemble with correct offsets

        $all_base_fonts = array(
            '1' => 'Helvetica', '2' => 'Helvetica-Bold',
            '3' => 'Helvetica-Oblique', '4' => 'Helvetica-BoldOblique',
            '5' => 'Times-Roman', '6' => 'Times-Bold',
            '7' => 'Times-Italic', '8' => 'Times-BoldItalic',
            '9' => 'Courier', '10' => 'Courier-Bold',
            '11' => 'Courier-Oblique', '12' => 'Courier-BoldOblique',
        );

        // We'll assign object numbers first, then build strings

        // Obj 1: Catalog (references Pages obj 2)
        // Obj 2: Pages (needs page obj IDs - built last among objects)
        // Obj 3: Info

        $next_obj = 4;

        // Font objects
        $font_obj_ids = array();
        foreach ($this->all_fonts as $key => $used) {
            $font_obj_ids[$key] = $next_obj;
            $next_obj++;
        }

        // Image objects
        $image_obj_ids = array();
        foreach ($this->images as $img_key => $img) {
            $image_obj_ids[$img_key] = $next_obj;
            $next_obj++;
        }

        // Page content + page objects (2 objects per page)
        $page_content_obj_ids = array();
        $page_obj_ids = array();
        foreach ($this->pages as $page_num => $content) {
            $page_content_obj_ids[$page_num] = $next_obj;
            $next_obj++;
            $page_obj_ids[$page_num] = $next_obj;
            $next_obj++;
        }

        // Step 2: Build all object strings

        $objects = array(); // obj_num => string

        // Obj 1: Catalog
        $objects[1] = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";

        // Obj 2: Pages
        $kids_refs = array();
        foreach ($page_obj_ids as $poid) {
            $kids_refs[] = "$poid 0 R";
        }
        $objects[2] = "2 0 obj\n<< /Type /Pages /Kids [" . implode(' ', $kids_refs) . "] /Count " . count($page_obj_ids) . " >>\nendobj\n";

        // Obj 3: Info
        $info = "3 0 obj\n<< ";
        if ($this->title) $info .= '/Title (' . $this->_escape($this->title) . ') ';
        if ($this->author) $info .= '/Author (' . $this->_escape($this->author) . ') ';
        if ($this->creator) $info .= '/Creator (' . $this->_escape($this->creator) . ') ';
        $info .= '/Producer (Freelance Studio) ';
        $info .= '/CreationDate (D:' . gmdate('YmdHis') . ') ';
        $info .= ">>\nendobj\n";
        $objects[3] = $info;

        // Font objects
        foreach ($this->all_fonts as $key => $used) {
            $oid = $font_obj_ids[$key];
            $base_name = $all_base_fonts[$key] ?? 'Helvetica';
            $objects[$oid] = "$oid 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /$base_name /Encoding /WinAnsiEncoding >>\nendobj\n";
        }

        // Image objects
        foreach ($this->images as $img_key => $img) {
            $oid = $image_obj_ids[$img_key];
            $stream = $img['data'];
            $s = "$oid 0 obj\n";
            $s .= "<< /Type /XObject /Subtype /Image";
            $s .= " /Width " . $img['w'];
            $s .= " /Height " . $img['h'];
            $s .= " /ColorSpace /" . $img['cs'];
            $s .= " /BitsPerComponent " . $img['bpc'];
            $s .= " /Filter /" . $img['filter'];
            $s .= " /Length " . strlen($stream);
            $s .= " >>\n";
            $s .= "stream\n" . $stream . "\nendstream\n";
            $s .= "endobj\n";
            $objects[$oid] = $s;
        }

        // Page content streams and page objects
        foreach ($this->pages as $page_num => $content) {
            $c_oid = $page_content_obj_ids[$page_num];
            $p_oid = $page_obj_ids[$page_num];

            // Content stream
            $objects[$c_oid] = "$c_oid 0 obj\n<< /Length " . strlen($content) . " >>\nstream\n" . $content . "endstream\nendobj\n";

            // Resources
            $resources = '<< ';
            $fonts_str = '';
            $page_fk = isset($this->page_fonts[$page_num]) ? $this->page_fonts[$page_num] : $this->all_fonts;
            foreach ($page_fk as $fk => $used) {
                if (isset($font_obj_ids[$fk])) {
                    $fonts_str .= "/F$fk " . $font_obj_ids[$fk] . " 0 R ";
                }
            }
            if ($fonts_str) $resources .= "/Font << $fonts_str>> ";
            $imgs_str = '';
            foreach ($image_obj_ids as $ik => $ioid) {
                $imgs_str .= "/$ik $ioid 0 R ";
            }
            if ($imgs_str) $resources .= "/XObject << $imgs_str>> ";
            $resources .= '>>';

            $objects[$p_oid] = "$p_oid 0 obj\n<< /Type /Page /Parent 2 0 R"
                . " /MediaBox [0 0 " . sprintf('%.2f %.2f', $this->page_width, $this->page_height) . "]"
                . " /Contents $c_oid 0 R"
                . " /Resources $resources"
                . " >>\nendobj\n";
        }

        // Step 3: Assemble in order, recording exact offsets

        $buffer = "%PDF-1.4\n";
        $buffer .= "%" . chr(226) . chr(227) . chr(207) . chr(211) . "\n";

        $offsets = array();
        for ($i = 1; $i < $next_obj; $i++) {
            $offsets[$i] = strlen($buffer);
            $buffer .= $objects[$i];
        }

        // Cross-reference table
        $xref_offset = strlen($buffer);
        $buffer .= "xref\n";
        $buffer .= "0 $next_obj\n";
        $buffer .= "0000000000 65535 f \n";
        for ($i = 1; $i < $next_obj; $i++) {
            $buffer .= sprintf("%010d 00000 n \n", $offsets[$i]);
        }

        // Trailer
        $buffer .= "trailer\n";
        $buffer .= "<< /Size $next_obj /Root 1 0 R /Info 3 0 R >>\n";
        $buffer .= "startxref\n";
        $buffer .= "$xref_offset\n";
        $buffer .= "%%EOF\n";

        return $buffer;
    }

    /**
     * Load core font character widths (Helvetica)
     */
    private function _load_font_widths() {
        // Helvetica character widths at 1000 units per em
        $this->font_widths['Helvetica'] = array(
            0=>278, 1=>278, 2=>278, 3=>278, 4=>278, 5=>278, 6=>278, 7=>278,
            8=>278, 9=>278, 10=>278, 11=>278, 12=>278, 13=>278, 14=>278, 15=>278,
            16=>278, 17=>278, 18=>278, 19=>278, 20=>278, 21=>278, 22=>278, 23=>278,
            24=>278, 25=>278, 26=>278, 27=>278, 28=>278, 29=>278, 30=>278, 31=>278,
            32=>278, 33=>278, 34=>355, 35=>556, 36=>556, 37=>889, 38=>667, 39=>191,
            40=>333, 41=>333, 42=>389, 43=>584, 44=>278, 45=>333, 46=>278, 47=>278,
            48=>556, 49=>556, 50=>556, 51=>556, 52=>556, 53=>556, 54=>556, 55=>556,
            56=>556, 57=>556, 58=>278, 59=>278, 60=>584, 61=>584, 62=>584, 63=>556,
            64=>1015, 65=>667, 66=>667, 67=>722, 68=>722, 69=>667, 70=>611, 71=>778,
            72=>722, 73=>278, 74=>500, 75=>667, 76=>556, 77=>833, 78=>722, 79=>778,
            80=>667, 81=>778, 82=>722, 83=>667, 84=>611, 85=>722, 86=>667, 87=>944,
            88=>667, 89=>667, 90=>611, 91=>278, 92=>278, 93=>278, 94=>469, 95=>556,
            96=>333, 97=>556, 98=>556, 99=>500, 100=>556, 101=>556, 102=>278, 103=>556,
            104=>556, 105=>222, 106=>222, 107=>500, 108=>222, 109=>833, 110=>556, 111=>556,
            112=>556, 113=>556, 114=>333, 115=>500, 116=>278, 117=>556, 118=>500, 119=>722,
            120=>500, 121=>500, 122=>500, 123=>334, 124=>260, 125=>334, 126=>584, 127=>278,
            // Extended characters (128-255) - approximated
            128=>556, 129=>278, 130=>278, 131=>556, 132=>500, 133=>1000, 134=>556, 135=>556,
            136=>333, 137=>1000, 138=>667, 139=>333, 140=>1000, 141=>278, 142=>611, 143=>278,
            144=>278, 145=>222, 146=>222, 147=>333, 148=>333, 149=>350, 150=>556, 151=>1000,
            152=>333, 153=>1000, 154=>500, 155=>333, 156=>944, 157=>278, 158=>500, 159=>667,
            160=>278, 161=>333, 162=>556, 163=>556, 164=>556, 165=>556, 166=>260, 167=>556,
            168=>333, 169=>737, 170=>370, 171=>556, 172=>584, 173=>333, 174=>737, 175=>333,
            176=>400, 177=>584, 178=>333, 179=>333, 180=>333, 181=>556, 182=>537, 183=>278,
            184=>333, 185=>333, 186=>365, 187=>556, 188=>834, 189=>834, 190=>834, 191=>611,
            192=>667, 193=>667, 194=>667, 195=>667, 196=>667, 197=>667, 198=>1000, 199=>722,
            200=>667, 201=>667, 202=>667, 203=>667, 204=>278, 205=>278, 206=>278, 207=>278,
            208=>722, 209=>722, 210=>778, 211=>778, 212=>778, 213=>778, 214=>778, 215=>584,
            216=>778, 217=>722, 218=>722, 219=>722, 220=>722, 221=>667, 222=>667, 223=>611,
            224=>556, 225=>556, 226=>556, 227=>556, 228=>556, 229=>556, 230=>889, 231=>500,
            232=>556, 233=>556, 234=>556, 235=>556, 236=>278, 237=>278, 238=>278, 239=>278,
            240=>556, 241=>556, 242=>556, 243=>556, 244=>556, 245=>556, 246=>556, 247=>584,
            248=>611, 249=>556, 250=>556, 251=>556, 252=>556, 253=>500, 254=>556, 255=>500,
        );
        
        // Bold is ~5% wider on average, approximate
        $this->font_widths['Helvetica-Bold'] = $this->font_widths['Helvetica'];
        foreach ($this->font_widths['Helvetica-Bold'] as &$w) {
            $w = (int)($w * 1.05);
        }
        
        // Oblique same as regular
        $this->font_widths['Helvetica-Oblique'] = $this->font_widths['Helvetica'];
        $this->font_widths['Helvetica-BoldOblique'] = $this->font_widths['Helvetica-Bold'];
        
        // Times (slightly narrower than Helvetica on average)
        $this->font_widths['Times-Roman'] = $this->font_widths['Helvetica'];
        $this->font_widths['Times-Bold'] = $this->font_widths['Helvetica-Bold'];
        $this->font_widths['Times-Italic'] = $this->font_widths['Helvetica-Oblique'];
        $this->font_widths['Times-BoldItalic'] = $this->font_widths['Helvetica-BoldOblique'];
        
        // Courier (fixed width)
        $courier = array_fill(0, 256, 600);
        $this->font_widths['Courier'] = $courier;
        $this->font_widths['Courier-Bold'] = $courier;
        $this->font_widths['Courier-Oblique'] = $courier;
        $this->font_widths['Courier-BoldOblique'] = $courier;
    }

    /**
     * Get character widths for a given font
     */
    private function _get_char_widths($font_name) {
        return $this->font_widths[$font_name] ?? $this->font_widths['Helvetica'];
    }

    /**
     * Parse hex color to RGB array (0-255)
     *
     * @param string $hex Hex color like '#2271b1' or '2271b1'
     * @return array [r, g, b]
     */
    public static function hexToRGB($hex) {
        $hex = ltrim($hex, '#');
        if (strlen($hex) === 3) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }
        return array(
            hexdec(substr($hex, 0, 2)),
            hexdec(substr($hex, 2, 2)),
            hexdec(substr($hex, 4, 2)),
        );
    }
}
