$report */ public static function render(array $report): string { $builder = new self(); return $builder->build($report); } /** * @param array $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 $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> $doctors * @param array> $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> $months * @param array> $doctors * @return array */ 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 $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; } }