feca-mailshots-plugin/feca_mailshots_plugin/src/Application/TemplateRenderer.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 '';
}
}