$check Enriched check row (SecurityCheckService::findById) * @param array{locale?: string, app_name?: string, tenant_uuid?: string} $options */ public function renderCustomerReportPdf(array $check, array $options = []): string { if (!class_exists(Dompdf::class) || !class_exists(Options::class)) { return ''; } $report = $this->buildReportContext($check); $report['app_name'] = trim((string) ($options['app_name'] ?? '')); $report['logo_data_uri'] = $this->resolveLogoDataUri((string) ($options['tenant_uuid'] ?? '')); $report['generated_at'] = date('Y-m-d H:i'); $html = $this->renderReportHtml($report); if ($html === '') { return ''; } try { $options = new Options(); // Keep rendering deterministic: no remote fetches, no embedded PHP. $options->setIsRemoteEnabled(false); $options->setIsPhpEnabled(false); $options->setDefaultFont('DejaVu Sans'); $dompdf = new Dompdf($options); $dompdf->loadHtml($html, 'UTF-8'); $dompdf->setPaper('A4'); $dompdf->render(); $dompdf->addInfo('Title', (string) $report['title']); $dompdf->addInfo('Subject', (string) $report['title']); return (string) $dompdf->output(); } catch (Throwable) { return ''; } } /** * A filename-safe slug describing the report (customer + domain), e.g. * "security-check-report-acme-acme-de". Always returns a non-empty base. * * @param array $check */ public function buildReportFilename(array $check): string { $parts = array_filter([ (string) ($check['debitor_name'] ?? $check['debitor_no'] ?? ''), (string) ($check['domain_url'] ?? $check['domain_no'] ?? ''), ]); $slug = self::slugify(implode('-', $parts)); return 'security-check-report' . ($slug !== '' ? '-' . $slug : '') . '.pdf'; } /** * Build the flat, render-ready report context from an enriched check row. * Pure transform (no filesystem / network) — translation via t() only. * * @param array $check * @return array */ public function buildReportContext(array $check): array { $processState = is_array($check['process_state'] ?? null) ? $check['process_state'] : []; $techState = is_array($check['tech_state'] ?? null) ? $check['tech_state'] : []; $snapshot = is_array($check['tech_schema_snapshot'] ?? null) ? $check['tech_schema_snapshot'] : []; $progress = is_array($check['progress'] ?? null) ? $check['progress'] : []; $status = (string) ($check['status'] ?? SecurityCheckProcess::STATUS_OPEN); // The owner-authored, customer-facing report text captured on the final step. $reportFields = is_array($processState[SecurityCheckProcess::STEP_REPORT]['fields'] ?? null) ? $processState[SecurityCheckProcess::STEP_REPORT]['fields'] : []; return [ 'title' => t('Security check report'), 'customer_report' => trim((string) ($reportFields[SecurityCheckProcess::FIELD_CUSTOMER_REPORT] ?? '')), 'customer_name' => (string) ($check['debitor_name'] ?? ''), 'customer_no' => (string) ($check['debitor_no'] ?? ''), 'domain' => (string) ($check['domain_url'] ?? ($check['domain_no'] ?? '')), 'product' => (string) ($check['product_name'] ?? ($check['product_code'] ?? '')), 'owner' => (string) ($check['owner_name'] ?? ''), 'status' => $status, 'status_label' => t(SecurityCheckProcess::statusLabel($status)), 'summary' => [ 'done' => (int) ($progress['done'] ?? 0), 'total' => (int) ($progress['total'] ?? 0), 'percent' => (int) ($progress['percent'] ?? 0), 'tech_done' => (int) ($progress['tech_done'] ?? 0), 'tech_total' => (int) ($progress['tech_total'] ?? 0), ], 'steps' => $this->buildSteps($processState), 'tech_sections' => $this->buildTechSections($snapshot, $techState), ]; } /** * High-level process milestones (label + completion date), excluding the * internal field values and per-step notes that are not customer-facing. * * @param array $processState * @return list */ private function buildSteps(array $processState): array { $steps = []; $number = 0; foreach ($this->process->steps() as $step) { $number++; if ($step['kind'] === SecurityCheckProcess::KIND_TECH_CHECKLIST) { continue; } $key = $step['key']; $entry = is_array($processState[$key] ?? null) ? $processState[$key] : []; $steps[] = [ 'number' => $number, 'label' => t($step['label']), 'done' => !empty($entry['done']), 'completed_at' => trim((string) ($entry['at'] ?? '')), ]; } return $steps; } /** * The frozen technical checklist grouped by section, each item carrying its * pass/fail state and any finding note. Items before the first section land * in an unlabelled leading group. * * @param array $snapshot * @param array $techState * @return list}> */ private function buildTechSections(array $snapshot, array $techState): array { $items = is_array($snapshot['items'] ?? null) ? $snapshot['items'] : []; $sections = []; $current = ['label' => '', 'items' => []]; foreach ($items as $item) { if (!is_array($item)) { continue; } if (($item['type'] ?? '') === 'section') { if ($current['items'] !== []) { $sections[] = $current; } $current = ['label' => (string) ($item['label'] ?? ''), 'items' => []]; continue; } $key = trim((string) ($item['key'] ?? '')); if ($key === '') { continue; } $entry = is_array($techState[$key] ?? null) ? $techState[$key] : []; $current['items'][] = [ 'label' => (string) ($item['label'] ?? ''), 'done' => !empty($entry['done']), 'note' => trim((string) ($entry['note'] ?? '')), ]; } if ($current['items'] !== []) { $sections[] = $current; } return $sections; } /** * Produce the report HTML. When a custom template is configured (and the * collaborating services are wired), it is filled with the report context; * otherwise the built-in PHTML default layout is used. Keeping the default * on the proven PHTML path makes the feature purely additive. * * @param array $report */ private function renderReportHtml(array $report): string { if ($this->reportSettings !== null && $this->templateService !== null) { $template = $this->reportSettings->getTemplateHtml(); if ($template !== '') { return $this->templateService->render($template, $report); } } return $this->renderTemplate($report); } /** * @param array $report */ private function renderTemplate(array $report): string { $templatePath = dirname(__DIR__, 4) . '/templates/pdf/customer-report.phtml'; if (!is_file($templatePath)) { return ''; } // Isolated scope: the template only sees $report, never this service's state. $render = static function (string $__path, array $report): string { ob_start(); include $__path; return (string) ob_get_clean(); }; return $render($templatePath, $report); } /** * Resolve a logo data URI: configured report logo → tenant light-logo → * global branding logo → static brand asset → transparent pixel. Mirrors the * core PDF pattern; the PDF has no theme context, so the light variant is * always used. */ private function resolveLogoDataUri(string $tenantUuid): string { $path = ''; $mime = ''; if ($this->reportLogoService !== null && $this->reportLogoService->hasLogo()) { $logoPath = $this->reportLogoService->findLogoPath(256); if ($logoPath && is_file($logoPath)) { $path = $logoPath; $mime = $this->reportLogoService->detectMime($logoPath); } } if ($path === '' && $this->tenantLogoService !== null && $tenantUuid !== '' && $this->tenantLogoService->hasLogo($tenantUuid, TenantLogoService::THEME_LIGHT)) { $logoPath = $this->tenantLogoService->findLogoPath($tenantUuid, TenantLogoService::THEME_LIGHT, 128); if ($logoPath && is_file($logoPath)) { $path = $logoPath; $mime = $this->tenantLogoService->detectMime($logoPath); } } if ($path === '' && $this->brandingLogoService->hasLogo()) { $logoPath = $this->brandingLogoService->findLogoPath(128); if ($logoPath && is_file($logoPath)) { $path = $logoPath; $mime = $this->brandingLogoService->detectMime($logoPath); } } if ($path === '') { $fallback = dirname(__DIR__, 6) . '/web/brand/logo.svg'; if (is_file($fallback)) { $path = $fallback; $mime = 'image/svg+xml'; } } if ($path === '' || !is_file($path)) { return self::transparentPixelDataUri(); } $content = @file_get_contents($path); if ($content === false || $content === '') { return self::transparentPixelDataUri(); } return 'data:' . ($mime !== '' ? $mime : 'image/png') . ';base64,' . base64_encode($content); } private static function transparentPixelDataUri(): string { // Smallest safe placeholder so the tag stays valid when no logo exists. return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////fwAJ+wP9KobjigAAAABJRU5ErkJggg=='; } private static function slugify(string $value): string { $value = strtolower(trim($value)); if ($value === '') { return ''; } $value = (string) preg_replace('/[^a-z0-9]+/', '-', $value); return trim($value, '-'); } }