$scan TheDotDevAgenticBridge_Readiness_Store::latest() shape * @param array{ * siteName?: string, * siteUrl?: string, * pluginName?: string, * pluginVersion?: string, * generatedAt?: string * } $meta */ public static function build(array $scan, array $meta = []): string { // Text-only product branding until a founder-approved logo exists. // markPath is intentionally ignored if present in legacy callers. unset($meta['markPath']); $context = self::build_context($scan, $meta); $pages = self::layout_pages($context); return self::assemble_pdf($pages); } /** * @param array $finding */ public static function lifecycle_label(array $finding): string { $state = (string) ($finding['presentationState'] ?? ''); $verified_at = trim((string) ($finding['verifiedAt'] ?? '')); if ($state === 'verified' && $verified_at !== '') { return 'Verified at ' . self::sanitize_field($verified_at, 64); } if ($state === 'verified') { return 'Verified time not recorded'; } if ($state === 'pending_recheck') { return 'Pending recheck'; } if ($state === 'stale') { return 'Stale'; } return 'From latest scan'; } public static function owner_status_label(string $status): string { if (class_exists('TheDotDevAgenticBridge_Readiness_Methodology')) { $status = TheDotDevAgenticBridge_Readiness_Methodology::normalize_status($status); } else { $status = strtolower(trim($status)); } return match ($status) { 'fail' => 'Action needed', 'warning', 'unknown' => 'Needs review', 'pass', 'not_applicable' => 'All clear', default => 'Unavailable', }; } public static function priority_label(string $severity): string { return match (strtolower($severity)) { 'high' => 'High', 'medium' => 'Medium', 'info' => 'Review', default => $severity !== '' ? ucfirst($severity) : 'Unavailable', }; } /** * @return list */ public static function wrap_text(string $text, int $max_chars): array { $text = trim($text); if ($text === '') { return []; } $words = preg_split('/\s+/u', $text) ?: []; $lines = []; $current = ''; foreach ($words as $word) { $candidate = $current === '' ? $word : $current . ' ' . $word; if (strlen($candidate) > $max_chars && $current !== '') { $lines[] = $current; $current = $word; } else { $current = $candidate; } } if ($current !== '') { $lines[] = $current; } return $lines; } /** * Sentence-split + wrap the last-page local/privacy disclaimer. * * @return list */ public static function wrap_footer_note(string $note): array { $sentences = preg_split('/(?<=\.)\s+/', $note) ?: [$note]; $wrapped = []; foreach ($sentences as $sentence) { $sentence = trim((string) $sentence); if ($sentence === '') { continue; } foreach (self::wrap_text($sentence, self::FOOTER_NOTE_WRAP_CHARS) as $line) { $wrapped[] = $line; } } return $wrapped; } /** * Baselines for the last-page disclaimer relative to the footer rule. * * @return array{rule_y: float, first_baseline: float, last_baseline: float, line_count: int} */ public static function footer_note_baselines(int $line_count): array { $line_count = max(0, $line_count); $rule_y = self::FOOTER_BASELINE + self::FOOTER_RULE_OFFSET; $last_baseline = $rule_y + self::FOOTER_NOTE_GAP_ABOVE_RULE; $first_baseline = $last_baseline + max(0, $line_count - 1) * self::FOOTER_NOTE_LINE_STEP; return [ 'rule_y' => $rule_y, 'first_baseline' => $first_baseline, 'last_baseline' => $last_baseline, 'line_count' => $line_count, ]; } public static function measure_finding_card_height( string $why, string $next, string $meta = '', string $urls = '', string $impact = '', array $evidence_lines = [] ): float { $why_lines = self::wrap_text($why, self::FINDING_CARD_WRAP_CHARS); $impact_lines = self::wrap_text($impact, self::FINDING_CARD_WRAP_CHARS); $next_lines = self::wrap_text($next, self::FINDING_CARD_WRAP_CHARS); if ($next_lines === []) { $next_lines = [self::UNAVAILABLE_FIELD_TEXT]; } $url_lines = []; if ($urls !== '') { foreach (preg_split("/\n+/", $urls) ?: [$urls] as $url) { $url = trim((string) $url); if ($url === '') { continue; } foreach (self::wrap_text($url, self::FINDING_CARD_WRAP_CHARS) as $url_line) { $url_lines[] = $url_line; } } } $urls_height = $url_lines === [] ? 0.0 : self::FINDING_CARD_URLS_TOP_GAP + self::FINDING_CARD_SECTION_LABEL_GAP + (count($url_lines) * self::FINDING_CARD_LINE_HEIGHT); $meta_lines = $meta !== '' ? self::wrap_text($meta, self::FINDING_CARD_META_WRAP_CHARS) : ['']; $header_depth = self::FINDING_CARD_HEADER_DEPTH + (max(0, count($meta_lines) - 1) * self::FINDING_CARD_LINE_HEIGHT); $impact_height = $impact_lines === [] ? 0.0 : self::FINDING_CARD_IMPACT_TOP_GAP + self::FINDING_CARD_SECTION_LABEL_GAP + (count($impact_lines) * self::FINDING_CARD_LINE_HEIGHT); $why_height = $why_lines === [] ? 0.0 : self::FINDING_CARD_SECTION_LABEL_GAP + (count($why_lines) * self::FINDING_CARD_LINE_HEIGHT); $evidence_height = 0.0; if ($evidence_lines !== []) { $evidence_height = self::FINDING_CARD_EVIDENCE_TOP_GAP + (count($evidence_lines) * self::FINDING_CARD_LINE_HEIGHT); } return $header_depth + self::FINDING_CARD_BOTTOM_PAD + $impact_height + $why_height + self::FINDING_CARD_NEXT_TOP_GAP + self::FINDING_CARD_SECTION_LABEL_GAP + (count($next_lines) * self::FINDING_CARD_LINE_HEIGHT) + $urls_height + $evidence_height; } /** * @param array $finding * @return list */ public static function appendix_evidence_lines(array $finding): array { $evidence = is_array($finding['evidence'] ?? null) ? $finding['evidence'] : []; if ($evidence === []) { return []; } $lines = []; foreach ($evidence as $key => $value) { $raw_key = trim((string) $key); $normalized = self::normalize_evidence_key($raw_key); if ($raw_key === '' || self::is_internal_evidence_key($normalized)) { continue; } if (is_bool($value)) { $text = $value ? 'true' : 'false'; } elseif (is_scalar($value) || $value === null) { $text = trim((string) $value); } elseif (is_array($value)) { $flat = []; foreach ($value as $item) { if (is_scalar($item) || $item === null) { $item_text = trim((string) $item); if ($item_text !== '') { $flat[] = $item_text; } } } $text = implode(', ', $flat); } else { continue; } $safe = self::sanitize_evidence_value($text); if ($safe === '') { continue; } $lines[] = $raw_key . ': ' . $safe; if (count($lines) >= self::MAX_EVIDENCE_ENTRIES) { break; } } return $lines; } /** * @deprecated Use appendix_evidence_lines(); evidence belongs in the technical appendix only. * * @param array $finding * @return list */ public static function finding_card_evidence_lines(array $finding): array { return self::appendix_evidence_lines($finding); } /** * @param list}> $blocks */ public static function measure_appendix_height(array $blocks): float { if ($blocks === []) { return 0.0; } $intro_lines = max(1, count(self::wrap_text(self::APPENDIX_INTRO, self::APPENDIX_WRAP_CHARS))); $height = self::APPENDIX_TITLE_BAND + 18.0 + ($intro_lines * (10 + 4)) + self::APPENDIX_INTRO_GAP; foreach ($blocks as $block) { $height += self::measure_appendix_block_height($block); } return $height; } /** * @param array{title?: string, lines?: list} $block */ public static function measure_appendix_block_height(array $block): float { $lines = is_array($block['lines'] ?? null) ? $block['lines'] : []; if ($lines === []) { return 0.0; } $wrapped = 0; foreach ($lines as $line) { if (!is_string($line) || $line === '') { continue; } $wrapped += max(1, count(self::wrap_text('• ' . $line, self::APPENDIX_WRAP_CHARS))); } return self::APPENDIX_BLOCK_TITLE_HEIGHT + ($wrapped * self::APPENDIX_LINE_HEIGHT) + self::APPENDIX_BLOCK_GAP; } /** * Keep a compact evidence tail with findings when it fits, or when overflow * would otherwise create a near-empty extra page. */ public static function should_keep_appendix_on_current_page(float $remaining, float $height): bool { if ($height <= 0.0) { return true; } if ($height <= $remaining) { return true; } $overflow = $height - $remaining; return $height <= (self::APPENDIX_USABLE_BODY * 0.40) && $remaining >= self::APPENDIX_SECTION_MIN && $overflow <= 8.0; } public static function measure_score_card_height(string $explanation): float { $lines = self::wrap_text($explanation, self::SCORE_EXPLANATION_WRAP_CHARS); $line_count = max(1, count($lines)); $layout = self::score_card_layout_offsets(); $left_content_depth = $layout['band_rect_bottom_offset'] + self::SCORE_CARD_BOTTOM_PAD; $right_content_depth = 24.0 + ($line_count * self::SCORE_EXPLANATION_LINE_HEIGHT) + 12.0 + 32.0 + self::SCORE_CARD_BOTTOM_PAD; return max( self::SCORE_CARD_MIN_HEIGHT, $left_content_depth, $right_content_depth ); } public static function measure_next_step_callout_height(string $next): float { $lines = self::wrap_text($next, 76); $line_count = max(1, count($lines)); return max( self::NEXT_STEP_CALLOUT_MIN_HEIGHT, self::NEXT_STEP_CALLOUT_PAD + 16.0 + ($line_count * self::NEXT_STEP_CALLOUT_BODY_LINE_HEIGHT) + self::NEXT_STEP_CALLOUT_PAD ); } /** * Clamp category points to methodology caps so owner-facing scores never exceed /cap. * * @param array $categories * @return array */ public static function normalize_category_scores(array $categories): array { $caps = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::category_caps() : [ 'discoverability' => 20, 'understanding' => 20, 'trust' => 20, 'actions' => 20, 'safety' => 20, ]; $normalized = []; foreach ($caps as $key => $cap) { $pts = (int) ($categories[$key] ?? 0); $normalized[$key] = max(0, min((int) $cap, $pts)); } return $normalized; } /** * Category score-card column geometry for a 1-, 2-, or 3-column grid. * * @return array{columns: int, width: float, x_positions: list} */ public static function category_card_column_layout(int $columns): array { $columns = max(1, min(3, $columns)); $gap = self::CATEGORY_CARD_COL_GAP; $width = $columns === 1 ? (float) self::CONTENT_WIDTH : (self::CONTENT_WIDTH - (($columns - 1) * $gap)) / $columns; $x_positions = []; for ($i = 0; $i < $columns; ++$i) { $x_positions[] = (float) self::MARGIN_LEFT + ($i * ($width + $gap)); } return [ 'columns' => $columns, 'width' => $width, 'x_positions' => $x_positions, ]; } public static function measure_category_grid_height(int $columns = self::CATEGORY_COLUMNS, int $count = self::CATEGORY_COUNT): float { $columns = self::category_card_column_layout($columns)['columns']; $count = max(1, $count); $rows = (int) ceil($count / $columns); return $rows * (self::CATEGORY_CARD_HEIGHT + self::CATEGORY_CARD_ROW_GAP); } public static function measure_category_section_height(string $intro, int $columns = self::CATEGORY_COLUMNS, int $count = self::CATEGORY_COUNT): float { $intro_lines = max(1, count(self::wrap_text($intro, 88))); return 12.0 + 24.0 + ($intro_lines * 12.0) + 12.0 + self::measure_category_grid_height($columns, $count) + 8.0; } public static function score_band(int $score): string { if ($score >= 90) { return 'Strong local readiness'; } if ($score >= 50) { return 'Building readiness'; } if ($score > 0) { return 'Early readiness'; } return 'No points earned yet'; } public static function score_band_explanation(int $score): string { if ($score >= 90) { return 'Most local readiness checks are passing. Agents can discover, understand, and trust this site based on what this scan observed.'; } if ($score >= 50) { return 'Core readiness signals are present, but several checks still need attention before agents can rely on this site confidently.'; } if ($score > 0) { return 'Only part of the local readiness checklist is passing. Address the prioritized findings below and scan again.'; } return 'No methodology points were earned in this scan. Use the findings below to decide what to fix first, then scan again.'; } /** @return array{accent: array{0: float, 1: float, 2: float}, background: array{0: float, 1: float, 2: float}} */ public static function score_band_palette(int $score): array { if ($score >= 90) { return ['accent' => [0.027, 0.561, 0.451], 'background' => [0.945, 0.984, 0.973]]; } if ($score >= 50) { return ['accent' => [0.631, 0.384, 0.027], 'background' => [1.000, 0.984, 0.922]]; } return ['accent' => [0.761, 0.255, 0.047], 'background' => [1.000, 0.957, 0.929]]; } public static function category_label(string $key): string { return match ($key) { 'discoverability' => 'Discoverability', 'understanding' => 'Understanding', 'trust' => 'Trust', 'actions' => 'Actions', 'safety' => 'Safety', default => $key !== '' ? ucfirst($key) : 'Unavailable', }; } public static function category_group_heading(string $key): string { $label = self::category_label($key); if ($label === 'Unavailable') { return 'Other findings'; } return $label . ' findings'; } /** * @param list> $findings * @return array>> */ public static function group_findings_by_category(array $findings): array { $order = ['discoverability', 'understanding', 'trust', 'actions', 'safety']; $buckets = array_fill_keys($order, []); $other = []; foreach ($findings as $finding) { if (!is_array($finding)) { continue; } $category = (string) ($finding['category'] ?? ''); if (isset($buckets[$category])) { $buckets[$category][] = $finding; } else { $other[] = $finding; } } $grouped = []; foreach ($order as $key) { if ($buckets[$key] !== []) { $grouped[$key] = $buckets[$key]; } } if ($other !== []) { $grouped[''] = $other; } return $grouped; } /** * @param array $finding */ public static function finding_why_text(array $finding): string { if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $why = self::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_why($finding), self::MAX_FIELD_CHARS ); if ($why !== '' && !TheDotDevAgenticBridge_Findings_Presentation::looks_like_technical_evidence($why)) { return $why; } } $why = self::sanitize_field((string) ($finding['whyItMatters'] ?? ''), self::MAX_FIELD_CHARS); return $why !== '' ? $why : self::UNAVAILABLE_FIELD_TEXT; } /** * @param array $finding */ public static function finding_impact_text(array $finding): string { if (self::is_confirmed_strength($finding)) { return ''; } $why = self::finding_why_text($finding); if (self::finding_is_incomplete($finding)) { $impact = 'This check could not be confirmed from the latest local scan.'; if (!self::same_owner_sentence($impact, $why)) { return $impact; } } $candidates = []; $candidates[] = self::category_impact_fallback((string) ($finding['category'] ?? ''), false); if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $summary = self::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_summary($finding), self::MAX_FIELD_CHARS ); if ($summary !== '' && !TheDotDevAgenticBridge_Findings_Presentation::looks_like_technical_evidence($summary)) { $candidates[] = $summary; } $catalog_finding = $finding; unset($catalog_finding['whyItMatters'], $catalog_finding['title']); $catalog_summary = self::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_summary($catalog_finding), self::MAX_FIELD_CHARS ); if ($catalog_summary !== '' && !TheDotDevAgenticBridge_Findings_Presentation::looks_like_technical_evidence($catalog_summary) ) { $candidates[] = $catalog_summary; } } foreach ($candidates as $candidate) { if ($candidate !== '' && !self::same_owner_sentence($candidate, $why)) { return $candidate; } } return self::UNAVAILABLE_FIELD_TEXT; } /** * @param array $finding */ public static function finding_next_text(array $finding): string { if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $next = self::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_next($finding), self::MAX_FIELD_CHARS ); if ($next !== '' && !TheDotDevAgenticBridge_Findings_Presentation::looks_like_technical_evidence($next)) { return $next; } } $next = self::sanitize_field((string) ($finding['nextStep'] ?? ''), self::MAX_FIELD_CHARS); return $next !== '' ? $next : self::UNAVAILABLE_FIELD_TEXT; } /** * @param array $finding */ public static function is_confirmed_strength(array $finding): bool { $status = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::normalize_status((string) ($finding['status'] ?? '')) : strtolower(trim((string) ($finding['status'] ?? ''))); if (in_array($status, ['pass', 'not_applicable'], true)) { return true; } $severity = strtolower(trim((string) ($finding['severity'] ?? ''))); return $severity === 'info' && !in_array($status, ['fail', 'warning', 'unknown'], true); } /** * @param array $finding */ public static function finding_is_incomplete(array $finding): bool { $status = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::normalize_status((string) ($finding['status'] ?? '')) : strtolower(trim((string) ($finding['status'] ?? ''))); return $status === 'unknown'; } /** * @param array $finding */ public static function is_priority_finding(array $finding): bool { if (self::is_confirmed_strength($finding)) { return false; } $status = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::normalize_status((string) ($finding['status'] ?? '')) : strtolower(trim((string) ($finding['status'] ?? ''))); return in_array($status, ['fail', 'warning', 'unknown'], true); } /** * @param list> $findings * @return list> */ public static function prioritized_findings(array $findings): array { $prioritized = []; foreach ($findings as $finding) { if (is_array($finding) && self::is_priority_finding($finding)) { $prioritized[] = $finding; } } usort($prioritized, static function (array $a, array $b): int { $sev = self::severity_rank((string) ($a['severity'] ?? '')) <=> self::severity_rank((string) ($b['severity'] ?? '')); if ($sev !== 0) { return $sev; } return self::category_rank((string) ($a['category'] ?? '')) <=> self::category_rank((string) ($b['category'] ?? '')); }); return $prioritized; } /** * @param list> $findings * @return list> */ public static function page_one_strengths(array $findings): array { [, $strengths] = self::split_report_findings($findings); return array_slice($strengths, 0, self::PAGE_ONE_STRENGTHS_MAX); } /** * @param array $finding */ public static function finding_strength_result(array $finding): string { if ($finding === []) { return 'Confirmed in the latest local scan.'; } return 'Confirmed in the latest local scan.'; } /** * @param list> $findings * @return array{0: list>, 1: list>} */ public static function split_report_findings(array $findings): array { $prioritized = []; $strengths = []; foreach ($findings as $finding) { if (!is_array($finding)) { continue; } if (self::is_confirmed_strength($finding)) { $strengths[] = $finding; } elseif (self::is_priority_finding($finding)) { $prioritized[] = $finding; } } return [$prioritized, $strengths]; } /** * @param array $finding * @return array{found: string, why: string, next: string, impact: string} */ public static function finding_owner_copy(array $finding): array { $found = self::finding_display_title($finding); $why = self::is_confirmed_strength($finding) ? self::sanitize_field((string) ($finding['whyItMatters'] ?? ''), self::MAX_FIELD_CHARS) : self::finding_why_text($finding); $next = self::finding_next_text($finding); if (self::same_owner_sentence($why, $found) || self::same_owner_sentence($why, $next)) { $fallback = self::category_why_fallback((string) ($finding['category'] ?? '')); if ($fallback !== '' && !self::same_owner_sentence($fallback, $found) && !self::same_owner_sentence($fallback, $next) ) { $why = $fallback; } } if (self::same_owner_sentence($next, $found) || self::same_owner_sentence($next, $why)) { $status_next = 'Review this check in WordPress, then run a fresh local scan.'; if (!self::same_owner_sentence($status_next, $found) && !self::same_owner_sentence($status_next, $why)) { $next = $status_next; } } $impact = self::finding_impact_text($finding); if (!self::is_confirmed_strength($finding)) { if ($impact === self::UNAVAILABLE_FIELD_TEXT || self::same_owner_sentence($impact, $why) || self::same_owner_sentence($impact, $found) ) { $impact = self::category_impact_fallback( (string) ($finding['category'] ?? ''), self::finding_is_incomplete($finding) ); } if (self::same_owner_sentence($impact, $why) || self::same_owner_sentence($impact, $found) || $impact === '') { $impact = 'A site visitor or AI agent may not get a reliable result from this check until it is resolved.'; } } else { $impact = ''; } return [ 'found' => $found, 'why' => $why, 'next' => $next, 'impact' => $impact, ]; } /** * @param array $finding */ public static function finding_display_title(array $finding): string { if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $presented = self::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_title($finding), self::MAX_FIELD_CHARS ); if ($presented !== '' && ($presented !== 'This check could not be completed safely' || self::finding_is_incomplete($finding))) { return $presented; } } $stored = self::sanitize_field((string) ($finding['title'] ?? ''), self::MAX_FIELD_CHARS); if ($stored !== '') { return $stored; } $catalog = self::catalog_finding_title((string) ($finding['id'] ?? '')); if ($catalog !== '') { return $catalog; } return 'Finding from the latest local scan'; } /** * Appendix heading that never repeats the owner-detail title. * * @param array $finding */ public static function appendix_finding_label(array $finding): string { $id = self::sanitize_field((string) ($finding['id'] ?? ''), 80); if ($id !== '') { return $id; } return 'Stored finding evidence'; } private static function catalog_finding_title(string $id): string { if ($id === '' || !class_exists('TheDotDevAgenticBridge_Readiness_Methodology')) { return ''; } foreach (TheDotDevAgenticBridge_Readiness_Methodology::checks() as $check) { if (!is_array($check) || (string) ($check['id'] ?? '') !== $id) { continue; } return self::sanitize_field((string) ($check['title'] ?? ''), self::MAX_FIELD_CHARS); } return ''; } public static function owner_sentences_are_distinct(string ...$texts): bool { $seen = []; foreach ($texts as $text) { $normalized = self::normalize_owner_sentence($text); if ($normalized === '' || isset($seen[$normalized])) { return false; } $seen[$normalized] = true; } return true; } /** * @param list> $findings * @return list}> */ public static function appendix_blocks(array $findings): array { $blocks = []; foreach ($findings as $finding) { if (!is_array($finding)) { continue; } $lines = self::appendix_evidence_lines($finding); if ($lines === []) { continue; } $blocks[] = [ 'title' => self::appendix_finding_label($finding), 'lines' => $lines, ]; } return $blocks; } /** * @param list}> $blocks */ public static function should_inline_appendix_evidence(array $blocks): bool { unset($blocks); return false; } /** * @return array{ * score_value_bottom_offset: float, * band_rect_top_offset: float, * band_rect_bottom_offset: float, * explain_first_baseline_offset: float, * explain_line_height: float * } */ private static function score_card_layout_offsets(): array { $score_value_bottom_offset = self::SCORE_VALUE_BASELINE_OFFSET + self::SCORE_VALUE_DESCENDER_CLEARANCE; $band_rect_top_offset = max( self::SCORE_BAND_TOP_OFFSET, $score_value_bottom_offset + self::SCORE_BAND_CLEARANCE_AFTER_VALUE ); $band_rect_bottom_offset = $band_rect_top_offset + self::SCORE_BAND_HEIGHT; return [ 'score_value_bottom_offset' => $score_value_bottom_offset, 'band_rect_top_offset' => $band_rect_top_offset, 'band_rect_bottom_offset' => $band_rect_bottom_offset, 'explain_first_baseline_offset' => $band_rect_bottom_offset + self::SCORE_CARD_EXPLAIN_GAP_AFTER_BAND + self::SCORE_CARD_EXPLAIN_TEXT_CLEARANCE, 'explain_line_height' => self::SCORE_EXPLANATION_LINE_HEIGHT, ]; } /** * Score-card geometry for layout regression tests (offsets from panel top, PDF points). * * @return array{ * panel_height: float, * score_value_bottom_offset: float, * band_rect_top_offset: float, * band_rect_bottom_offset: float, * explain_first_baseline_offset: float, * explain_line_height: float * } */ public static function score_card_layout_metrics(string $explanation): array { $offsets = self::score_card_layout_offsets(); return [ 'panel_height' => self::measure_score_card_height($explanation), ] + $offsets; } /** * @param array $evidence * @return list */ public static function owner_evidence_lines(array $evidence): array { $http_status = null; $path = null; $extra = []; foreach ($evidence as $key => $value) { if (!is_scalar($value)) { continue; } $normalized = self::normalize_evidence_key((string) $key); if ($normalized === '' || self::is_internal_evidence_key($normalized)) { continue; } if ($normalized === 'httpstatus') { $http_status = (int) $value; continue; } if ($normalized === 'path') { $path = (string) $value; continue; } $sentence = self::owner_evidence_sentence($normalized, (string) $value); if ($sentence !== '') { $extra[] = $sentence; } } $lines = []; if ($http_status !== null) { $status_sentence = self::owner_http_status_sentence($http_status, $path); if ($status_sentence !== '') { $lines[] = $status_sentence; } } elseif ($path !== null && trim($path) !== '') { $path_sentence = self::owner_path_check_sentence($path); if ($path_sentence !== '') { $lines[] = $path_sentence; } } foreach ($extra as $sentence) { if (!in_array($sentence, $lines, true)) { $lines[] = $sentence; } } $deduped = []; $result = []; foreach ($lines as $line) { $clean = self::sanitize_field($line, 240); if ($clean === '' || self::is_redacted_evidence_line($clean) || isset($deduped[$clean])) { continue; } $deduped[$clean] = true; $result[] = $clean; if (count($result) >= self::MAX_EVIDENCE_ENTRIES) { break; } } return $result; } public static function same_owner_sentence(string $left, string $right): bool { $normalized = self::normalize_owner_sentence($left); return $normalized !== '' && $normalized === self::normalize_owner_sentence($right); } private static function normalize_owner_sentence(string $text): string { $text = strtolower(trim($text)); $text = preg_replace('/[^a-z0-9]+/', ' ', $text) ?? $text; return trim($text); } private static function category_why_fallback(string $category): string { return match ($category) { 'discoverability' => 'Agents need a reliable way to find the public pages you intend to expose.', 'understanding' => 'Agents need accurate public context to describe this site without guessing.', 'trust' => 'Trust signals help agents decide whether they can rely on this site.', 'actions' => 'Clear intent helps agents know which public actions are allowed.', 'safety' => 'Safety checks keep private or ineligible content out of agent-facing files.', default => '', }; } private static function category_impact_fallback(string $category, bool $incomplete): string { if ($incomplete) { return 'This check could not be confirmed from the latest local scan.'; } return match ($category) { 'discoverability' => 'A site visitor or AI agent may miss public pages that should be easy to find.', 'understanding' => 'An AI agent may misdescribe this site or skip important public pages.', 'trust' => 'A site visitor or AI agent may treat this site as less reliable than it should be.', 'actions' => 'An AI agent may not know which public actions are allowed.', 'safety' => 'Private or ineligible content could be exposed to AI agents.', default => 'A site visitor or AI agent may not get a reliable result from this check until it is resolved.', }; } private static function is_redacted_evidence_line(string $line): bool { $label = ''; $value = $line; $colon = strpos($line, ':'); if ($colon !== false) { $label = strtolower(trim(substr($line, 0, $colon))); $value = trim(substr($line, $colon + 1)); } if ($label !== '' && (str_contains($label, 'hash') || str_contains($label, 'markpath') || $label === 'sha256')) { return true; } return self::is_redacted_evidence_value($value) || self::is_redacted_evidence_value($line); } private static function normalize_evidence_key(string $key): string { return preg_replace('/[^a-z0-9]/', '', strtolower($key)) ?? ''; } private static function is_internal_evidence_key(string $normalized_key): bool { if ($normalized_key === '' || str_contains($normalized_key, 'hash')) { return true; } return in_array( $normalized_key, [ 'markpath', 'payloadhash', 'physicalpath', 'contentids', 'note', ], true ); } private static function owner_path_subject(?string $path): string { if ($path === null || trim($path) === '') { return 'The page'; } $lower = strtolower($path); return match (true) { str_contains($lower, 'llms.txt') => 'The AI guidance file', str_contains($lower, 'agent-map') => 'The AI discovery map', str_contains($lower, 'sitemap') => 'The XML sitemap', str_contains($lower, 'robots.txt') => 'The robots.txt file', default => 'The page', }; } private static function owner_http_status_sentence(int $status, ?string $path): string { $subject = self::owner_path_subject($path); return match (true) { $status === 404 => $subject . ' was not available when checked.', $status === 403 => $subject . ' could not be accessed when checked.', $status >= 500 => $subject . ' returned an error when checked.', $status >= 400 => $subject . ' could not be read when checked.', $status >= 200 && $status < 300 => $subject . ' responded when checked.', default => $subject . ' could not be verified when checked.', }; } private static function owner_path_check_sentence(string $path): string { return self::owner_path_subject($path) . ' was checked during the scan.'; } private static function owner_evidence_sentence(string $normalized_key, string $value): string { $clean = self::sanitize_evidence_value($value); if ($clean === '') { return ''; } return match ($normalized_key) { 'contenttype' => 'The response type was checked during the scan.', 'bytelength' => 'The response size was checked during the scan.', 'error' => str_contains(strtolower($clean), 'too large') ? 'The response was too large to read safely during the scan.' : 'An error was recorded during the scan.', 'blogpublic' => 'Search visibility settings were checked during the scan.', 'artifactspublished' => 'Published discovery files were checked during the scan.', 'robotsstatus' => 'The robots.txt file was checked during the scan.', 'routeconflict' => 'A route conflict was detected during the scan.', 'https' => in_array(strtolower($clean), ['1', 'true', 'yes'], true) ? 'HTTPS is in use on the public site.' : (in_array(strtolower($clean), ['0', 'false', 'no'], true) ? 'HTTPS was not confirmed on the public site.' : ''), 'killswitch' => in_array(strtolower($clean), ['1', 'true', 'yes', 'on', 'enabled'], true) ? 'The emergency stop is currently enabled.' : (in_array(strtolower($clean), ['0', 'false', 'no', 'off', 'disabled'], true) ? 'The emergency stop is currently disabled.' : ''), 'ageseconds' => 'The age of the published discovery files was checked during the scan.', default => '', }; } public static function safe_filename(string $site_name, string $date_ymd): string { $date = preg_replace('/[^0-9]/', '', $date_ymd) ?? ''; $date = substr($date, 0, 8); if ($date === '') { $date = gmdate('Ymd'); } $slug = strtolower(self::transliterate($site_name)); $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; $slug = trim($slug, '-'); $slug = substr($slug, 0, self::MAX_FILENAME_SLUG); if ($slug === '') { $slug = 'site'; } $name = 'agentic-bridge-readiness-' . $slug . '-' . $date . '.pdf'; $name = str_replace(["\r", "\n", '"', "'", '/', '\\', '..'], '', $name); return $name; } /** * @return array{rgb: string, alpha: string, width: int, height: int} */ public static function load_official_mark(string $path): array { if (!is_file($path) || !is_readable($path)) { throw new RuntimeException('Official brand mark missing'); } $bytes = (string) file_get_contents($path); if (hash('sha256', $bytes) !== self::MARK_SHA256) { throw new RuntimeException('Official brand mark hash mismatch'); } if (!str_starts_with($bytes, "\x89PNG\r\n\x1a\n")) { throw new RuntimeException('Official brand mark is not PNG'); } $offset = 8; $width = 0; $height = 0; $bit_depth = 0; $color_type = -1; $interlace = 1; $idat = ''; $len = strlen($bytes); while ($offset + 8 <= $len) { $length = unpack('N', substr($bytes, $offset, 4)); $chunk_len = is_array($length) ? (int) $length[1] : 0; $type = substr($bytes, $offset + 4, 4); $data = substr($bytes, $offset + 8, $chunk_len); $offset += 12 + $chunk_len; if ($type === 'IHDR') { $ihdr = unpack('Nwidth/Nheight/Cbit/Ccolor/Ccomp/Cfilter/Cinterlace', $data); if (!is_array($ihdr)) { throw new RuntimeException('Invalid PNG IHDR'); } $width = (int) $ihdr['width']; $height = (int) $ihdr['height']; $bit_depth = (int) $ihdr['bit']; $color_type = (int) $ihdr['color']; $interlace = (int) $ihdr['interlace']; } elseif ($type === 'IDAT') { $idat .= $data; } elseif ($type === 'IEND') { break; } } if ($width !== self::MARK_WIDTH || $height !== self::MARK_HEIGHT || $bit_depth !== 8 || $color_type !== 6 || $interlace !== 0) { throw new RuntimeException('Official brand mark shape/type unexpected'); } $raw = @gzuncompress($idat); if (!is_string($raw) || $raw === '') { $raw = @gzinflate($idat); } if (!is_string($raw) || $raw === '') { throw new RuntimeException('Unable to inflate official brand mark'); } $stride = 1 + ($width * 4); $expected = $stride * $height; if (strlen($raw) < $expected) { throw new RuntimeException('Official brand mark pixel data truncated'); } $rgba = self::unfilter_png($raw, $width, $height, 4); $rgb = ''; $alpha = ''; $pixels = $width * $height; for ($i = 0; $i < $pixels; ++$i) { $o = $i * 4; $rgb .= $rgba[$o] . $rgba[$o + 1] . $rgba[$o + 2]; $alpha .= $rgba[$o + 3]; } return [ 'rgb' => $rgb, 'alpha' => $alpha, 'width' => $width, 'height' => $height, ]; } public static function sanitize_field(string $value, int $max): string { $value = preg_replace('/<(script|style)\b[^>]*>.*?<\/\1>/is', '', $value) ?? ''; $value = wp_strip_all_tags($value); $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $value = wp_strip_all_tags($value); $value = preg_replace('/<\/?[a-zA-Z][^>]*>/', '', $value) ?? ''; $value = str_ireplace([' $finding * @return list */ public static function finding_affected_urls(array $finding): array { $candidates = []; foreach (['affectedUrls', 'urls', 'url', 'pageUrl'] as $key) { if (isset($finding[$key])) { $candidates[] = $finding[$key]; } } $evidence = is_array($finding['evidence'] ?? null) ? $finding['evidence'] : []; foreach (['affectedUrls', 'urls', 'url', 'pageUrl'] as $key) { if (isset($evidence[$key])) { $candidates[] = $evidence[$key]; } } foreach ($evidence as $value) { $candidates[] = $value; } $urls = []; $collect = static function (mixed $value) use (&$collect, &$urls): void { if (is_array($value)) { foreach ($value as $item) { $collect($item); } return; } if (!is_scalar($value) && $value !== null) { return; } $text = trim((string) $value); if ($text === '' || preg_match('#^https?://[^\s<>"\']+#i', $text, $match) !== 1) { return; } $url = TheDotDevAgenticBridge_Readiness_PDF::sanitize_field(rtrim($match[0], '.,);'), 200); if ($url !== '' && preg_match('#^https?://#i', $url) === 1) { $urls[$url] = true; } }; foreach ($candidates as $candidate) { $collect($candidate); } return array_slice(array_keys($urls), 0, 8); } /** * Stored http(s) URL(s), or the exact site-wide fallback. Never fabricates site+path URLs. * * @param array $finding */ public static function finding_affected_location(array $finding): string { $urls = self::finding_affected_urls($finding); if ($urls !== []) { return implode("\n", $urls); } return self::SITE_WIDE_FALLBACK; } /** * Compact page-one count blocks. Never a run-on "Action needed: N. Needs review: N. All clear: N." sentence. * * @param list> $findings * @return list */ public static function summary_count_blocks(array $findings): array { $counts = class_exists('TheDotDevAgenticBridge_Findings_Presentation') ? TheDotDevAgenticBridge_Findings_Presentation::triage_summary_counts($findings) : ['action_needed' => 0, 'needs_review' => 0, 'all_clear' => 0]; return [ ['label' => 'Action needed', 'count' => (int) $counts['action_needed']], ['label' => 'Needs review', 'count' => (int) $counts['needs_review']], ['label' => 'All clear', 'count' => (int) $counts['all_clear']], ]; } /** * @param array $scan * @param list> $findings */ public static function executive_summary(array $scan, array $findings = []): string { if ($findings === [] && is_array($scan['findings'] ?? null)) { $findings = $scan['findings']; } $score = (int) ($scan['score'] ?? 0); $band = self::score_band($score); $blocks = self::summary_count_blocks($findings); $needed = (int) ($blocks[0]['count'] ?? 0); $review = (int) ($blocks[1]['count'] ?? 0); $clear = (int) ($blocks[2]['count'] ?? 0); return self::sanitize_field( 'This local scan scored ' . $score . ' of 100 (' . $band . '). ' . $needed . ' check(s) need action, ' . $review . ' need review, and ' . $clear . ' are clear.', 800 ); } /** * @param list> $findings */ public static function recommended_next_step(array $findings): string { foreach ($findings as $finding) { if (!is_array($finding)) { continue; } $status = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::normalize_status((string) ($finding['status'] ?? '')) : (string) ($finding['status'] ?? ''); if (!in_array($status, ['fail', 'warning', 'unknown'], true)) { continue; } $next = class_exists('TheDotDevAgenticBridge_Findings_Presentation') ? TheDotDevAgenticBridge_Findings_Presentation::owner_next($finding) : (string) ($finding['nextStep'] ?? ''); $next = self::sanitize_field($next, self::MAX_FIELD_CHARS); $technical = class_exists('TheDotDevAgenticBridge_Findings_Presentation') && TheDotDevAgenticBridge_Findings_Presentation::looks_like_technical_evidence($next); if ($next !== '' && !$technical) { return $next; } } return 'No further owner action is required from this scan.'; } public static function transliterate(string $value): string { $map = [ '—' => '-', '–' => '-', '‘' => "'", '’' => "'", '“' => '"', '”' => '"', '…' => '...', '•' => '*', '®' => '(R)', '©' => '(C)', '™' => '(TM)', 'á' => 'a', 'à' => 'a', 'ä' => 'a', 'â' => 'a', 'ã' => 'a', 'é' => 'e', 'è' => 'e', 'ë' => 'e', 'ê' => 'e', 'í' => 'i', 'ì' => 'i', 'ï' => 'i', 'î' => 'i', 'ó' => 'o', 'ò' => 'o', 'ö' => 'o', 'ô' => 'o', 'õ' => 'o', 'ú' => 'u', 'ù' => 'u', 'ü' => 'u', 'û' => 'u', 'ñ' => 'n', 'ç' => 'c', 'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ú' => 'U', ]; $value = strtr($value, $map); if (function_exists('iconv')) { $converted = @iconv('UTF-8', 'ISO-8859-1//TRANSLIT//IGNORE', $value); if (is_string($converted)) { return $converted; } } return $value; } /** * @param array $scan * @param array $meta * @return array */ private static function build_context(array $scan, array $meta): array { $findings = is_array($scan['findings'] ?? null) ? $scan['findings'] : []; $findings = array_values(array_filter($findings, static fn(mixed $finding): bool => is_array($finding))); usort($findings, static function (array $a, array $b): int { $cat = self::category_rank((string) ($a['category'] ?? '')) <=> self::category_rank((string) ($b['category'] ?? '')); if ($cat !== 0) { return $cat; } $rank_a = self::severity_rank((string) ($a['severity'] ?? '')); $rank_b = self::severity_rank((string) ($b['severity'] ?? '')); if ($rank_a !== $rank_b) { return $rank_a <=> $rank_b; } return strcmp((string) ($a['title'] ?? ''), (string) ($b['title'] ?? '')); }); $appendix_blocks = self::appendix_blocks($findings); $site_name = (string) ($meta['siteName'] ?? ''); if ($site_name === '') { $site_name = (string) ($scan['siteName'] ?? ''); } $site_url = (string) ($meta['siteUrl'] ?? ''); if ($site_url === '') { $site_url = (string) ($scan['siteUrl'] ?? ''); } return [ 'generatedAt' => self::sanitize_field((string) ($meta['generatedAt'] ?? gmdate('c')), 64), 'siteName' => self::sanitize_field($site_name, 120), 'siteUrl' => self::sanitize_field($site_url, 200), 'pluginName' => self::sanitize_field(self::customer_facing_plugin_name((string) ($meta['pluginName'] ?? '')), 120), 'pluginVersion' => self::sanitize_field((string) ($meta['pluginVersion'] ?? (defined('THEDOTDEV_AGENTIC_BRIDGE_VERSION') ? THEDOTDEV_AGENTIC_BRIDGE_VERSION : '')), 32), 'score' => (int) ($scan['score'] ?? 0), 'scannedAt' => self::sanitize_field((string) ($scan['scannedAt'] ?? ''), 64), 'methodologyVersion' => self::sanitize_field((string) ($scan['methodologyVersion'] ?? ''), 32), 'categories' => self::normalize_category_scores( is_array($scan['categories'] ?? null) ? $scan['categories'] : [] ), 'findings' => $findings, 'scoreBand' => self::score_band((int) ($scan['score'] ?? 0)), 'scoreBandExplanation' => self::score_band_explanation((int) ($scan['score'] ?? 0)), 'summaryCountBlocks' => self::summary_count_blocks($findings), 'recommendedNextStep' => self::recommended_next_step($findings), 'executiveSummary' => self::executive_summary($scan, $findings), 'appendixBlocks' => $appendix_blocks, 'inlineAppendixEvidence' => false, ]; } public static function sanitize_evidence_value(string $value): string { $value = self::sanitize_field($value, 240); if ($value === '' || self::is_redacted_evidence_value($value)) { return ''; } return $value; } private static function is_redacted_evidence_value(string $value): bool { $lower = strtolower($value); if (str_contains($lower, 'assets/brand')) { return true; } if (str_contains($lower, 'verified-span-mark')) { return true; } if (str_contains($lower, 'wp-content/plugins')) { return true; } if (str_contains($lower, 'plugins/agentic-bridge')) { return true; } if (str_contains($lower, 'thedotdev_agentic_bridge')) { return true; } if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) { return true; } return false; } private static function category_rank(string $category): int { return match ($category) { 'discoverability' => 0, 'understanding' => 1, 'trust' => 2, 'actions' => 3, 'safety' => 4, default => 5, }; } private static function severity_rank(string $severity): int { return match (strtolower($severity)) { 'high' => 0, 'medium' => 1, 'info' => 2, default => 3, }; } private static function severity_label(string $severity): string { return match (strtolower($severity)) { 'high' => 'High', 'medium' => 'Medium', 'info' => 'Review', default => ucfirst($severity), }; } /** * @return array{0: float, 1: float, 2: float} */ private static function severity_color(string $severity): array { return match (strtolower($severity)) { 'high' => self::COLOR_HIGH, 'medium', 'info' => self::COLOR_MEDIUM, default => [0.314, 0.341, 0.369], }; } private static function format_display_date(string $iso): string { if ($iso === '') { return 'Unavailable'; } $ts = strtotime($iso); if ($ts === false) { return $iso; } return gmdate('j M Y, H:i', $ts) . ' UTC'; } /** * @param array $context * @return list> */ private static function layout_pages(array $context): array { $pages = []; $canvas = new TheDotDevAgenticBridge_Readiness_PDF_Canvas($pages); $canvas->render_cover($context); $canvas->render_findings_pages($context); $canvas->render_trailing_sections($context); return $canvas->finalize(); } /** * @param list> $pages */ private static function assemble_pdf(array $pages): string { if ($pages === []) { $pages = [['']]; } $objects = []; $objects[1] = '<< /Type /Catalog /Pages 2 0 R >>'; $font_regular = 3; $font_bold = 4; $first_page_obj = 5; $page_count = count($pages); $page_objs = []; for ($i = 0; $i < $page_count; ++$i) { $page_objs[] = $first_page_obj + ($i * 2); } $kids = implode(' ', array_map(static fn(int $n): string => $n . ' 0 R', $page_objs)); $objects[2] = '<< /Type /Pages /Kids [' . $kids . '] /Count ' . $page_count . ' >>'; $objects[$font_regular] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'; $objects[$font_bold] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>'; $stream_blobs = []; foreach ($pages as $pi => $page_ops) { $page_obj = $page_objs[$pi]; $content_obj = $page_obj + 1; // Isolate each physical page's graphics state. This prevents a malformed // or partially consumed state in one operator sequence from affecting the // rest of the page in stricter PDF renderers. $content = "q\n" . implode("\n", $page_ops) . "\nQ\n"; $objects[$page_obj] = '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' . self::PAGE_WIDTH . ' ' . self::PAGE_HEIGHT . '] /Resources << /Font << /F1 ' . $font_regular . ' 0 R /F2 ' . $font_bold . ' 0 R >> >> /Contents ' . $content_obj . ' 0 R >>'; $objects[$content_obj] = '<< /Length ' . strlen($content) . ' >>'; $stream_blobs[$content_obj] = $content; } ksort($objects, SORT_NUMERIC); $pdf = "%PDF-1.4\n"; $offsets = [0]; foreach ($objects as $num => $dict) { $offsets[(int) $num] = strlen($pdf); if (isset($stream_blobs[$num])) { $pdf .= $num . " 0 obj\n" . $dict . "\nstream\n" . $stream_blobs[$num] . "\nendstream\nendobj\n"; } else { $pdf .= $num . " 0 obj\n" . $dict . "\nendobj\n"; } } $xref_pos = strlen($pdf); $max_obj = max(array_keys($objects)); $pdf .= "xref\n0 " . ($max_obj + 1) . "\n"; $pdf .= "0000000000 65535 f \n"; for ($i = 1; $i <= $max_obj; ++$i) { $off = $offsets[$i] ?? 0; $pdf .= sprintf("%010d 00000 n \n", $off); } $pdf .= "trailer\n<< /Size " . ($max_obj + 1) . " /Root 1 0 R >>\nstartxref\n" . $xref_pos . "\n%%EOF\n"; return $pdf; } private static function unfilter_png(string $raw, int $width, int $height, int $bpp): string { $stride = $width * $bpp; $out = ''; $prev = str_repeat("\0", $stride); $pos = 0; for ($y = 0; $y < $height; ++$y) { $filter = ord($raw[$pos]); ++$pos; $row = substr($raw, $pos, $stride); $pos += $stride; $recon = ''; for ($i = 0; $i < $stride; ++$i) { $x = ord($row[$i]); $a = $i >= $bpp ? ord($recon[$i - $bpp]) : 0; $b = ord($prev[$i]); $c = ($i >= $bpp) ? ord($prev[$i - $bpp]) : 0; $val = match ($filter) { 0 => $x, 1 => ($x + $a) & 255, 2 => ($x + $b) & 255, 3 => ($x + intdiv($a + $b, 2)) & 255, 4 => ($x + self::paeth($a, $b, $c)) & 255, default => throw new RuntimeException('Unsupported PNG filter'), }; $recon .= chr($val); } $out .= $recon; $prev = $recon; } return $out; } private static function paeth(int $a, int $b, int $c): int { $p = $a + $b - $c; $pa = abs($p - $a); $pb = abs($p - $b); $pc = abs($p - $c); if ($pa <= $pb && $pa <= $pc) { return $a; } if ($pb <= $pc) { return $b; } return $c; } } /** * @internal PDF page layout canvas (content-stream ops). */ final class TheDotDevAgenticBridge_Readiness_PDF_Canvas { private const PAGE_WIDTH = 612; private const PAGE_HEIGHT = 792; private const MARGIN_LEFT = 48; private const CONTENT_WIDTH = 499; private const FOOTER_BASELINE = TheDotDevAgenticBridge_Readiness_PDF::FOOTER_BASELINE; private const BODY_TOP = 708; private const BODY_BOTTOM = TheDotDevAgenticBridge_Readiness_PDF::BODY_BOTTOM; private const CONTINUATION_BODY_TOP = 708; /** @var list> */ private array $pages; /** @var list */ private array $ops = []; /** @var array|null */ private ?array $header_context = null; private bool $section_active = false; private bool $inline_appendix_evidence = false; private float $y = self::BODY_TOP; private int $page_index = 0; private int $total_pages = 1; private string $last_page_footer_note = ''; /** * @param list> $pages */ public function __construct(array &$pages) { $this->pages = &$pages; $this->pages[] = []; $this->ops = &$this->pages[0]; } /** * @return list> */ public function finalize(): array { $this->total_pages = count($this->pages); $this->last_page_footer_note = $this->build_last_page_footer_note(); for ($i = 0; $i < $this->total_pages; ++$i) { $this->pages[$i] = $this->decorate_page($this->pages[$i], $i + 1); } return $this->pages; } /** * @param array $context */ public function render_cover(array $context): void { $this->draw_light_header_band(self::PAGE_HEIGHT - 128, 128); $this->text_at(self::MARGIN_LEFT, self::PAGE_HEIGHT - 72, 'Agentic Bridge', 22, true, 0.090, 0.125, 0.200); $this->text_at(self::MARGIN_LEFT, self::PAGE_HEIGHT - 94, 'Agent Ready for WordPress', 12, false, 0.031, 0.498, 0.435); $this->text_at(self::MARGIN_LEFT, self::PAGE_HEIGHT - 112, 'Local Readiness Report', 10, false, 0.322, 0.384, 0.478); $site_name = (string) ($context['siteName'] ?? ''); if ($site_name === '') { $site_name = 'Unnamed site'; } $identity_y = self::PAGE_HEIGHT - 156; $this->text_at(self::MARGIN_LEFT, $identity_y, $site_name, 20, true, 0.118, 0.118, 0.118); $site_url = (string) ($context['siteUrl'] ?? ''); if ($site_url !== '') { $this->text_at(self::MARGIN_LEFT, $identity_y - 20, 'Site URL scanned', 9, true, 0.314, 0.341, 0.369); $site_url_y = $identity_y - 36; $this->text_block(self::MARGIN_LEFT, $site_url_y, $site_url, 10, false, 0.314, 0.341, 0.369, 80); $after_identity = $site_url_y - 24; } else { $this->text_at(self::MARGIN_LEFT, $identity_y - 22, 'Site URL scanned: Unavailable', 10, false, 0.314, 0.341, 0.369); $after_identity = $identity_y - 40; } $score = (int) ($context['score'] ?? 0); $score_palette = TheDotDevAgenticBridge_Readiness_PDF::score_band_palette($score); $score_accent = $score_palette['accent']; $score_background = $score_palette['background']; $explanation = (string) ($context['scoreBandExplanation'] ?? ''); $score_panel_h = TheDotDevAgenticBridge_Readiness_PDF::measure_score_card_height($explanation); $score_panel_top = $after_identity - 12; $score_panel_y = $score_panel_top - $score_panel_h; $this->fill_rect(self::MARGIN_LEFT, $score_panel_y, self::CONTENT_WIDTH, $score_panel_h, 0.965, 0.969, 0.969); $this->stroke_rect(self::MARGIN_LEFT, $score_panel_y, self::CONTENT_WIDTH, $score_panel_h, 0.863, 0.863, 0.871); $this->fill_rect(self::MARGIN_LEFT, $score_panel_y, 6, $score_panel_h, $score_accent[0], $score_accent[1], $score_accent[2]); $score_layout = TheDotDevAgenticBridge_Readiness_PDF::score_card_layout_metrics($explanation); $this->text_at(self::MARGIN_LEFT + 20, $score_panel_top - 22, 'Agent Ready score', 10, true, 0.314, 0.341, 0.369); $score_baseline = $score_panel_top - TheDotDevAgenticBridge_Readiness_PDF::SCORE_VALUE_BASELINE_OFFSET; $this->text_at( self::MARGIN_LEFT + 20, $score_baseline, (string) $score, (int) TheDotDevAgenticBridge_Readiness_PDF::SCORE_VALUE_FONT_SIZE, true, $score_accent[0], $score_accent[1], $score_accent[2] ); $this->text_at( self::MARGIN_LEFT + TheDotDevAgenticBridge_Readiness_PDF::SCORE_HUNDRED_X_OFFSET, $score_baseline, '/100', 18, false, 0.314, 0.341, 0.369 ); $band = (string) ($context['scoreBand'] ?? ''); if ($band !== '') { $band_pad_x = TheDotDevAgenticBridge_Readiness_PDF::SCORE_BAND_PAD_X; $band_h = TheDotDevAgenticBridge_Readiness_PDF::SCORE_BAND_HEIGHT; $band_w = max( 120.0, ($band_pad_x * 2.0) + (strlen($band) * 6.2) ); $band_x = self::MARGIN_LEFT + 20; $band_bottom = $score_panel_top - (float) $score_layout['band_rect_bottom_offset']; $this->fill_rect( $band_x, $band_bottom, $band_w, $band_h, $score_background[0], $score_background[1], $score_background[2] ); $this->stroke_rect( $band_x, $band_bottom, $band_w, $band_h, $score_accent[0], $score_accent[1], $score_accent[2] ); $this->text_at( $band_x + $band_pad_x, $band_bottom + TheDotDevAgenticBridge_Readiness_PDF::SCORE_BAND_LABEL_OFFSET, $band, 10, true, $score_accent[0], $score_accent[1], $score_accent[2], 48 ); } $right_x = self::MARGIN_LEFT + 248; $explain_y = $score_panel_top - 24; $explain_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text( $explanation, TheDotDevAgenticBridge_Readiness_PDF::SCORE_EXPLANATION_WRAP_CHARS ); $this->text_block( $right_x, $explain_y, $explanation, 9, false, 0.314, 0.341, 0.369, 42 ); $meta_y = $explain_y - (max(1, count($explain_lines)) * TheDotDevAgenticBridge_Readiness_PDF::SCORE_EXPLANATION_LINE_HEIGHT) - 12.0; $this->text_at($right_x, $meta_y, 'Scanned', 9, true, 0.314, 0.341, 0.369); $this->text_at($right_x + 58, $meta_y, self::format_display_date((string) ($context['scannedAt'] ?? '')), 9, false, 0.118, 0.118, 0.118, 32); $meta_y -= 16; $this->text_at($right_x, $meta_y, 'Generated', 9, true, 0.314, 0.341, 0.369); $this->text_at($right_x + 58, $meta_y, self::format_display_date((string) ($context['generatedAt'] ?? '')), 9, false, 0.118, 0.118, 0.118, 32); $method = trim((string) ($context['methodologyVersion'] ?? '')); if ($method !== '') { $meta_y -= 16; $this->text_at($right_x, $meta_y, 'Methodology', 9, true, 0.314, 0.341, 0.369); $this->text_at($right_x + 70, $meta_y, $method, 9, false, 0.118, 0.118, 0.118, 20); } unset($score_layout); $how_to = 'Start with the most important next step, review its evidence, then check again after changes. This is a local checklist and does not guarantee search, AI, traffic, ranking, or citation outcomes.'; $this->text_at(self::MARGIN_LEFT, $score_panel_y - 16, 'How to use this report', 10, true, 0.118, 0.118, 0.118); $how_to_y = $score_panel_y - 32; $how_to_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($how_to, TheDotDevAgenticBridge_Readiness_PDF::HOW_TO_WRAP_CHARS); $this->text_block(self::MARGIN_LEFT, $how_to_y, $how_to, 8, false, 0.314, 0.341, 0.369, TheDotDevAgenticBridge_Readiness_PDF::HOW_TO_WRAP_CHARS); $summary_y = $how_to_y - (max(1, count($how_to_lines)) * TheDotDevAgenticBridge_Readiness_PDF::HOW_TO_LINE_HEIGHT) - TheDotDevAgenticBridge_Readiness_PDF::HOW_TO_SUMMARY_GAP; $this->text_at(self::MARGIN_LEFT, $summary_y, 'Executive summary', 13, true, 0.118, 0.118, 0.118); $this->y = $summary_y - 16; $exec = trim((string) ($context['executiveSummary'] ?? '')); if ($exec !== '') { $exec_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($exec, 86); $this->text_block(self::MARGIN_LEFT, $this->y, $exec, 10, false, 0.314, 0.341, 0.369, 86); $this->y -= (max(1, count($exec_lines)) * 12) + 8; } $count_blocks = is_array($context['summaryCountBlocks'] ?? null) ? $context['summaryCountBlocks'] : []; $this->render_summary_count_blocks($count_blocks); $this->header_context = $context; $this->section_active = true; $next_step = (string) ($context['recommendedNextStep'] ?? ''); $callout_h = TheDotDevAgenticBridge_Readiness_PDF::measure_next_step_callout_height($next_step); $this->ensure_space( (int) ceil( $callout_h + TheDotDevAgenticBridge_Readiness_PDF::NEXT_STEP_CALLOUT_AFTER_GAP + TheDotDevAgenticBridge_Readiness_PDF::PAGE_ONE_SPLIT_MIN ), true ); $this->render_recommended_next_step_callout($next_step); $this->render_page_one_split($context); $this->y -= 12; $category_needed = TheDotDevAgenticBridge_Readiness_PDF::measure_category_section_height( 'Each category is scored out of its cap from the latest local scan.' ); if ($this->y - $category_needed < self::BODY_BOTTOM) { $this->header_context = $context; $this->start_continuation_page(); $this->draw_running_header($context); $this->y = self::BODY_TOP; } $this->text_at(self::MARGIN_LEFT, $this->y, 'Readiness by category', 13, true, 0.118, 0.118, 0.118); $this->y -= 16; $this->section_intro('Each category is scored out of its cap from the latest local scan.'); $categories = is_array($context['categories'] ?? null) ? $context['categories'] : []; if ($categories === []) { $this->text_at(self::MARGIN_LEFT, $this->y, 'Category breakdown: Unavailable in the latest scan.', 11, false, 0.314, 0.341, 0.369); $this->y -= 24; } else { $this->render_category_cards($context, TheDotDevAgenticBridge_Readiness_PDF::CATEGORY_COLUMNS); $this->y -= 8; } $this->header_context = $context; $this->section_active = true; } /** * @param array $context */ public function render_findings_pages(array $context): void { $this->inline_appendix_evidence = false; $findings = is_array($context['findings'] ?? null) ? $context['findings'] : []; $prioritized = TheDotDevAgenticBridge_Readiness_PDF::prioritized_findings($findings); $this->start_section($context, 'Prioritized findings', TheDotDevAgenticBridge_Readiness_PDF::FINDING_SECTION_MIN); if ($prioritized === []) { $this->section_intro('No actionable findings were stored in the latest scan.'); } else { $this->section_intro('Actionable checks ranked by severity. Technical evidence is in the appendix only.'); $rank = 0; foreach ($prioritized as $finding) { if (!is_array($finding)) { continue; } ++$rank; $this->render_finding_card($rank, $finding, ''); } } $this->start_section($context, 'Detailed findings', TheDotDevAgenticBridge_Readiness_PDF::FINDING_SECTION_MIN); if ($findings === []) { $this->section_intro('No findings were stored in the latest scan.'); return; } $this->section_intro('Every stored finding appears once, grouped by category.'); foreach (TheDotDevAgenticBridge_Readiness_PDF::group_findings_by_category($findings) as $category_key => $group) { $heading = TheDotDevAgenticBridge_Readiness_PDF::category_group_heading((string) $category_key); $first_in_group = true; $index = 0; foreach ($group as $finding) { if (!is_array($finding)) { continue; } ++$index; $this->render_finding_card($index, $finding, $first_in_group ? $heading : ''); $first_in_group = false; } } } /** * @param array $context */ private function render_page_one_split(array $context): void { $findings = is_array($context['findings'] ?? null) ? $context['findings'] : []; $strengths = TheDotDevAgenticBridge_Readiness_PDF::page_one_strengths($findings); $attention = array_slice(TheDotDevAgenticBridge_Readiness_PDF::prioritized_findings($findings), 0, 3); $col_w = (self::CONTENT_WIDTH - 16) / 2; $left_x = self::MARGIN_LEFT; $right_x = self::MARGIN_LEFT + $col_w + 16; $top = $this->y; $this->text_at($left_x, $top, 'What is working', 11, true, 0.039, 0.490, 0.420, 36); $this->text_at($right_x, $top, 'Needs attention next', 11, true, 0.718, 0.475, 0.122, 36); $left_y = $top - 16; $right_y = $top - 16; if ($strengths === []) { $this->text_at($left_x, $left_y, 'No confirmed strengths in this scan.', 9, false, 0.314, 0.341, 0.369, 38); $left_y -= 16; } foreach ($strengths as $finding) { $title = TheDotDevAgenticBridge_Readiness_PDF::finding_display_title($finding); $lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text('• ' . $title, 38); foreach ($lines as $line) { $this->text_at($left_x, $left_y, $line, 9, false, 0.118, 0.118, 0.118, 38); $left_y -= 12; } } if ($attention === []) { $this->text_at($right_x, $right_y, 'No actionable findings in this scan.', 9, false, 0.314, 0.341, 0.369, 38); $right_y -= 16; } foreach ($attention as $finding) { $title = TheDotDevAgenticBridge_Readiness_PDF::finding_display_title($finding); $priority = TheDotDevAgenticBridge_Readiness_PDF::priority_label((string) ($finding['severity'] ?? '')); $lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text('• ' . $priority . ': ' . $title, 38); foreach ($lines as $line) { $this->text_at($right_x, $right_y, $line, 9, false, 0.118, 0.118, 0.118, 38); $right_y -= 12; } } $this->y = min($left_y, $right_y) - 8; } /** * @param array $context */ public function render_trailing_sections(array $context): void { $findings = is_array($context['findings'] ?? null) ? $context['findings'] : []; $appendix_blocks = is_array($context['appendixBlocks'] ?? null) ? $context['appendixBlocks'] : TheDotDevAgenticBridge_Readiness_PDF::appendix_blocks($findings); $this->inline_appendix_evidence = false; $this->render_technical_appendix($context, $appendix_blocks); } /** * @param array $context */ private function render_category_cards(array $context, int $columns): void { $labels = [ 'discoverability' => TheDotDevAgenticBridge_Readiness_PDF::category_label('discoverability'), 'understanding' => TheDotDevAgenticBridge_Readiness_PDF::category_label('understanding'), 'trust' => TheDotDevAgenticBridge_Readiness_PDF::category_label('trust'), 'actions' => TheDotDevAgenticBridge_Readiness_PDF::category_label('actions'), 'safety' => TheDotDevAgenticBridge_Readiness_PDF::category_label('safety'), ]; $caps = class_exists('TheDotDevAgenticBridge_Readiness_Methodology') ? TheDotDevAgenticBridge_Readiness_Methodology::category_caps() : []; $categories = TheDotDevAgenticBridge_Readiness_PDF::normalize_category_scores( is_array($context['categories'] ?? null) ? $context['categories'] : [] ); $layout = TheDotDevAgenticBridge_Readiness_PDF::category_card_column_layout($columns); $columns = $layout['columns']; $card_w = $layout['width']; $x_positions = $layout['x_positions']; $card_h = TheDotDevAgenticBridge_Readiness_PDF::CATEGORY_CARD_HEIGHT; $row_gap = TheDotDevAgenticBridge_Readiness_PDF::CATEGORY_CARD_ROW_GAP; $grid_height = TheDotDevAgenticBridge_Readiness_PDF::measure_category_grid_height($columns, count($labels)); $index = 0; foreach ($labels as $key => $label) { $pts = (int) ($categories[$key] ?? 0); $cap = (int) ($caps[$key] ?? 0); $col = $index % $columns; $row = intdiv($index, $columns); $x = (float) $x_positions[$col]; $row_top = $this->y - ($row * ($card_h + $row_gap)); $this->fill_rect($x, $row_top - $card_h, $card_w, $card_h, 0.965, 0.969, 0.969); $this->stroke_rect($x, $row_top - $card_h, $card_w, $card_h, 0.863, 0.863, 0.871); $ratio = $cap > 0 ? max(0.0, min(1.0, $pts / $cap)) : 0.0; $bar_w = $cap > 0 ? max(4.0, $ratio * ($card_w - 16)) : 4.0; $category_accent = $ratio >= 1.0 ? [0.027, 0.561, 0.451] : ($ratio < 0.5 ? [0.761, 0.255, 0.047] : [0.631, 0.384, 0.027]); $this->fill_rect($x + 8, $row_top - $card_h + 8, $bar_w, 4, $category_accent[0], $category_accent[1], $category_accent[2]); $label_max = $columns >= 3 ? 18 : 34; $this->text_at($x + 8, $row_top - 16, $label, 9, true, 0.118, 0.118, 0.118, $label_max); $this->text_at($x + $card_w - 8, $row_top - 16, $pts . ($cap > 0 ? '/' . $cap : ''), 10, true, $category_accent[0], $category_accent[1], $category_accent[2], 12, 'right'); ++$index; } $this->y = $this->y - $grid_height; } private function render_recommended_next_step_callout(string $next): void { $callout_h = TheDotDevAgenticBridge_Readiness_PDF::measure_next_step_callout_height($next); $top = $this->y; $bottom = $top - $callout_h; $this->fill_rect(self::MARGIN_LEFT, $bottom, self::CONTENT_WIDTH, $callout_h, 0.965, 0.984, 0.976); $this->stroke_rect(self::MARGIN_LEFT, $bottom, self::CONTENT_WIDTH, $callout_h, 0.039, 0.490, 0.420); $this->fill_rect(self::MARGIN_LEFT, $bottom, 5, $callout_h, 0.039, 0.490, 0.420); $this->text_at( self::MARGIN_LEFT + 14, $top - 14, 'Recommended next step', 10, true, 0.039, 0.490, 0.420 ); $body_y = $top - 30; $this->text_block( self::MARGIN_LEFT + 14, $body_y, $next, 11, true, 0.118, 0.118, 0.118, 76 ); $this->y = $bottom - TheDotDevAgenticBridge_Readiness_PDF::NEXT_STEP_CALLOUT_AFTER_GAP; } private function render_finding_group_heading(string $heading): void { $band_h = TheDotDevAgenticBridge_Readiness_PDF::FINDING_GROUP_BAND_HEIGHT; $top = $this->y; $bottom = $top - $band_h; $this->fill_rect(self::MARGIN_LEFT, $bottom, self::CONTENT_WIDTH, $band_h, 0.945, 0.957, 0.976); $this->fill_rect(self::MARGIN_LEFT, $bottom, 4, $band_h, 0.090, 0.125, 0.200); $this->stroke_rect(self::MARGIN_LEFT, $bottom, self::CONTENT_WIDTH, $band_h, 0.863, 0.878, 0.902); $this->text_at( self::MARGIN_LEFT + 12, $top - TheDotDevAgenticBridge_Readiness_PDF::FINDING_GROUP_LABEL_BASELINE_OFFSET, $heading, 12, true, 0.090, 0.125, 0.200, 72 ); $this->y = $bottom - 8; } /** * @param list $blocks */ private function render_summary_count_blocks(array $blocks): void { if ($blocks === []) { return; } $layout = TheDotDevAgenticBridge_Readiness_PDF::category_card_column_layout(min(3, count($blocks))); $card_w = $layout['width']; $x_positions = $layout['x_positions']; $card_h = TheDotDevAgenticBridge_Readiness_PDF::SUMMARY_BLOCK_HEIGHT; $accents = [ TheDotDevAgenticBridge_Readiness_PDF::COLOR_HIGH, TheDotDevAgenticBridge_Readiness_PDF::COLOR_MEDIUM, [0.039, 0.490, 0.420], ]; $top = $this->y; foreach ($blocks as $index => $block) { if (!is_array($block) || !isset($x_positions[$index])) { continue; } $x = (float) $x_positions[$index]; $label = (string) ($block['label'] ?? ''); $count = (string) (int) ($block['count'] ?? 0); $this->fill_rect($x, $top - $card_h, $card_w, $card_h, 0.965, 0.969, 0.969); $this->stroke_rect($x, $top - $card_h, $card_w, $card_h, 0.863, 0.863, 0.871); [$ar, $ag, $ab] = $accents[$index] ?? [0.314, 0.341, 0.369]; $this->fill_rect($x, $top - $card_h, 5, $card_h, $ar, $ag, $ab); $this->text_at($x + 14, $top - 16, $label, 9, true, 0.314, 0.341, 0.369, 22); $this->text_at($x + 14, $top - 36, $count, 16, true, 0.118, 0.118, 0.118, 8); } $this->y = $top - $card_h - TheDotDevAgenticBridge_Readiness_PDF::SUMMARY_BLOCK_GAP; } /** * @param array $finding */ private function render_finding_card(int $index, array $finding, string $group_heading = ''): void { $copy = TheDotDevAgenticBridge_Readiness_PDF::finding_owner_copy($finding); $title = $copy['found']; $impact = $copy['impact']; $why = $copy['why']; $next = $copy['next']; $severity = (string) ($finding['severity'] ?? ''); $priority = TheDotDevAgenticBridge_Readiness_PDF::priority_label($severity); $status = TheDotDevAgenticBridge_Readiness_PDF::owner_status_label((string) ($finding['status'] ?? '')); $category = TheDotDevAgenticBridge_Readiness_PDF::category_label((string) ($finding['category'] ?? '')); $life = TheDotDevAgenticBridge_Readiness_PDF::lifecycle_label($finding); $affected_location = TheDotDevAgenticBridge_Readiness_PDF::finding_affected_location($finding); $evidence_lines = []; $meta = 'Priority: ' . $priority . ' | Category: ' . $category . ' | ' . $status; $meta_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($meta, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_META_WRAP_CHARS); if ($life !== 'From latest scan') { foreach (TheDotDevAgenticBridge_Readiness_PDF::wrap_text($life, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_META_WRAP_CHARS) as $life_line) { $meta_lines[] = $life_line; } $meta .= ' ' . $life; } $impact_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($impact, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_WRAP_CHARS); $why_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($why, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_WRAP_CHARS); $next_lines = TheDotDevAgenticBridge_Readiness_PDF::wrap_text($next, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_WRAP_CHARS); $url_lines = []; foreach (preg_split("/\n+/", $affected_location) ?: [$affected_location] as $location_line) { $location_line = trim((string) $location_line); if ($location_line === '') { continue; } foreach (TheDotDevAgenticBridge_Readiness_PDF::wrap_text($location_line, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_WRAP_CHARS) as $url_line) { $url_lines[] = $url_line; } } if ($impact_lines === [] && !TheDotDevAgenticBridge_Readiness_PDF::is_confirmed_strength($finding)) { $impact_lines = [TheDotDevAgenticBridge_Readiness_PDF::UNAVAILABLE_FIELD_TEXT]; } if ($why_lines === [] && !TheDotDevAgenticBridge_Readiness_PDF::is_confirmed_strength($finding)) { $why_lines = [TheDotDevAgenticBridge_Readiness_PDF::UNAVAILABLE_FIELD_TEXT]; } if ($next_lines === []) { $next_lines = [TheDotDevAgenticBridge_Readiness_PDF::UNAVAILABLE_FIELD_TEXT]; } if ($meta_lines === []) { $meta_lines = ['Priority: Unavailable']; } $header_depth = TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_HEADER_DEPTH + (max(0, count($meta_lines) - 1) * TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_LINE_HEIGHT); $height = TheDotDevAgenticBridge_Readiness_PDF::measure_finding_card_height( $why, $next, $meta, $affected_location, $impact, $evidence_lines ); $heading_h = $group_heading !== '' ? TheDotDevAgenticBridge_Readiness_PDF::FINDING_GROUP_HEADING_HEIGHT : 0.0; $this->ensure_space((int) ceil($heading_h + $height + TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_SPACING), true); if ($group_heading !== '') { $this->render_finding_group_heading($group_heading); } $top = $this->y; $card_bottom = $top - $height; $this->fill_rect(self::MARGIN_LEFT, $card_bottom, self::CONTENT_WIDTH, $height, 1.0, 1.0, 1.0); $this->stroke_rect(self::MARGIN_LEFT, $card_bottom, self::CONTENT_WIDTH, $height, 0.863, 0.863, 0.871); $is_strength = TheDotDevAgenticBridge_Readiness_PDF::is_confirmed_strength($finding); [$accent_r, $accent_g, $accent_b] = $is_strength ? [0.039, 0.490, 0.420] : self::severity_color($severity); $this->fill_rect(self::MARGIN_LEFT, $card_bottom, 5, $height, $accent_r, $accent_g, $accent_b); $badge = $priority; [$sr, $sg, $sb] = self::severity_color($severity); $badge_w = max(36.0, strlen($badge) * 5.5 + 12); $badge_bottom = $top - TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_TOP_PAD - TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_BADGE_HEIGHT; $this->fill_rect( self::MARGIN_LEFT + 12, $badge_bottom, $badge_w, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_BADGE_HEIGHT, $sr, $sg, $sb ); $this->text_at( self::MARGIN_LEFT + 18, $badge_bottom + TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_BADGE_LABEL_OFFSET, $badge, 8, true, 1.0, 1.0, 1.0 ); $issue_y = $top - TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_ISSUE_OFFSET; $this->text_at(self::MARGIN_LEFT + 12, $issue_y, 'What we found', 9, true, 0.314, 0.341, 0.369); $this->text_at(self::MARGIN_LEFT + 12, $issue_y - 16, $index . '. ' . $title, 12, true, 0.118, 0.118, 0.118, 72); $meta_y = $issue_y - 32; foreach ($meta_lines as $meta_line) { $this->text_at(self::MARGIN_LEFT + 12, $meta_y, $meta_line, 9, false, 0.314, 0.341, 0.369, 88); $meta_y -= 12; } $cursor = $top - $header_depth; if ($why_lines !== []) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, 'Why it matters', 9, true, 0.118, 0.118, 0.118); $cursor -= 14; foreach ($why_lines as $line) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, $line, 10, false, 0.314, 0.341, 0.369, 82); $cursor -= 12; } } if ($impact_lines !== []) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, 'Possible impact', 9, true, 0.118, 0.118, 0.118); $cursor -= 14; foreach ($impact_lines as $line) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, $line, 10, false, 0.314, 0.341, 0.369, 82); $cursor -= 12; } $cursor -= TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_IMPACT_TOP_GAP; } $cursor -= TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_NEXT_TOP_GAP; $this->text_at(self::MARGIN_LEFT + 12, $cursor, 'Recommended next step', 9, true, 0.118, 0.118, 0.118); $cursor -= 14; foreach ($next_lines as $line) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, $line, 10, false, 0.118, 0.118, 0.118, 82); $cursor -= 12; } if ($url_lines !== []) { $cursor -= TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_URLS_TOP_GAP; $this->text_at(self::MARGIN_LEFT + 12, $cursor, TheDotDevAgenticBridge_Readiness_PDF::AFFECTED_LOCATION_LABEL, 9, true, 0.118, 0.118, 0.118); $cursor -= 14; foreach ($url_lines as $line) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, $line, 10, false, 0.314, 0.341, 0.369, 82); $cursor -= 12; } } if ($evidence_lines !== []) { $cursor -= TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_EVIDENCE_TOP_GAP; foreach ($evidence_lines as $line) { foreach (TheDotDevAgenticBridge_Readiness_PDF::wrap_text('• ' . $line, TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_WRAP_CHARS) as $evidence_line) { $this->text_at(self::MARGIN_LEFT + 12, $cursor, $evidence_line, 9, false, 0.314, 0.341, 0.369, 82); $cursor -= 12; } } } $this->y = $top - $height - TheDotDevAgenticBridge_Readiness_PDF::FINDING_CARD_SPACING; } /** * @param list> $strengths */ private function render_confirmed_strengths(array $strengths): void { if ($strengths === []) { return; } $this->ensure_space(32); $this->text_at(self::MARGIN_LEFT, $this->y, 'Confirmed strengths', 13, true, 0.118, 0.118, 0.118); $this->y -= 16; foreach ($strengths as $finding) { $title = ''; if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $title = TheDotDevAgenticBridge_Readiness_PDF::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_title($finding), TheDotDevAgenticBridge_Readiness_PDF::MAX_FIELD_CHARS ); } if ($title === '') { $title = TheDotDevAgenticBridge_Readiness_PDF::sanitize_field( (string) ($finding['title'] ?? ''), TheDotDevAgenticBridge_Readiness_PDF::MAX_FIELD_CHARS ); } if ($title === '') { $title = 'Confirmed check'; } $result = TheDotDevAgenticBridge_Readiness_PDF::finding_strength_result($finding); $wrapped = TheDotDevAgenticBridge_Readiness_PDF::wrap_text('• ' . $title . ' — ' . $result, 88); if ($wrapped === []) { continue; } $block_lines = count($wrapped); $this->ensure_space($block_lines * 14); foreach ($wrapped as $text_line) { $this->text_at(self::MARGIN_LEFT, $this->y, $text_line, 10, false, 0.118, 0.118, 0.118); $this->y -= 14; } } $this->y -= 8; } /** * @param array $context * @param list}> $blocks */ private function render_technical_appendix(array $context, array $blocks = []): void { if ($blocks === []) { $findings = is_array($context['findings'] ?? null) ? $context['findings'] : []; $blocks = TheDotDevAgenticBridge_Readiness_PDF::appendix_blocks($findings); } if ($blocks === []) { return; } $height = TheDotDevAgenticBridge_Readiness_PDF::measure_appendix_height($blocks); $remaining = $this->y - self::BODY_BOTTOM; $keep_on_page = TheDotDevAgenticBridge_Readiness_PDF::should_keep_appendix_on_current_page($remaining, $height); if (!$keep_on_page) { $this->start_appendix_page(TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_TITLE); } else { $this->y -= 8; $this->text_at( self::MARGIN_LEFT, $this->y, TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_TITLE, 13, true, 0.118, 0.118, 0.118 ); $this->y -= 16; } $this->section_intro(TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_INTRO); foreach ($blocks as $block) { $title = (string) ($block['title'] ?? ''); $lines = is_array($block['lines'] ?? null) ? $block['lines'] : []; if ($title === '' || $lines === []) { continue; } $block_height = TheDotDevAgenticBridge_Readiness_PDF::measure_appendix_block_height($block); if ( !$keep_on_page && ($this->y - $block_height) < self::BODY_BOTTOM && $this->page_used_significantly() ) { $this->start_appendix_page(TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_TITLE); $this->section_intro(TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_INTRO); } $this->text_at(self::MARGIN_LEFT, $this->y, $title, 10, true, 0.118, 0.118, 0.118); $this->y -= TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_BLOCK_TITLE_HEIGHT; foreach ($lines as $line) { if (!is_string($line) || $line === '') { continue; } $wrapped = TheDotDevAgenticBridge_Readiness_PDF::wrap_text( '• ' . $line, TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_WRAP_CHARS ); foreach ($wrapped as $text_line) { $this->text_at(self::MARGIN_LEFT + 8, $this->y, $text_line, 9, false, 0.314, 0.341, 0.369); $this->y -= TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_LINE_HEIGHT; } } $this->y -= TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_BLOCK_GAP; } } /** * @param list> $strengths */ private function measure_confirmed_strengths_height(array $strengths): float { $height = 32.0; foreach ($strengths as $finding) { if (!is_array($finding)) { continue; } $title = ''; if (class_exists('TheDotDevAgenticBridge_Findings_Presentation')) { $title = TheDotDevAgenticBridge_Readiness_PDF::sanitize_field( TheDotDevAgenticBridge_Findings_Presentation::owner_title($finding), TheDotDevAgenticBridge_Readiness_PDF::MAX_FIELD_CHARS ); } if ($title === '') { $title = TheDotDevAgenticBridge_Readiness_PDF::sanitize_field( (string) ($finding['title'] ?? ''), TheDotDevAgenticBridge_Readiness_PDF::MAX_FIELD_CHARS ); } if ($title === '') { $title = 'Confirmed check'; } $result = TheDotDevAgenticBridge_Readiness_PDF::finding_strength_result($finding); $wrapped = TheDotDevAgenticBridge_Readiness_PDF::wrap_text('• ' . $title . ' — ' . $result, 88); $height += max(1, count($wrapped)) * 12.0; } return $height + 8.0; } private function build_last_page_footer_note(): string { $plugin = 'Agentic Bridge'; if ($this->header_context !== null) { $name = trim((string) ($this->header_context['pluginName'] ?? '')); if ($name !== '') { $plugin = $name; } } return $plugin . ' generates this report locally. It does not send this report externally. ' . 'AI platforms decide their own rankings, citations, and recommendations. ' . 'This report does not guarantee agent visibility.'; } /** * @param array $context */ private function draw_page_header(array $context, string $section_title): void { $this->draw_running_header($context); $this->y = self::BODY_TOP; $this->text_at(self::MARGIN_LEFT, $this->y, $section_title, 18, true, 0.118, 0.118, 0.118); $this->y -= 24; } /** * @param array $context */ private function draw_running_header(array $context): void { $this->draw_light_header_band(self::PAGE_HEIGHT - 52, 52); $this->text_at(self::MARGIN_LEFT, self::PAGE_HEIGHT - 24, 'Agentic Bridge', 11, true, 0.090, 0.125, 0.200); $this->text_at(self::MARGIN_LEFT, self::PAGE_HEIGHT - 38, 'Agent Ready for WordPress', 8, false, 0.031, 0.498, 0.435); $site = (string) ($context['siteName'] ?? ''); if ($site === '') { $site = (string) ($context['siteUrl'] ?? ''); } if ($site !== '') { $this->text_at(self::PAGE_WIDTH - self::MARGIN_LEFT, self::PAGE_HEIGHT - 24, $site, 9, false, 0.090, 0.125, 0.200, 42, 'right'); } } private function section_intro(string $text): void { $this->text_block(self::MARGIN_LEFT, $this->y, $text, 10, false, 0.314, 0.341, 0.369, 88); $this->y -= 12; } /** * Continue on the current page when enough space remains; otherwise start a new page. * * @param array $context */ private function start_section(array $context, string $section_title, float $min_needed = 96.0): void { $this->header_context = $context; $this->section_active = true; if (!$this->page_has_content()) { $this->draw_page_header($context, $section_title); return; } $remaining = $this->y - self::BODY_BOTTOM; if ($remaining < $min_needed) { $this->start_continuation_page(); $this->draw_page_header($context, $section_title); return; } $this->y -= 12; $this->text_at(self::MARGIN_LEFT, $this->y, $section_title, 16, true, 0.118, 0.118, 0.118); $this->y -= 24; } private function ensure_space(int $needed, bool $indivisible = false): void { if ($this->y - $needed < self::BODY_BOTTOM) { $continuation_body_top = self::CONTINUATION_BODY_TOP; $usable_page = $continuation_body_top - self::BODY_BOTTOM; $remaining = $this->y - self::BODY_BOTTOM; $sparse_threshold = $usable_page * TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_MIN_PAGE_FILL; if (!$indivisible && $needed <= $sparse_threshold && $remaining >= ($needed * 0.55)) { return; } $this->start_continuation_page(); $this->y = $continuation_body_top; } } private function start_continuation_page(): void { $this->pages[] = []; $this->ops = &$this->pages[count($this->pages) - 1]; $this->page_index = count($this->pages) - 1; $this->y = self::CONTINUATION_BODY_TOP; if ($this->header_context !== null) { $this->draw_running_header($this->header_context); } } private function start_appendix_page(string $title): void { $this->start_continuation_page(); $this->y = self::CONTINUATION_BODY_TOP; $this->text_at(self::MARGIN_LEFT, $this->y, $title, 16, true, 0.118, 0.118, 0.118); $this->y -= 24; } private function page_used_significantly(): bool { $usable = self::BODY_TOP - self::BODY_BOTTOM; $used = self::BODY_TOP - $this->y; return $used >= ($usable * TheDotDevAgenticBridge_Readiness_PDF::APPENDIX_MIN_PAGE_FILL); } private function page_has_content(): bool { return $this->ops !== []; } private function draw_light_header_band(float $y, float $height): void { $this->fill_rect(0, $y, self::PAGE_WIDTH, $height, 0.965, 0.969, 0.984); $accent_h = 3.0; $accent_y = $y + $height - $accent_h; $w = (float) self::PAGE_WIDTH; $this->fill_rect(0, $accent_y, $w, $accent_h, 0.039, 0.490, 0.420); $this->fill_rect(0, $y - 1, self::PAGE_WIDTH, 1, 0.890, 0.902, 0.922); } private function fill_rect(float $x, float $y, float $w, float $h, float $r, float $g, float $b): void { $this->ops[] = sprintf('%.3F %.3F %.3F rg', $r, $g, $b); $this->ops[] = sprintf('%.2F %.2F %.2F %.2F re f', $x, $y, $w, $h); } private function stroke_rect(float $x, float $y, float $w, float $h, float $r, float $g, float $b): void { $this->ops[] = sprintf('%.3F %.3F %.3F RG', $r, $g, $b); $this->ops[] = '0.75 w'; $this->ops[] = sprintf('%.2F %.2F %.2F %.2F re S', $x, $y, $w, $h); } private function text_at( float $x, float $y, string $text, int $size, bool $bold, float $r, float $g, float $b, int $max_chars = 0, string $align = 'left' ): void { $text = TheDotDevAgenticBridge_Readiness_PDF::transliterate($text); if ($max_chars > 0 && strlen($text) > $max_chars) { $text = substr($text, 0, max(0, $max_chars - 3)) . '...'; } $encoded = self::pdf_literal(self::pdf_font_text($text)); $font = $bold ? '/F2' : '/F1'; if ($align === 'right') { $approx = strlen($text) * ($size * 0.48); $x -= $approx; } $this->ops[] = sprintf('%.3F %.3F %.3F rg', $r, $g, $b); $this->ops[] = 'BT'; $this->ops[] = $font . ' ' . $size . ' Tf'; $this->ops[] = sprintf('1 0 0 1 %.2F %.2F Tm', $x, $y); $this->ops[] = '(' . $encoded . ') Tj'; $this->ops[] = 'ET'; } private function text_block( float $x, float &$y, string $text, int $size, bool $bold, float $r, float $g, float $b, int $max_chars_per_line ): void { foreach (TheDotDevAgenticBridge_Readiness_PDF::wrap_text($text, $max_chars_per_line) as $line) { $this->text_at($x, $y, $line, $size, $bold, $r, $g, $b); $y -= ($size + 4); } } /** * @param list $ops * @return list */ private function decorate_page(array $ops, int $page_number): array { $footer_left = 'Agentic Bridge · Local readiness scan'; $footer_right = 'Page ' . $page_number . ' of ' . $this->total_pages; $rule_y = TheDotDevAgenticBridge_Readiness_PDF::FOOTER_BASELINE + TheDotDevAgenticBridge_Readiness_PDF::FOOTER_RULE_OFFSET; $ops[] = '0.863 0.863 0.871 RG'; $ops[] = '0.5 w'; $ops[] = sprintf( '%.2F %.2F m %.2F %.2F l S', self::MARGIN_LEFT, $rule_y, self::PAGE_WIDTH - self::MARGIN_LEFT, $rule_y ); $ops[] = '0.314 0.341 0.369 rg'; $ops[] = 'BT /F1 8 Tf 1 0 0 1 ' . self::MARGIN_LEFT . ' ' . self::FOOTER_BASELINE . ' Tm (' . self::pdf_literal($footer_left) . ') Tj ET'; $ops[] = 'BT /F1 8 Tf 1 0 0 1 ' . (self::PAGE_WIDTH - self::MARGIN_LEFT - 72) . ' ' . self::FOOTER_BASELINE . ' Tm (' . self::pdf_literal($footer_right) . ') Tj ET'; if ($page_number === $this->total_pages && $this->last_page_footer_note !== '') { $wrapped = TheDotDevAgenticBridge_Readiness_PDF::wrap_footer_note($this->last_page_footer_note); $layout = TheDotDevAgenticBridge_Readiness_PDF::footer_note_baselines(count($wrapped)); $note_y = $layout['first_baseline']; $step = TheDotDevAgenticBridge_Readiness_PDF::FOOTER_NOTE_LINE_STEP; foreach ($wrapped as $line) { $ops[] = 'BT /F1 7 Tf 1 0 0 1 ' . self::MARGIN_LEFT . ' ' . $note_y . ' Tm (' . self::pdf_literal(TheDotDevAgenticBridge_Readiness_PDF::transliterate($line)) . ') Tj ET'; $note_y -= $step; } } return $ops; } private static function pdf_literal(string $text): string { return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text); } private static function pdf_font_text(string $text): string { $out = ''; $len = strlen($text); for ($i = 0; $i < $len; ++$i) { $c = ord($text[$i]); if ($c === 9) { $out .= ' '; continue; } if ($c < 32 || $c === 127) { continue; } if ($c > 255) { $out .= '?'; continue; } $out .= chr($c); } return $out; } /** * @return array{0: float, 1: float, 2: float} */ private static function severity_color(string $severity): array { return match (strtolower($severity)) { 'high' => TheDotDevAgenticBridge_Readiness_PDF::COLOR_HIGH, 'medium', 'info' => TheDotDevAgenticBridge_Readiness_PDF::COLOR_MEDIUM, default => [0.314, 0.341, 0.369], }; } private static function severity_label(string $severity): string { return match (strtolower($severity)) { 'high' => 'High', 'medium' => 'Medium', 'info' => 'Review', default => ucfirst($severity), }; } private static function format_display_date(string $iso): string { if ($iso === '') { return 'Unavailable'; } $ts = strtotime($iso); if ($ts === false) { return $iso; } return gmdate('j M Y, H:i', $ts) . ' UTC'; } }