diff --git a/app/Helpers/merge_pdf_helper.php b/app/Helpers/merge_pdf_helper.php index 9316017f..4498b9f8 100644 --- a/app/Helpers/merge_pdf_helper.php +++ b/app/Helpers/merge_pdf_helper.php @@ -13,22 +13,23 @@ if (! defined('MERGED_CLAIM_FILE_TYPE')) { if (! function_exists('merge_ticket_pdfs')) { /** - * Merge all active PDF rows in claim_files for a given ticket_master id + * Merge all active PDF/image rows in claim_files for a given ticket_master id * into one combined PDF and register that PDF as a new claim_files row * with file_type = MERGED_CLAIM_FILE_TYPE. * * Source rows are picked from claim_files where: * - ticket_id = $ticket_master_id * - is_active = 1 - * - mime_type = 'application/pdf' - * - file_type IN $opts['include_file_types'] (default [2, 3]) + * - mime_type IN $opts['include_mime_types'] (default PDF/JPG/PNG) + * - file_type IN $opts['include_file_types'] (default [1, 2, 3]) * * @param int $ticket_master_id * @param array $opts { * @var bool $replace Default true. Soft-delete previous merged row before re-creating. * @var int $created_by Override created_by user id on the inserted row. * @var int $ticket_type Default 1. Stored on the inserted claim_files row. - * @var array $include_file_types Default [2, 3]. + * @var array $include_file_types Default [1, 2, 3]. + * @var array $include_mime_types Default ['application/pdf', 'image/jpeg', 'image/png']. * } * @return array {status, merged_file_id, file_name, pages, source_count, message} */ @@ -38,7 +39,8 @@ if (! function_exists('merge_ticket_pdfs')) { 'replace' => true, 'created_by' => null, 'ticket_type' => 1, - 'include_file_types' => [2, 3], + 'include_file_types' => [1, 2], + 'include_mime_types' => ['application/pdf', 'image/jpeg', 'image/png'], ]; $result = [ @@ -60,14 +62,13 @@ if (! function_exists('merge_ticket_pdfs')) { $rows = $claimFiles ->where('ticket_id', $ticket_master_id) ->where('is_active', 1) - ->where('mime_type', 'application/pdf') ->whereIn('file_type', $opts['include_file_types']) ->orderBy('id', 'ASC') ->findAll(); if (empty($rows)) { $result['status'] = true; - $result['message'] = 'No PDF files to merge'; + $result['message'] = 'No PDF/image files to merge'; return $result; } @@ -75,7 +76,7 @@ if (! function_exists('merge_ticket_pdfs')) { . 'uploads' . DIRECTORY_SEPARATOR . 'claim_files' . DIRECTORY_SEPARATOR; - $sourcePaths = []; + $sourceFiles = []; foreach ($rows as $row) { $name = ! empty($row['url']) ? $row['url'] : ($row['file_name'] ?? ''); if (empty($name)) { @@ -84,23 +85,47 @@ if (! function_exists('merge_ticket_pdfs')) { // url column may sometimes hold a full URL; we only care about the file basename on disk. $full = $uploadDir . basename($name); if (is_file($full) && is_readable($full)) { - $sourcePaths[] = $full; + $mime = merge_ticket_pdf_resolve_mime($full, (string) ($row['mime_type'] ?? '')); + if (! in_array($mime, $opts['include_mime_types'], true)) { + log_message('error', "merge_ticket_pdfs | unsupported source mime {$mime} | claim_file_id={$row['id']} | path={$full}"); + continue; + } + + $sourceFiles[] = [ + 'path' => $full, + 'mime' => $mime, + 'id' => $row['id'] ?? null, + ]; } else { - log_message('error', "merge_ticket_pdfs | missing PDF on disk | claim_file_id={$row['id']} | path={$full}"); + log_message('error', "merge_ticket_pdfs | missing file on disk | claim_file_id={$row['id']} | path={$full}"); } } - if (empty($sourcePaths)) { - $result['message'] = 'No readable PDF files on disk'; + if (empty($sourceFiles)) { + $result['message'] = 'No readable PDF/image files on disk'; return $result; } - $result['source_count'] = count($sourcePaths); + $result['source_count'] = count($sourceFiles); + log_message( + 'error', + 'merge_ticket_pdfs | source files resolved | ticket_id=' . $ticket_master_id . ' | sources=' . json_encode(array_map(static function ($sourceFile) { + return [ + 'id' => $sourceFile['id'], + 'mime' => $sourceFile['mime'], + 'file' => basename($sourceFile['path']), + ]; + }, $sourceFiles)) + ); $tempDir = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR . 'mpdf'; if (! is_dir($tempDir)) { @mkdir($tempDir, 0775, true); } + $mpdfCacheDir = $tempDir . DIRECTORY_SEPARATOR . 'mpdf'; + if (! is_dir($mpdfCacheDir)) { + @mkdir($mpdfCacheDir, 0775, true); + } $mergedName = 'merged_' . $ticket_master_id . '_' . time() . '_' . bin2hex(random_bytes(5)) . '.pdf'; $mergedPath = $uploadDir . $mergedName; @@ -112,18 +137,26 @@ if (! function_exists('merge_ticket_pdfs')) { ]); $totalPages = 0; - foreach ($sourcePaths as $src) { + foreach ($sourceFiles as $sourceFile) { + $src = $sourceFile['path']; try { - $pageCount = $mpdf->setSourceFile($src); - for ($p = 1; $p <= $pageCount; $p++) { - $tplId = $mpdf->importPage($p); - $size = $mpdf->getTemplateSize($tplId); - $mpdf->AddPageByArray([ - 'orientation' => ($size['width'] > $size['height']) ? 'L' : 'P', - 'sheet-size' => [$size['width'], $size['height']], - ]); - $mpdf->useTemplate($tplId); - $totalPages++; + if ($sourceFile['mime'] === 'application/pdf') { + $pageCount = $mpdf->setSourceFile($src); + for ($p = 1; $p <= $pageCount; $p++) { + $tplId = $mpdf->importPage($p); + $mpdf->AddPage(); + // adjustPageSize=true makes the output page match the imported page. + $mpdf->useTemplate($tplId, 0, 0, null, null, true); + $totalPages++; + } + log_message('error', "merge_ticket_pdfs | added PDF | file={$src} | pages={$pageCount}"); + } elseif (in_array($sourceFile['mime'], ['image/jpeg', 'image/png'], true)) { + if (merge_ticket_pdf_add_image_page($mpdf, $src)) { + $totalPages++; + log_message('error', "merge_ticket_pdfs | added image | file={$src} | mime={$sourceFile['mime']}"); + } + } else { + log_message('error', "merge_ticket_pdfs | unsupported source mime {$sourceFile['mime']} | {$src}"); } } catch (\Throwable $e) { log_message('error', "merge_ticket_pdfs | failed to import {$src} | " . $e->getMessage()); @@ -131,7 +164,7 @@ if (! function_exists('merge_ticket_pdfs')) { } if ($totalPages === 0) { - $result['message'] = 'All source PDFs failed to import'; + $result['message'] = 'All source PDF/image files failed to import'; return $result; } @@ -192,3 +225,98 @@ if (! function_exists('merge_ticket_pdfs')) { return $result; } } + +if (! function_exists('merge_ticket_pdf_resolve_mime')) { + /** + * Resolve file type from stored MIME, filesystem MIME, and extension. + * Some uploads can be saved as image/jpg, empty MIME, or octet-stream in DB. + */ + function merge_ticket_pdf_resolve_mime(string $path, string $storedMime = ''): string + { + $storedMime = strtolower(trim($storedMime)); + if ($storedMime === 'image/jpg') { + return 'image/jpeg'; + } + if (in_array($storedMime, ['application/pdf', 'image/jpeg', 'image/png'], true)) { + return $storedMime; + } + + $detectedMime = ''; + if (function_exists('mime_content_type')) { + $detectedMime = strtolower((string) @mime_content_type($path)); + if ($detectedMime === 'image/jpg') { + return 'image/jpeg'; + } + if (in_array($detectedMime, ['application/pdf', 'image/jpeg', 'image/png'], true)) { + return $detectedMime; + } + } + + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($ext === 'pdf') { + return 'application/pdf'; + } + if (in_array($ext, ['jpg', 'jpeg'], true)) { + return 'image/jpeg'; + } + if ($ext === 'png') { + return 'image/png'; + } + + return $detectedMime ?: ($storedMime ?: 'application/octet-stream'); + } +} + +if (! function_exists('merge_ticket_pdf_add_image_page')) { + /** + * Add an uploaded image as a single PDF page, preserving portrait/landscape + * orientation and fitting the image proportionally inside the page. + */ + function merge_ticket_pdf_add_image_page(Mpdf $mpdf, string $imagePath): bool + { + $imageInfo = @getimagesize($imagePath); + $ext = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION)); + $imageType = $ext === 'png' || ((int) ($imageInfo[2] ?? 0) === IMAGETYPE_PNG) ? 'png' : 'jpg'; + + $imageWidthPx = (int) ($imageInfo[0] ?? 0); + $imageHeightPx = (int) ($imageInfo[1] ?? 0); + $hasSize = $imageWidthPx > 0 && $imageHeightPx > 0; + $isLandscape = $hasSize && $imageWidthPx > $imageHeightPx; + + $pageWidth = $isLandscape ? 297 : 210; + $pageHeight = $isLandscape ? 210 : 297; + $margin = 0; + + $availableWidth = $pageWidth - ($margin * 2); + $availableHeight = $pageHeight - ($margin * 2); + if ($hasSize) { + $scale = min($availableWidth / $imageWidthPx, $availableHeight / $imageHeightPx); + $drawWidth = $imageWidthPx * $scale; + $drawHeight = $imageHeightPx * $scale; + $x = ($pageWidth - $drawWidth) / 2; + $y = ($pageHeight - $drawHeight) / 2; + } else { + // Some PNGs fail getimagesize(), but mPDF can still render them. + // Use a portrait A4 fallback and let mPDF calculate image height. + log_message('error', "merge_ticket_pdfs | image size unavailable, using fallback page | {$imagePath}"); + $drawWidth = $availableWidth; + $drawHeight = 0; + $x = $margin; + $y = $margin; + } + + $mpdf->AddPageByArray([ + 'orientation' => $isLandscape ? 'L' : 'P', + 'sheet-size' => 'A4', + 'margin-left' => 0, + 'margin-right' => 0, + 'margin-top' => 0, + 'margin-bottom' => 0, + 'margin-header' => 0, + 'margin-footer' => 0, + ]); + + $mpdf->Image($imagePath, $x, $y, $drawWidth, $drawHeight, $imageType); + return true; + } +}