Rapport-Laravel/app/Support/SynchrobreatheReportCardPdf...

469 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}