469 lines
16 KiB
PHP
469 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Support;
|
||
|
||
use setasign\Fpdi\Tcpdf\Fpdi;
|
||
use TCPDF_FONTS;
|
||
|
||
/**
|
||
* Fills the Synchrobreathe report-card template and returns PDF bytes.
|
||
*
|
||
* Coordinates are PDF user space (origin bottom-left) measured from the template.
|
||
*/
|
||
class SynchrobreatheReportCardPdf
|
||
{
|
||
private const PAGE_W = 927.13;
|
||
|
||
private const PAGE_H = 1024.94;
|
||
|
||
/** CMYK 0–100, matched to the template fills. */
|
||
private const CARD = [18.3, 14.1, 14.5, 0];
|
||
|
||
private const VALUE_RED = [15.3, 100, 100, 6.2];
|
||
|
||
private const NAME_INK = [28, 28, 28];
|
||
|
||
private const NUMBER_INK = [34, 34, 34];
|
||
|
||
private const FOOTER_INK = [56.3, 47.6, 47.2, 13.5];
|
||
|
||
private const LABEL_INK = [62.4, 54.5, 53.4, 26.9];
|
||
|
||
private const IDENTITY_SIZE = 23.1785;
|
||
|
||
private const FOOTER_SIZE = 18.6153;
|
||
|
||
private const CELL_SIZE = 8.0;
|
||
|
||
private const NAME_X = 53.62;
|
||
|
||
private const NAME_W = 200.86;
|
||
|
||
private const NAME_H = 22.77;
|
||
|
||
private const VALUE_W = 45.98;
|
||
|
||
private const VALUE_H = 22.84;
|
||
|
||
/** @var float[] CMYK 0–100 for the name and metric cells */
|
||
private const NAME_CELL = [7.0, 5.0, 5.2, 0.0];
|
||
|
||
private const QTY_CELL = [12.0, 9.0, 9.5, 0.0];
|
||
|
||
/** @var float[]|null PDF bottom of each fitted doctor row, when more than 19 are shown */
|
||
private $fittedBottoms = null;
|
||
|
||
private $fittedNameH = self::NAME_H;
|
||
|
||
private $fittedValueH = self::VALUE_H;
|
||
|
||
/** @var float[] PDF bottom edge of each doctor-name cell */
|
||
private const NAME_ROW_Y = [
|
||
658.16, 631.1, 604.03, 576.97, 549.91, 524.08, 497.18, 471.35, 445.52,
|
||
419.3, 392.23, 365.17, 338.11, 311.05, 283.99, 258.16, 231.26, 205.43, 179.6,
|
||
];
|
||
|
||
/** @var float[] Left edge of QTY, Value, QTY, Value… for Apr through Sept */
|
||
private const VALUE_X = [
|
||
259.98, 310.74, 361.49, 412.25, 463.58, 514.33,
|
||
565.09, 615.84, 667.09, 717.84, 769.67, 820.43,
|
||
];
|
||
|
||
/**
|
||
* @param array<string, mixed> $report
|
||
*/
|
||
public static function render(array $report): string
|
||
{
|
||
$builder = new self();
|
||
|
||
return $builder->build($report);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $report
|
||
*/
|
||
private function build(array $report): string
|
||
{
|
||
$this->ensureFonts();
|
||
$this->loadPdfEngine();
|
||
|
||
$doctors = isset($report['doctors']) && is_array($report['doctors']) ? array_values($report['doctors']) : [];
|
||
$this->fitRows(count($doctors));
|
||
|
||
$pdf = new Fpdi('P', 'pt', [self::PAGE_W, self::PAGE_H], true, 'UTF-8', false);
|
||
$pdf->setPrintHeader(false);
|
||
$pdf->setPrintFooter(false);
|
||
$pdf->SetAutoPageBreak(false, 0);
|
||
$pdf->SetMargins(0, 0, 0);
|
||
$pdf->setCellPaddings(0, 0, 0, 0);
|
||
$pdf->setCellMargins(0, 0, 0, 0);
|
||
$pdf->setSourceFile($this->templatePath());
|
||
$template = $pdf->importPage(1);
|
||
|
||
$pdf->AddPage('P', [self::PAGE_W, self::PAGE_H]);
|
||
$pdf->SetFillColor(255, 255, 255);
|
||
$pdf->Rect(0, 0, self::PAGE_W, self::PAGE_H, 'F');
|
||
$pdf->useTemplate($template, 0, 0, self::PAGE_W, self::PAGE_H, false);
|
||
$this->paintFittedRows($pdf);
|
||
$this->writeIdentity($pdf, isset($report['tm']) && is_array($report['tm']) ? $report['tm'] : []);
|
||
$this->writeDoctors($pdf, $doctors, isset($report['months']) && is_array($report['months']) ? $report['months'] : []);
|
||
$this->writeFooter($pdf, $report);
|
||
|
||
return $pdf->Output('', 'S');
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $tm
|
||
*/
|
||
private function writeIdentity(Fpdi $pdf, array $tm): void
|
||
{
|
||
$this->cover($pdf, 136.2, 818.3, 420.0, 30.0);
|
||
$this->cover($pdf, 116.5, 784.9, 430.0, 30.0);
|
||
$this->cover($pdf, 564.0, 818.3, 300.0, 30.0);
|
||
$this->cover($pdf, 564.0, 784.9, 300.0, 30.0);
|
||
|
||
$this->writeBaseline($pdf, 136.55, 826.2565, (string) ($tm['name'] ?? ''), 'sb_semi', self::IDENTITY_SIZE, self::VALUE_RED, 410);
|
||
$this->writeBaseline($pdf, 117.89, 792.86, (string) ($tm['abm'] ?? ''), 'sb_semi', self::IDENTITY_SIZE, self::VALUE_RED, 420);
|
||
$this->writeRightIdentity($pdf, 568.0, 826.2565, 'HQ:', (string) ($tm['hq'] ?? ''), 860.0);
|
||
$this->writeRightIdentity($pdf, 568.0, 792.86, 'Zone:', (string) ($tm['zone'] ?? ''), 860.0);
|
||
}
|
||
|
||
private function writeRightIdentity(Fpdi $pdf, float $x, float $baseline, string $label, string $value, float $right): void
|
||
{
|
||
$file = $this->fontFile('sb_semi');
|
||
$pdf->SetFont('sb_semi', '', self::IDENTITY_SIZE, $file);
|
||
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
||
$this->writeBaseline($pdf, $x, $baseline, $label, 'sb_semi', self::IDENTITY_SIZE, self::LABEL_INK, $labelWidth + 4);
|
||
$this->writeBaseline($pdf, $x + $labelWidth, $baseline, $value, 'sb_semi', self::IDENTITY_SIZE, self::VALUE_RED, max(40.0, $right - ($x + $labelWidth)));
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array<string, mixed>> $doctors
|
||
* @param array<int, array<string, mixed>> $months
|
||
*/
|
||
private function writeDoctors(Fpdi $pdf, array $doctors, array $months): void
|
||
{
|
||
$columns = $this->monthColumns($months, $doctors);
|
||
foreach ($doctors as $index => $doctor) {
|
||
if (!is_array($doctor)) {
|
||
continue;
|
||
}
|
||
$bottom = $this->rowBottom($index);
|
||
$nameH = $this->fittedBottoms === null ? self::NAME_H : $this->fittedNameH;
|
||
$valueH = $this->fittedBottoms === null ? self::VALUE_H : $this->fittedValueH;
|
||
$font = $this->fittedBottoms === null ? self::CELL_SIZE : self::CELL_SIZE * ($nameH / self::NAME_H);
|
||
$name = trim((string) ($doctor['name'] ?? ''));
|
||
if ($name !== '') {
|
||
$this->writeBox($pdf, self::NAME_X, $bottom, self::NAME_W, $nameH, $name, 'L', 'sb_semi', $font, self::NAME_INK, 6);
|
||
}
|
||
|
||
$cells = isset($doctor['cells']) && is_array($doctor['cells']) ? $doctor['cells'] : [];
|
||
foreach ($columns as $key => $column) {
|
||
if (!isset($cells[$key]) || !is_array($cells[$key])) {
|
||
continue;
|
||
}
|
||
$qtyX = self::VALUE_X[$column * 2] ?? null;
|
||
$valueX = self::VALUE_X[$column * 2 + 1] ?? null;
|
||
if ($qtyX !== null) {
|
||
$this->writeBox($pdf, $qtyX, $bottom - 0.07, self::VALUE_W, $valueH, (string) ($cells[$key]['qty'] ?? ''), 'C', 'sb_medium', $font, self::NUMBER_INK, 1);
|
||
}
|
||
if ($valueX !== null) {
|
||
$this->writeBox($pdf, $valueX, $bottom - 0.07, self::VALUE_W, $valueH, (string) ($cells[$key]['value'] ?? ''), 'C', 'sb_medium', $font, self::NUMBER_INK, 1);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Template columns are Apr through Sept. Months outside that range are skipped
|
||
* unless none of the report months match, in which case the first six are used in order.
|
||
*
|
||
* @param array<int, array<string, mixed>> $months
|
||
* @param array<int, array<string, mixed>> $doctors
|
||
* @return array<string, int>
|
||
*/
|
||
private function monthColumns(array $months, array $doctors): array
|
||
{
|
||
$keys = [];
|
||
foreach ($months as $month) {
|
||
if (!is_array($month)) {
|
||
continue;
|
||
}
|
||
$key = trim((string) ($month['key'] ?? ''));
|
||
if ($key !== '') {
|
||
$keys[] = $key;
|
||
}
|
||
}
|
||
if ($keys === []) {
|
||
$seen = [];
|
||
foreach ($doctors as $doctor) {
|
||
if (!is_array($doctor) || !isset($doctor['cells']) || !is_array($doctor['cells'])) {
|
||
continue;
|
||
}
|
||
foreach (array_keys($doctor['cells']) as $key) {
|
||
$seen[(string) $key] = true;
|
||
}
|
||
}
|
||
$keys = array_keys($seen);
|
||
sort($keys);
|
||
}
|
||
|
||
$mapped = [];
|
||
foreach ($keys as $key) {
|
||
if (preg_match('/^\d{4}-(\d{2})$/', $key, $match)) {
|
||
$month = (int) $match[1];
|
||
if ($month >= 4 && $month <= 9) {
|
||
$mapped[$key] = $month - 4;
|
||
}
|
||
}
|
||
}
|
||
if ($mapped !== []) {
|
||
return $mapped;
|
||
}
|
||
|
||
$fallback = [];
|
||
foreach (array_slice($keys, 0, 6) as $index => $key) {
|
||
$fallback[$key] = $index;
|
||
}
|
||
|
||
return $fallback;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $report
|
||
*/
|
||
private function writeFooter(Fpdi $pdf, array $report): void
|
||
{
|
||
$comment = $this->singleLine((string) ($report['comment'] ?? ''));
|
||
$rbm = $this->singleLine((string) ($report['rbm'] ?? ''));
|
||
$zbm = $this->singleLine((string) ($report['zbm'] ?? ''));
|
||
$marketing = $this->singleLine((string) ($report['marketing'] ?? ''));
|
||
|
||
$this->writeBaseline($pdf, 134.0, 123.9275, $comment, 'sb_medium', self::FOOTER_SIZE, self::FOOTER_INK, 750);
|
||
$this->writeBaseline($pdf, 78.0, 90.85, $rbm, 'sb_medium', self::FOOTER_SIZE, self::FOOTER_INK, 420);
|
||
$this->writeBaseline($pdf, 564.0, 90.1, $zbm, 'sb_medium', self::FOOTER_SIZE, self::FOOTER_INK, 320);
|
||
$this->writeBaseline($pdf, 134.0, 55.09, $marketing, 'sb_medium', self::FOOTER_SIZE, self::FOOTER_INK, 750);
|
||
}
|
||
|
||
/**
|
||
* Squeeze every doctor into the template's 19-row band so the rounded card stays put.
|
||
*/
|
||
private function fitRows(int $count): void
|
||
{
|
||
$slots = count(self::NAME_ROW_Y);
|
||
if ($count <= $slots) {
|
||
$this->fittedBottoms = null;
|
||
|
||
return;
|
||
}
|
||
|
||
$top = self::NAME_ROW_Y[0] + self::NAME_H;
|
||
$bottom = self::NAME_ROW_Y[$slots - 1];
|
||
$span = $top - $bottom;
|
||
$nativeGap = ($span - ($slots * self::NAME_H)) / ($slots - 1);
|
||
$scale = $span / (($count * self::NAME_H) + (($count - 1) * $nativeGap));
|
||
$cellH = self::NAME_H * $scale;
|
||
$gap = $nativeGap * $scale;
|
||
$this->fittedNameH = $cellH;
|
||
$this->fittedValueH = self::VALUE_H * $scale;
|
||
$this->fittedBottoms = [];
|
||
$cursor = $top;
|
||
for ($i = 0; $i < $count; $i++) {
|
||
$this->fittedBottoms[$i] = $cursor - $cellH;
|
||
$cursor -= ($cellH + $gap);
|
||
}
|
||
}
|
||
|
||
private function paintFittedRows(Fpdi $pdf): void
|
||
{
|
||
if ($this->fittedBottoms === null) {
|
||
return;
|
||
}
|
||
|
||
$top = self::NAME_ROW_Y[0] + self::NAME_H;
|
||
$bottom = self::NAME_ROW_Y[count(self::NAME_ROW_Y) - 1];
|
||
$right = self::VALUE_X[count(self::VALUE_X) - 1] + self::VALUE_W;
|
||
$this->fillCmyk($pdf, self::CARD);
|
||
$pdf->Rect(self::NAME_X, self::PAGE_H - $top, $right - self::NAME_X, $top - $bottom, 'F');
|
||
|
||
foreach ($this->fittedBottoms as $rowBottom) {
|
||
$this->fillCmyk($pdf, self::NAME_CELL);
|
||
$pdf->Rect(self::NAME_X, self::PAGE_H - ($rowBottom + $this->fittedNameH), self::NAME_W, $this->fittedNameH, 'F');
|
||
$this->fillCmyk($pdf, self::QTY_CELL);
|
||
foreach (self::VALUE_X as $x) {
|
||
$pdf->Rect($x, self::PAGE_H - (($rowBottom - 0.07) + $this->fittedValueH), self::VALUE_W, $this->fittedValueH, 'F');
|
||
}
|
||
}
|
||
}
|
||
|
||
private function rowBottom(int $index): float
|
||
{
|
||
if ($this->fittedBottoms !== null && isset($this->fittedBottoms[$index])) {
|
||
return $this->fittedBottoms[$index];
|
||
}
|
||
|
||
return self::NAME_ROW_Y[$index] ?? self::NAME_ROW_Y[count(self::NAME_ROW_Y) - 1];
|
||
}
|
||
|
||
/**
|
||
* @param float[] $cmyk
|
||
*/
|
||
private function fillCmyk(Fpdi $pdf, array $cmyk): void
|
||
{
|
||
$pdf->SetFillColor($cmyk[0], $cmyk[1], $cmyk[2], $cmyk[3]);
|
||
}
|
||
|
||
/**
|
||
* @param int[] $rgb
|
||
*/
|
||
private function writeBaseline(Fpdi $pdf, float $x, float $baseline, string $text, string $font, float $size, array $rgb, float $maxWidth): void
|
||
{
|
||
$text = trim($text);
|
||
if ($text === '') {
|
||
return;
|
||
}
|
||
|
||
$size = $this->fitSize($pdf, $font, $text, $maxWidth, $size);
|
||
$pdf->SetFont($font, '', $size, $this->fontFile($font));
|
||
$this->applyTextColor($pdf, $rgb);
|
||
$pdf->SetXY($x, self::PAGE_H - $baseline);
|
||
$pdf->Cell($maxWidth, 0, $text, 0, 0, 'L', false, '', 0, false, 'L', 'T');
|
||
}
|
||
|
||
/**
|
||
* @param int[] $rgb
|
||
*/
|
||
private function writeBox(
|
||
Fpdi $pdf,
|
||
float $x,
|
||
float $bottom,
|
||
float $w,
|
||
float $h,
|
||
string $text,
|
||
string $align,
|
||
string $font,
|
||
float $size,
|
||
array $rgb,
|
||
float $pad
|
||
): void {
|
||
$text = trim($text);
|
||
if ($text === '') {
|
||
return;
|
||
}
|
||
|
||
$inner = max(4.0, $w - ($pad * 2));
|
||
$size = $this->fitSize($pdf, $font, $text, $inner, $size);
|
||
$pdf->SetFont($font, '', $size, $this->fontFile($font));
|
||
$this->applyTextColor($pdf, $rgb);
|
||
$pdf->SetXY($x + $pad, self::PAGE_H - ($bottom + $h));
|
||
$pdf->Cell($inner, $h, $text, 0, 0, $align, false, '', 0, false, 'T', 'M');
|
||
}
|
||
|
||
private function fitSize(Fpdi $pdf, string $font, string $text, float $maxWidth, float $size): float
|
||
{
|
||
$file = $this->fontFile($font);
|
||
$pdf->SetFont($font, '', $size, $file);
|
||
while ($size > 6.5 && $pdf->GetStringWidth($text) > $maxWidth) {
|
||
$size -= 0.4;
|
||
$pdf->SetFont($font, '', $size, $file);
|
||
}
|
||
|
||
return $size;
|
||
}
|
||
|
||
/**
|
||
* @param float[] $color RGB 0–255, or CMYK 0–100 when four components are given.
|
||
*/
|
||
private function applyTextColor(Fpdi $pdf, array $color): void
|
||
{
|
||
if (count($color) === 4) {
|
||
$pdf->SetTextColor($color[0], $color[1], $color[2], $color[3]);
|
||
|
||
return;
|
||
}
|
||
|
||
$pdf->SetTextColor($color[0], $color[1], $color[2]);
|
||
}
|
||
|
||
private function cover(Fpdi $pdf, float $x, float $bottom, float $w, float $h): void
|
||
{
|
||
$pdf->SetFillColor(self::CARD[0], self::CARD[1], self::CARD[2], self::CARD[3]);
|
||
$pdf->Rect($x, self::PAGE_H - ($bottom + $h), $w, $h, 'F');
|
||
}
|
||
|
||
private function singleLine(string $value): string
|
||
{
|
||
$value = str_replace(["\r\n", "\r", "\n"], ' ', $value);
|
||
|
||
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
|
||
}
|
||
|
||
/**
|
||
* Composer installs these libraries into vendor/, which is not deployed.
|
||
* The copies under lib/reportcard-pdf travel with the app.
|
||
*/
|
||
private function loadPdfEngine(): void
|
||
{
|
||
if (!defined('TCPDF_SILENCE_DEPRECATION')) {
|
||
define('TCPDF_SILENCE_DEPRECATION', true);
|
||
}
|
||
if (class_exists(Fpdi::class)) {
|
||
return;
|
||
}
|
||
|
||
$root = dirname(__DIR__, 2) . '/lib/reportcard-pdf';
|
||
$tcpdf = $root . '/tecnickcom/tcpdf/tcpdf.php';
|
||
$fpdi = $root . '/setasign/fpdi/src/autoload.php';
|
||
if (!is_file($tcpdf) || !is_file($fpdi)) {
|
||
throw new \RuntimeException('PDF libraries are missing.');
|
||
}
|
||
|
||
require_once $tcpdf;
|
||
require_once $fpdi;
|
||
}
|
||
|
||
private function ensureFonts(): void
|
||
{
|
||
$source = dirname(__DIR__, 2) . '/resources/reportcard/fonts';
|
||
$build = $source . '/build';
|
||
if (!is_dir($build) && !mkdir($build, 0775, true) && !is_dir($build)) {
|
||
throw new \RuntimeException('Unable to prepare the report card fonts.');
|
||
}
|
||
|
||
// TCPDF shortens "bold" inside a filename and then treats a trailing "b" as bold style.
|
||
$map = [
|
||
'sb_semi' => 'Poppins-SemiBold.ttf',
|
||
'sb_medium' => 'Poppins-Medium.ttf',
|
||
];
|
||
foreach ($map as $name => $file) {
|
||
if (is_file($build . '/' . $name . '.php')) {
|
||
continue;
|
||
}
|
||
$copy = $build . '/' . $name . '.ttf';
|
||
if (!copy($source . '/' . $file, $copy)) {
|
||
throw new \RuntimeException('Unable to prepare the report card fonts.');
|
||
}
|
||
$created = TCPDF_FONTS::addTTFfont($copy, 'TrueTypeUnicode', '', 32, $build . '/');
|
||
@unlink($copy);
|
||
if ($created === false) {
|
||
throw new \RuntimeException('Unable to prepare the report card fonts.');
|
||
}
|
||
}
|
||
}
|
||
|
||
private function fontFile(string $font): string
|
||
{
|
||
return dirname(__DIR__, 2) . '/resources/reportcard/fonts/build/' . $font . '.php';
|
||
}
|
||
|
||
private function templatePath(): string
|
||
{
|
||
$path = dirname(__DIR__, 2) . '/resources/reportcard/synchrobreathe-template.pdf';
|
||
if (!is_file($path)) {
|
||
throw new \RuntimeException('Report card template is missing.');
|
||
}
|
||
|
||
return $path;
|
||
}
|
||
}
|