226 lines
8.4 KiB
PHP
226 lines
8.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace FecaMailshots\Application;
|
|
|
|
final class TemplateRenderer
|
|
{
|
|
/** @var null|callable(string):?array<string,mixed> */
|
|
private $pdfAssetResolver;
|
|
|
|
/** @param null|callable(string):?array<string,mixed> $pdfAssetResolver */
|
|
public function __construct(?callable $pdfAssetResolver = null)
|
|
{
|
|
$this->pdfAssetResolver = $pdfAssetResolver;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $context
|
|
* @return array{subject:string,message:string,pdf_attachment:string,warnings:list<string>}
|
|
*/
|
|
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 = '<img src="' . $src . '" alt="' . $alt . '" style="' . $imgStyle . '">';
|
|
|
|
if ($justification === 'in-place') {
|
|
return $img;
|
|
}
|
|
return '<div style="display:block;text-align:' . $justification . ';">' . $img . '</div>';
|
|
}
|
|
|
|
/** @param array<string, mixed> $row @return array<string, mixed> */
|
|
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 '';
|
|
}
|
|
}
|