*/ private $pdfAssetResolver; /** @param null|callable(string):?array $pdfAssetResolver */ public function __construct(?callable $pdfAssetResolver = null) { $this->pdfAssetResolver = $pdfAssetResolver; } /** * @param array $context * @return array{subject:string,message:string,pdf_attachment:string,warnings:list} */ public function render(string $subjectTpl, string $messageTpl, string $pdfTpl, array $context): array { if (!class_exists('Twig\\Environment')) { throw new \RuntimeException('Twig is not available. Ensure vendor dependencies are installed.'); } $loader = new \Twig\Loader\ArrayLoader([ 'subject' => $subjectTpl, 'message' => $messageTpl, 'pdf' => $pdfTpl, ]); $twig = new \Twig\Environment($loader, [ 'autoescape' => 'html', 'strict_variables' => true, 'cache' => false, ]); $twig->addFunction(new \Twig\TwigFunction( 'pdf_asset', function (string $name): string { return $this->renderPdfAsset($name); }, ['is_safe' => ['html']] )); // Provide both original keys and canonicalized token keys. $safeContext = $this->canonicalizeContext($context); return [ 'subject' => html_entity_decode((string) $twig->render('subject', $safeContext), ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'message' => (string) $twig->render('message', $safeContext), 'pdf_attachment' => (string) $twig->render('pdf', $safeContext), 'warnings' => [], ]; } private function renderPdfAsset(string $name): string { $name = trim($name); if ($name === '') { throw new \RuntimeException('pdf_asset() requires a non-empty asset name.'); } if (!is_callable($this->pdfAssetResolver)) { throw new \RuntimeException('pdf_asset() is unavailable: PDF asset resolver is not configured.'); } $asset = ($this->pdfAssetResolver)($name); if (!is_array($asset)) { throw new \RuntimeException('pdf_asset("' . $name . '") not found.'); } $mimeType = trim((string) ($asset['mime_type'] ?? '')); $bytes = (string) ($asset['file_bytes'] ?? ''); $widthMm = (float) ($asset['width_mm'] ?? 0); $heightMm = (float) ($asset['height_mm'] ?? 0); $justification = trim((string) ($asset['justification'] ?? '')); $fileName = trim((string) ($asset['file_name'] ?? '')); if ($mimeType === '' || $bytes === '') { throw new \RuntimeException('pdf_asset("' . $name . '") is missing file content.'); } if ($widthMm <= 0 || $heightMm <= 0) { throw new \RuntimeException('pdf_asset("' . $name . '") has invalid width/height.'); } if (!in_array($justification, ['in-place', 'left', 'right'], true)) { throw new \RuntimeException('pdf_asset("' . $name . '") has invalid justification value.'); } if ($this->needsPngTranscodeForRuntime($mimeType)) { [$mimeType, $bytes] = $this->transcodePngToJpeg($name, $bytes); } $dataUri = 'data:' . $mimeType . ';base64,' . base64_encode($bytes); $imgStyle = 'width:' . $this->mm($widthMm) . ';height:' . $this->mm($heightMm) . ';'; $alt = htmlspecialchars($fileName !== '' ? $fileName : $name, ENT_QUOTES); $src = htmlspecialchars($dataUri, ENT_QUOTES); $img = '' . $alt . ''; if ($justification === 'in-place') { return $img; } return '
' . $img . '
'; } /** @param array $row @return array */ private function canonicalizeContext(array $row): array { $out = $row; foreach ($row as $key => $value) { $lower = strtolower((string) $key); $canon = preg_replace('/[^a-z0-9_]+/', '_', $lower); if (!is_string($canon)) { throw new \RuntimeException('Failed to canonicalize template context key.'); } $canon = preg_replace('/_+/', '_', $canon); if (!is_string($canon)) { throw new \RuntimeException('Failed to canonicalize template context key.'); } $canon = trim($canon, '_'); if ($canon !== '' && !array_key_exists($canon, $out)) { $out[$canon] = $value; } } return $out; } private function mm(float $value): string { $formatted = rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); if ($formatted === '') { $formatted = '0'; } return $formatted . 'mm'; } private function needsPngTranscodeForRuntime(string $mimeType): bool { return strtolower($mimeType) === 'image/png' && !function_exists('imagecreatefrompng'); } /** * @return array{0:string,1:string} */ private function transcodePngToJpeg(string $assetName, string $pngBytes): array { $convertBinary = $this->findExecutableBinary('convert', ['/usr/bin/convert', '/bin/convert']); if ($convertBinary === '') { throw new \RuntimeException( 'pdf_asset("' . $assetName . '") uses PNG, but this runtime lacks GD and ImageMagick convert.' ); } if (!function_exists('exec')) { throw new \RuntimeException( 'pdf_asset("' . $assetName . '") cannot transcode PNG: exec() is unavailable in this PHP runtime.' ); } $inputPath = tempnam(sys_get_temp_dir(), 'feca_pdf_asset_png_'); if (!is_string($inputPath) || $inputPath === '') { throw new \RuntimeException('Unable to allocate temporary file for PNG transcode.'); } $inputPngPath = $inputPath . '.png'; if (!@rename($inputPath, $inputPngPath)) { @unlink($inputPath); throw new \RuntimeException('Unable to prepare temporary PNG file for transcode.'); } $outputJpegPath = $inputPngPath . '.jpg'; try { if (@file_put_contents($inputPngPath, $pngBytes) === false) { throw new \RuntimeException('Unable to write temporary PNG for transcode.'); } $cmd = escapeshellarg($convertBinary) . ' ' . escapeshellarg($inputPngPath) . ' -background white -alpha remove -alpha off -quality 90 ' . escapeshellarg($outputJpegPath) . ' 2>&1'; $output = []; $code = 0; @exec($cmd, $output, $code); if ($code !== 0 || !is_file($outputJpegPath)) { throw new \RuntimeException( 'ImageMagick convert failed for png asset "' . $assetName . '"' . ($output !== [] ? ': ' . trim(implode('; ', $output)) : '.') ); } $jpegBytes = @file_get_contents($outputJpegPath); if (!is_string($jpegBytes) || $jpegBytes === '') { throw new \RuntimeException('Converted JPEG is empty for png asset "' . $assetName . '".'); } return ['image/jpeg', $jpegBytes]; } finally { @unlink($inputPngPath); @unlink($outputJpegPath); } } private function findExecutableBinary(string $binaryName, array $fallbackPaths = []): string { $pathEnv = (string) getenv('PATH'); $candidates = []; if ($pathEnv !== '') { foreach (explode(':', $pathEnv) as $dir) { $dir = trim($dir); if ($dir !== '') { $candidates[] = rtrim($dir, '/') . '/' . $binaryName; } } } foreach ($fallbackPaths as $p) { $candidates[] = $p; } foreach ($candidates as $candidate) { if (is_string($candidate) && $candidate !== '' && is_file($candidate) && is_executable($candidate)) { return $candidate; } } return ''; } }