draw(); } /** * Comparison rows, once built. * * @var array[]|null */ private $rows = null; /** * Constructor. * * @param array $context Context. */ private function __construct( array $context ) { $this->c = array_merge( array( 'data' => array(), 'title' => '', 'kind' => '', 'compare_label' => '', 'brand' => array(), 'prepared' => '', 'insights' => array(), 'notes' => '', 'problems' => array(), 'report_url' => '', ), $context ); $this->c['brand'] = array_merge( array( 'name' => '', 'color' => '#0f6b50', 'logo_path' => '', 'client_logo_path' => '', 'footer' => '', 'credit' => true, ), (array) $this->c['brand'] ); $dir = defined( 'TURTLEREPORT_DIR' ) ? TURTLEREPORT_DIR : dirname( __DIR__, 2 ) . '/'; $this->pdf = new Document(); $this->pdf->add_font( 'r', TrueTypeFont::load( $dir . 'assets/fonts/pdf/Inter-Regular.ttf' ) ); $this->pdf->add_font( 'b', TrueTypeFont::load( $dir . 'assets/fonts/pdf/Inter-SemiBold.ttf' ) ); } /** Most pages the report takes. */ const PAGES = 2; /** * Draws both pages. * * @return string */ private function draw() { $site = $this->c['site']; $this->pdf->set_title( $this->clean( $this->c['title'] . ' · ' . $site->display_name() ) ); // Page 1: the story at a glance. $this->pdf->add_page(); $this->cover(); $this->summary(); $this->problems(); $this->overall(); $this->kpis(); $this->insights( self::BOTTOM - 18 ); // Page 2: supporting numbers, in order of importance, as many as fit. // The comparison table holds every number with last period's beside it, so the separate // lead, traffic, AI and Business Profile boxes would only repeat it. $blocks = array( array( $this, 'comparison' ), array( $this, 'trend_chart' ), array( $this, 'pages' ), array( $this, 'notes' ), ); $this->pdf->add_page(); $this->header(); foreach ( $blocks as $block ) { $h = call_user_func( $block, true ); if ( $h > 0 && $this->y + $h <= self::BOTTOM - 22 ) { call_user_func( $block, false ); } } $this->footnote(); $this->footers(); return $this->pdf->output(); } /** * Replaces characters the fonts can't draw with "?". * * @param string $text Text. * @return string */ private function clean( $text ) { $font = $this->pdf->font( 'r' ); $out = ''; foreach ( Text::chars( (string) $text ) as $ch ) { $cp = Text::ord( $ch ); if ( $cp < 32 || ( $cp >= 0x7F && $cp < 0xA0 ) ) { $out .= ' '; } elseif ( $font->has( $cp ) ) { $out .= $ch; } else { $out .= '?'; $this->missing = true; } } return $out; } /** * Writes a line of cleaned text. * * @param float $x X. * @param float $y Baseline. * @param string $text Text. * @param string $font Font. * @param float $size Size. * @param string $color Colour. * @param string $align Align. * @param float $max Most width (0 = no limit). * @return void */ private function t( $x, $y, $text, $font, $size, $color, $align = 'left', $max = 0.0 ) { $text = $this->clean( $text ); if ( $max > 0 ) { $this->pdf->text_fit( $x, $y, $text, $font, $size, $color, $max, $align ); } else { $this->pdf->text( $x, $y, $text, $font, $size, $color, $align ); } } /** * Writes wrapped text and moves the cursor. * * @param string $text Text. * @param string $font Font. * @param float $size Size. * @param string $color Colour. * @param float $x Left. * @param float $width Width. * @param float $lh Line height. * @return void */ private function para( $text, $font, $size, $color, $x, $width, $lh ) { foreach ( $this->pdf->wrap( $this->clean( $text ), $font, $size, $width ) as $line ) { $this->pdf->text( $x, $this->y + $size, $line, $font, $size, $color ); $this->y += $lh; } } /** * Height of wrapped text. * * @param string $text Text. * @param string $font Font. * @param float $size Size. * @param float $width Width. * @param float $lh Line height. * @return float */ private function para_height( $text, $font, $size, $width, $lh ) { return count( $this->pdf->wrap( $this->clean( $text ), $font, $size, $width ) ) * $lh; } /** Height of a heading(). */ const HEADING = 26.0; /** * Section heading with an optional hint on the right. * * @param string $title Title. * @param string $hint Hint. * @return void */ private function heading( $title, $hint = '' ) { $this->y += 4; $this->t( self::MARGIN, $this->y + 11, $title, 'b', 11.5, self::INK ); if ( '' !== $hint ) { $this->t( Document::WIDTH - self::MARGIN, $this->y + 11, $hint, 'r', 8, self::MUTED, 'right', 280 ); } $this->y += 16; $this->pdf->line( self::MARGIN, $this->y, Document::WIDTH - self::MARGIN, $this->y, self::LINE, 0.75 ); $this->y += 6; } /** * Inner width. * * @return float */ private static function inner() { return Document::WIDTH - 2 * self::MARGIN; } /** * Signed whole number. * * @param float $n Number. * @return string */ private static function signed( $n ) { return ( $n > 0 ? '+' : ( $n < 0 ? '−' : '±' ) ) . Format::number( abs( $n ) ); } /** * Short page path from a URL. * * @param string $url URL. * @return string */ private static function path( $url ) { $parts = wp_parse_url( (string) $url ); if ( ! is_array( $parts ) || ! isset( $parts['path'] ) ) { return (string) $url; } $p = $parts['path'] . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' ); return rawurldecode( $p ); } /** * A date as "1 Sept 2026". * * @param string $date Y-m-d. * @return string */ private static function day( $date ) { return wp_date( 'j M Y', strtotime( $date . ' 12:00:00 UTC' ), new \DateTimeZone( 'UTC' ) ); } /** * Cover band on page 1. * * @return void */ private function cover() { $b = $this->c['brand']; $color = $b['color']; $on = Document::is_dark( $color ) ? '#ffffff' : self::INK; $h = 166.0; $this->pdf->rect( 0, 0, Document::WIDTH, $h, $color ); $x = self::MARGIN; $y = 34.0; if ( '' !== $b['logo_path'] ) { $this->pdf->rect( $x, $y, 150, 40, '#ffffff', null, 6 ); if ( ! $this->pdf->image( $b['logo_path'], $x + 6, $y + 5, 138, 30 ) ) { $this->t( $x + 10, $y + 25, $b['name'], 'b', 12, self::INK, 'left', 130 ); } } else { $this->t( $x, $y + 22, $b['name'], 'b', 14, $on, 'left', 300 ); } $right = Document::WIDTH - self::MARGIN; if ( '' !== $b['client_logo_path'] ) { $this->pdf->rect( $right - 120, $y, 120, 40, '#ffffff', null, 6 ); $this->pdf->image( $b['client_logo_path'], $right - 114, $y + 5, 108, 30, 'center' ); } if ( '' !== $this->c['prepared'] ) { /* translators: %s: date the report was prepared. */ $this->t( $right, $y + ( '' !== $b['client_logo_path'] ? 56 : 22 ), sprintf( __( 'Prepared %s', 'turtlereport' ), $this->c['prepared'] ), 'r', 9, $on, 'right' ); } $site = $this->c['site']; $p = $this->c['period']; $this->t( $x, 100, $site->display_name(), 'r', 12, $on, 'left', self::inner() ); $this->t( $x, 128, $this->c['title'], 'b', 22, $on, 'left', self::inner() ); $dates = sprintf( /* translators: 1: period type, 2: start, 3: end, 4: comparison start, 5: comparison end. */ __( '%1$s · %2$s – %3$s · compared with %4$s – %5$s', 'turtlereport' ), $this->c['kind'], self::day( $p['s'] ), self::day( $p['e'] ), self::day( $p['ps'] ), self::day( $p['pe'] ) ); $this->t( $x, 147, $dates, 'r', 9.5, $on, 'left', self::inner() ); // No "open the online report" button: the PDF is the client's whole report. $this->y = $h + 18; } /** * Slim header on later pages. * * @return void */ private function header() { $b = $this->c['brand']; $this->pdf->rect( self::MARGIN, 30, 4, 14, $b['color'] ); $this->t( self::MARGIN + 10, 41, $this->c['title'], 'b', 10, self::INK, 'left', 300 ); $this->t( Document::WIDTH - self::MARGIN, 41, $this->c['site']->display_name(), 'r', 9, self::MUTED, 'right', 200 ); $this->pdf->line( self::MARGIN, 52, Document::WIDTH - self::MARGIN, 52, self::LINE ); $this->y = 64; } /** * Footer and page numbers on every page. * * @return void */ private function footers() { $b = $this->c['brand']; $total = $this->pdf->page_count(); $left = ( '' !== $b['footer'] ? $b['footer'] : $b['name'] ) . ' · ' . $this->c['site']->display_name(); for ( $i = 0; $i < $total; $i++ ) { $this->pdf->go_to( $i ); $this->pdf->line( self::MARGIN, 806, Document::WIDTH - self::MARGIN, 806, self::LINE ); $this->t( self::MARGIN, 820, $left, 'r', 8, self::MUTED, 'left', 330 ); /* translators: 1: page number, 2: total pages. */ $right = sprintf( __( 'Page %1$d of %2$d', 'turtlereport' ), $i + 1, $total ) . ( $b['credit'] ? ' · ' . __( 'Made with TurtleReport', 'turtlereport' ) : '' ); $this->t( Document::WIDTH - self::MARGIN, 820, $right, 'r', 8, self::MUTED, 'right' ); } } /** * The one-paragraph summary under the cover (summaryNodes() in features/reporting/export/pdf.ts). * * @return void */ private function summary() { $d = $this->c['data']; $parts = array(); if ( isset( $d['search']['cur'] ) ) { $c = $d['search']['cur']['clicks']; $parts[] = sprintf( /* translators: 1: number of clicks, 2: change. */ _n( '%1$s Google click (%2$s)', '%1$s Google clicks (%2$s)', (int) round( $c ), 'turtlereport' ), Format::number( $c ), Format::delta( $c, $d['search']['prev']['clicks'] )['text'] ); } if ( isset( $d['conversions']['cur'] ) && 'no_events' !== $d['conversions']['status'] ) { $parts[] = sprintf( /* translators: 1: number, 2: what the site calls conversions, 3: change. */ __( '%1$s %2$s from organic search (%3$s)', 'turtlereport' ), Format::number( $d['conversions']['cur'] ), $d['conversions']['label'], self::signed( $d['conversions']['cur'] - $d['conversions']['prev'] ) ); } if ( isset( $d['business']['cur'] ) ) { $parts[] = sprintf( /* translators: 1: number of calls, 2: change. */ _n( '%1$s profile call (%2$s)', '%1$s profile calls (%2$s)', (int) $d['business']['cur']['calls'], 'turtlereport' ), Format::number( $d['business']['cur']['calls'] ), self::signed( $d['business']['cur']['calls'] - $d['business']['prev']['calls'] ) ); } if ( isset( $d['ai']['cur'] ) && ( $d['ai']['cur'] > 0 || $d['ai']['prev'] > 0 ) ) { $parts[] = sprintf( /* translators: 1: number of visits, 2: change. */ _n( '%1$s visit from AI assistants (%2$s)', '%1$s visits from AI assistants (%2$s)', (int) $d['ai']['cur'], 'turtlereport' ), Format::number( $d['ai']['cur'] ), self::signed( $d['ai']['cur'] - $d['ai']['prev'] ) ); } if ( ! $parts ) { return; } $text = implode( ', ', $parts ); if ( isset( $d['search']['gaining'] ) ) { $text .= '. ' . sprintf( /* translators: 1: pages that gained clicks, 2: pages that lost clicks, 3: comparison period. */ __( '%1$s gained clicks and %2$s lost clicks compared with %3$s.', 'turtlereport' ), /* translators: %s: number of pages. */ sprintf( _n( '%s page', '%s pages', (int) $d['search']['gaining'], 'turtlereport' ), Format::number( $d['search']['gaining'] ) ), Format::number( $d['search']['losing'] ), $this->c['compare_label'] ); } else { /* translators: %s: comparison period. */ $text .= ' ' . sprintf( __( 'compared with %s.', 'turtlereport' ), $this->c['compare_label'] ); } $this->para( $text, 'r', 11, self::INK2, self::MARGIN, self::inner(), 16 ); $this->y += 6; } /** * A warning box listing parts that couldn't load or may be incomplete. * * @return void */ private function problems() { $rows = array_values( array_filter( (array) $this->c['problems'], static function ( $p ) { return in_array( $p['level'], array( 'blocked', 'warning' ), true ); } ) ); if ( ! $rows ) { return; } $more = count( $rows ) - 3; $rows = array_slice( $rows, 0, 3 ); if ( $more > 0 ) { $rows[] = array( 'level' => 'warning', /* translators: %d: number of further warnings. */ 'message' => sprintf( _n( 'And %d more, smaller note.', 'And %d more, smaller notes.', $more, 'turtlereport' ), $more ), ); } $w = self::inner() - 24; $title = __( 'Some parts of this report may be incomplete', 'turtlereport' ); $h = 30; foreach ( $rows as $r ) { $h += $this->para_height( '• ' . $r['message'], 'r', 9, $w, 12.5 ) + 2; } $this->pdf->rect( self::MARGIN, $this->y, self::inner(), $h, '#fff6dc', '#f0d58a', 8 ); $top = $this->y; $this->y = $top + 10; $this->t( self::MARGIN + 12, $this->y + 10, $title, 'b', 10, self::WARN ); $this->y += 18; foreach ( $rows as $r ) { $this->para( '• ' . $r['message'], 'r', 9, '#5c4300', self::MARGIN + 12, $w, 12.5 ); $this->y += 2; } $this->y = $top + $h + 12; } /** * What improved, what declined, suggested next steps: at most three of * each, cut further until they fit above $bottom. * * @param float $bottom Lowest y the block may reach. * @return void */ private function insights( $bottom ) { $groups = Insights::grouped( (array) $this->c['insights'] ); foreach ( $groups as $kind => $lines ) { $groups[ $kind ] = array_slice( $lines, 0, 3 ); } $colors = array( Insights::IMPROVED => self::GOOD, Insights::DECLINED => self::BAD, Insights::ACTION => $this->c['brand']['color'], ); $width = self::inner() - 14 - 2 * self::CARD_PAD; $height = function () use ( &$groups, $width ) { $h = 0; foreach ( $groups as $lines ) { if ( $lines ) { $h += 17; foreach ( $lines as $line ) { $h += $this->para_height( $line, 'r', 9.5, $width, 13 ) + 3; } $h += 4; } } return $h ? $h + self::CARD_TOP + 8 : 0; }; // Until it fits, drop one line at a time: first from lists with several lines // (suggestions, then declines, then improvements), then single lines in that order. while ( $height() > $bottom - $this->y && array_filter( $groups ) ) { foreach ( array( Insights::ACTION, Insights::DECLINED, Insights::IMPROVED ) as $kind ) { if ( count( $groups[ $kind ] ) > 1 || ( count( $groups[ $kind ] ) === 1 && ! array_filter( array_diff_key( $groups, array( $kind => 1 ) ) ) ) ) { array_pop( $groups[ $kind ] ); continue 2; } } foreach ( array( Insights::ACTION, Insights::DECLINED, Insights::IMPROVED ) as $kind ) { if ( $groups[ $kind ] ) { array_pop( $groups[ $kind ] ); break; } } } if ( ! array_filter( $groups ) ) { return; } $this->y += 4; $top = $this->y; $this->card( self::MARGIN, $top, self::inner(), $height(), __( 'Highlights', 'turtlereport' ), __( 'What changed this period, then suggested next steps', 'turtlereport' ) ); $this->y = $top + self::CARD_TOP + 2; $x = self::MARGIN + self::CARD_PAD; $first = true; foreach ( Insights::headings() as $kind => $label ) { if ( ! $groups[ $kind ] ) { continue; } if ( ! $first ) { $this->pdf->line( $x, $this->y - 2, self::MARGIN + self::inner() - self::CARD_PAD, $this->y - 2, self::LINE, 0.5 ); } $first = false; $this->t( $x, $this->y + 10, $label, 'b', 9, $colors[ $kind ] ); $this->y += 17; foreach ( $groups[ $kind ] as $line ) { $this->pdf->circle( $x + 3.5, $this->y + 6, 2.4, $colors[ $kind ] ); $this->para( $line, 'r', 9.5, self::INK2, $x + 14, $width, 13 ); $this->y += 3; } $this->y += 4; } } /** Card corner radius, padding and title row. */ const CARD_R = 8.0; const CARD_PAD = 12.0; const CARD_TOP = 22.0; /** Height of one metric cell inside a card. */ const CELL_H = 48.0; /** Height of one bar row. */ const BAR_H = 15.0; /** Space between cards. */ const GAP = 8.0; /** * A white card with a title (and an optional colour mark and hint on the right). * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param float $h Height. * @param string $title Title. * @param string $hint Muted text on the right. * @param string|null $mark Colour of the small square before the title. * @return void */ private function card( $x, $y, $w, $h, $title, $hint = '', $mark = null ) { $this->pdf->rect( $x, $y, $w, $h, '#ffffff', self::LINE, self::CARD_R ); $tx = $x + self::CARD_PAD; if ( null !== $mark ) { $this->pdf->rect( $tx, $y + 9.5, 7, 7, $mark, null, 2 ); $tx += 12; } $hw = '' !== $hint ? min( $w * 0.55, $this->pdf->width( $this->clean( $hint ), 'r', 7.5 ) + 4 ) : 0; $this->t( $tx, $y + 16.5, $title, 'b', 9.5, self::INK, 'left', $x + $w - self::CARD_PAD - $hw - 8 - $tx ); if ( '' !== $hint ) { $this->t( $x + $w - self::CARD_PAD, $y + 16.5, $hint, 'r', 7.5, self::MUTED, 'right', $hw ); } } /** * A change chip: a tinted pill with an arrow and the change, e.g. "▲ 26%" in green. * The arrow shows which way the number went; the colour shows whether that's good. * * @param float $x Left (or right edge when $align is "right"). * @param float $y Top. * @param array $change Format::delta() result. * @param int $dir direction(). * @param float $max Most width. * @param float $size Font size. * @param string $align left|right. * @return float Width drawn. */ private function chip( $x, $y, array $change, $dir, $max = 200.0, $size = 7.5, $align = 'left' ) { $text = $this->clean( (string) $change['text'] ); if ( '' === trim( $text ) ) { return 0.0; } // The arrow replaces the sign: "+26%" → "▲ 26%", "▲ 1.4" → "▲ 1.4". $text = ReportCards::unsigned( $text ); $h = $size + 5.5; $aw = $dir ? $size * 0.75 + 3 : 0; $tw = min( $this->pdf->width( $text, 'b', $size ), max( 8, $max - 10 - $aw ) ); $w = $tw + $aw + 10; if ( 'right' === $align ) { $x -= $w; } $this->pdf->rect( $x, $y, $w, $h, Document::tint( $change['color'], 0.88 ), null, $h / 2 ); $ax = $x + 5; if ( $dir ) { $s = $size * 0.75; $cy = $y + $h / 2; $pts = $dir > 0 ? array( array( $ax, $cy + $s * 0.42 ), array( $ax + $s, $cy + $s * 0.42 ), array( $ax + $s / 2, $cy - $s * 0.48 ) ) : array( array( $ax, $cy - $s * 0.42 ), array( $ax + $s, $cy - $s * 0.42 ), array( $ax + $s / 2, $cy + $s * 0.48 ) ); $pts[] = $pts[0]; $this->pdf->path( $pts, null, $change['color'] ); $ax += $aw; } $this->pdf->text_fit( $ax, $y + $h / 2 + $size * 0.36, $text, 'b', $size, $change['color'], $tw + 0.5 ); return $w; } /** * A small tinted pill with text, e.g. "10 improved". * * @param float $x Left. * @param float $y Top. * @param string $text Text. * @param string $color Colour. * @return float Width. */ private function pill( $x, $y, $text, $color ) { $text = $this->clean( $text ); $w = $this->pdf->width( $text, 'b', 8 ) + 14; $this->pdf->rect( $x, $y, $w, 15, Document::tint( $color, 0.88 ), null, 7.5 ); $this->pdf->text( $x + 7, $y + 10.6, $text, 'b', 8, $color ); return $w; } /** * Short name of the earlier period for "Aug 2026: 1,017". * * @param bool $short Month only ("Aug"). * @return string */ private function before_label( $short = false ) { return ReportCards::before_label( $this->c['period'], $short ); } /** * One metric: label, big value, change chip and the earlier value. * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param string $label Label. * @param string $value Value. * @param array $change Format::delta() result. * @param int $dir direction(). * @param string $prev Earlier value, formatted. * @param float $size Value size. * @return void */ private function cell( $x, $y, $w, $label, $value, array $change, $dir, $prev, $size = 15.0 ) { $this->t( $x, $y + 10, $label, 'r', 8, self::MUTED, 'left', $w ); $this->t( $x, $y + 15 + $size, $value, 'b', $size, self::INK, 'left', $w ); $cy = $y + 22 + $size; $cw = $this->chip( $x, $cy, $change, $dir, $w * 0.6 ); if ( '' !== (string) $prev ) { /* translators: 1: earlier period, e.g. "Aug 2026", 2: its value. */ $was = sprintf( __( '%1$s: %2$s', 'turtlereport' ), $this->before_label(), $prev ); $room = max( 10, $w - $cw - 6 ); if ( $this->pdf->width( $this->clean( $was ), 'r', 7.5 ) > $room ) { // Tight cell: "Aug: 4.0%" instead of "Aug 2026: 4.0%". /* translators: 1: earlier period, e.g. "Aug 2026", 2: its value. */ $was = sprintf( __( '%1$s: %2$s', 'turtlereport' ), $this->before_label( true ), $prev ); } $this->t( $x + $cw + ( $cw ? 6 : 0 ), $cy + 9.5, $was, 'r', 7.5, self::MUTED, 'left', $room ); } } /** * Horizontal bars: label on the left, a bar on a grey track, the value and change on the right. * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param array[] $items Items: label, value (float), text, change (Format::delta()), dir. * @param string $color Bar colour. * @param float $max Value of a full bar (0 = the largest item). * @return void */ private function bars( $x, $y, $w, array $items, $color, $max = 0.0 ) { if ( $max <= 0 ) { foreach ( $items as $it ) { $max = max( $max, abs( (float) $it['value'] ) ); } } $label_w = min( 120, $w * 0.34 ); $right_w = 82; $track_x = $x + $label_w + 8; $track_w = max( 20, $w - $label_w - 8 - $right_w ); foreach ( $items as $i => $it ) { $ry = $y + $i * self::BAR_H; $this->t( $x, $ry + 9.5, $it['label'], 'r', 8.5, self::INK2, 'left', $label_w ); $this->pdf->rect( $track_x, $ry + 4, $track_w, 6, '#eef0f3', null, 3 ); $bw = $max > 0 ? $track_w * min( 1, abs( (float) $it['value'] ) / $max ) : 0; if ( $bw > 0 ) { $this->pdf->rect( $track_x, $ry + 4, max( 6, $bw ), 6, $color, null, 3 ); } $vx = $track_x + $track_w + 8; $this->t( $vx, $ry + 9.5, $it['text'], 'b', 8.5, self::INK, 'left', 28 ); if ( isset( $it['change'] ) ) { $this->chip( $vx + 30, $ry + 1, $it['change'], $it['dir'], $x + $w - $vx - 30, 6.5 ); } } } /** * Headline number cards: two rows of three. * * @return void */ private function kpis() { $tiles = ReportCards::tiles( (array) $this->c['data'], $this->c['brand']['color'] ); if ( ! $tiles ) { return; } /* translators: %s: comparison period. */ $this->heading( __( 'Headline numbers', 'turtlereport' ), sprintf( __( 'Change vs %s', 'turtlereport' ), $this->c['compare_label'] ) ); $cols = 3; $gap = 10; $w = ( self::inner() - $gap * ( $cols - 1 ) ) / $cols; $h = 72; foreach ( array_chunk( $tiles, $cols ) as $row ) { foreach ( $row as $i => $t ) { $x = self::MARGIN + $i * ( $w + $gap ); $this->pdf->rect( $x, $this->y, $w, $h, '#ffffff', self::LINE, self::CARD_R ); $this->pdf->rect( $x + 12, $this->y + 10.5, 7, 7, $t['color'], null, 2 ); $this->cell( $x + 12, $this->y + 7, $w - 24, '', $t['value'], $t['change'], $t['dir'], $t['prev'], 19 ); $this->t( $x + 24, $this->y + 17, $t['label'], 'r', 8.5, self::MUTED, 'left', $w - 36 ); } $this->y += $h + $gap; } $this->y += 2; } /** * Rows for the comparison cards and the overall change, built once. * * @return array[] */ private function comparison_rows() { if ( null === $this->rows ) { $this->rows = Comparison::rows( (array) $this->c['data'], $this->c['site'] ); } return $this->rows; } /** * One card: the average change across every measure, and how many improved or declined. * * @return void */ private function overall() { $rows = $this->comparison_rows(); if ( ! $rows ) { return; } $o = Comparison::overall( $rows ); $cols = Comparison::columns( $this->c['period'] ); $h = 50.0; $x = self::MARGIN; $this->y += 4; $top = $this->y; $this->pdf->rect( $x, $top, self::inner(), $h, Document::tint( $o['color'], 0.94 ), Document::tint( $o['color'], 0.75 ), self::CARD_R ); $this->t( $x + 14, $top + 18, __( 'Overall change', 'turtlereport' ), 'b', 8, $o['color'] ); $this->t( $x + 14, $top + 39, $o['text'], 'b', 19, $o['color'], 'left', 110 ); $dx = $x + 132; $this->pdf->line( $dx, $top + 10, $dx, $top + $h - 10, Document::tint( $o['color'], 0.75 ), 0.75 ); $tx = $dx + 14; /* translators: 1: comparison period, e.g. "August 2026", 2: number of measures. */ $line = sprintf( _n( 'Average change vs %1$s across %2$d measure', 'Average change vs %1$s across %2$d measures', max( 1, $o['count'] ), 'turtlereport' ), $cols[1], $o['count'] ); $this->t( $tx, $top + 19, $line, 'r', 9, self::INK2, 'left', self::MARGIN + self::inner() - 14 - $tx ); /* translators: %d: number of measures. */ $px = $tx + $this->pill( $tx, $top + 26, sprintf( _n( '%d improved', '%d improved', $o['better'], 'turtlereport' ), $o['better'] ), self::GOOD ) + 6; /* translators: %d: number of measures. */ $px += $this->pill( $px, $top + 26, sprintf( _n( '%d declined', '%d declined', $o['worse'], 'turtlereport' ), $o['worse'] ), self::BAD ) + 6; if ( $o['same'] ) { /* translators: %d: number of measures. */ $this->pill( $px, $top + 26, sprintf( _n( '%d unchanged', '%d unchanged', $o['same'], 'turtlereport' ), $o['same'] ), self::FLAT ); } $this->y = $top + $h + 8; } /** * The comparison rows as cards: one per section, with its colour, source and * either metric cells or (for lead types) bars. * * @return array[] Items: title, hint, color, cells (rows), bars (rows), height. */ private function sections() { $out = ReportCards::sections( $this->comparison_rows(), (array) $this->c['data'], $this->c['site'], $this->c['brand']['color'] ); foreach ( $out as $k => $s ) { $n = count( $s['cells'] ); $body = $s['bars'] ? max( self::CELL_H, count( $s['bars'] ) * self::BAR_H + 3 ) : (int) ceil( $n / 4 ) * self::CELL_H + ( (int) ceil( $n / 4 ) - 1 ) * 8; $out[ $k ]['height'] = self::CARD_TOP + $body + 6; } return $out; } /** * This period vs the one before: a card per source with every measure, its * change and last period's value. * * @param bool $measure Return the height instead of drawing. * @return float Height. */ private function comparison( $measure = false ) { $sections = $this->sections(); if ( ! $sections ) { return 0; } $height = self::HEADING + 4; foreach ( $sections as $s ) { $height += $s['height'] + self::GAP; } if ( $measure ) { return $height; } $cols = Comparison::columns( $this->c['period'] ); /* translators: 1: this period, e.g. "September 2026", 2: the period before. */ $this->heading( sprintf( __( '%1$s vs %2$s', 'turtlereport' ), $cols[0], $cols[1] ), __( 'Green is better, red is worse', 'turtlereport' ) ); $x = self::MARGIN; $w = self::inner(); $inner = $w - 2 * self::CARD_PAD; foreach ( $sections as $s ) { $top = $this->y; $this->card( $x, $top, $w, $s['height'], $s['title'], $s['hint'], $s['color'] ); $by = $top + self::CARD_TOP; if ( $s['bars'] ) { // The total on the left, its breakdown as bars on the right. $cw = $inner * 0.26; $r = $s['cells'][0]; $this->cell( $x + self::CARD_PAD, $by, $cw - 10, $r['label'], $r['cur'], $r['change'], self::row_dir( $r ), $r['prev'] ); $this->pdf->line( $x + self::CARD_PAD + $cw, $by + 2, $x + self::CARD_PAD + $cw, $by + self::CELL_H - 4, self::LINE, 0.75 ); $items = array(); foreach ( $s['bars'] as $b ) { $items[] = array( 'label' => $b['label'], 'value' => $b['value'], 'text' => $b['cur'], 'change' => $b['change'], 'dir' => self::row_dir( $b ), ); } $this->bars( $x + self::CARD_PAD + $cw + 14, $by + 1, $inner - $cw - 14, $items, $s['color'] ); } else { $per = min( 4, max( 1, count( $s['cells'] ) ) ); $cw = $inner / $per; foreach ( $s['cells'] as $i => $r ) { $col = $i % $per; $cx = $x + self::CARD_PAD + $col * $cw; $cy = $by + (int) floor( $i / $per ) * ( self::CELL_H + 8 ); if ( $col > 0 ) { $this->pdf->line( $cx - 8, $cy + 2, $cx - 8, $cy + self::CELL_H - 4, self::LINE, 0.75 ); } $this->cell( $cx + ( $col > 0 ? 4 : 0 ), $cy, $cw - 16, $r['label'], $r['cur'], $r['change'], self::row_dir( $r ), $r['prev'] ); } } $this->y = $top + $s['height'] + self::GAP; } $this->y += 4; return 0; } /** * Arrow direction for a comparison row. * * @param array $r Row. * @return int */ private static function row_dir( array $r ) { return ReportCards::row_dir( $r ); } /** * Six-month trend: clicks as bars, conversions and calls as lines. * * @param bool $measure Return the height instead of drawing. * @return float Height. */ private function trend_chart( $measure = false ) { $d = $this->c['data']; $has_clicks = isset( $d['search']['monthly'] ); $has_leads = isset( $d['conversions']['monthly'] ) && 'no_events' !== $d['conversions']['status']; $has_calls = isset( $d['business']['monthly'] ); if ( ! $has_clicks && ! $has_leads && ! $has_calls ) { return 0; } $plot = 92.0; if ( $measure ) { return self::CARD_TOP + $plot + 44 + self::GAP; } $trend = isset( $d['search']['trend'] ) ? $d['search']['trend'] : \TurtleReport\Support\Periods::trend( $this->c['period']['e'] ); $months = array(); $end_month = substr( $trend['e'], 0, 7 ); for ( $m = substr( $trend['s'], 0, 7 ); $m <= $end_month; $m = gmdate( 'Y-m', strtotime( $m . '-01 12:00:00 UTC +1 month' ) ) ) { $months[] = $m; } $last_day = gmdate( 'Y-m-t', strtotime( substr( $trend['e'], 0, 7 ) . '-01 12:00:00 UTC' ) ); $labels = array(); foreach ( $months as $i => $m ) { $l = wp_date( 'M Y', strtotime( $m . '-01 12:00:00 UTC' ), new \DateTimeZone( 'UTC' ) ); if ( count( $months ) - 1 === $i && $last_day !== $trend['e'] ) { /* translators: 1: month label, 2: day of month. */ $l = sprintf( __( '%1$s (to %2$d)', 'turtlereport' ), $l, (int) substr( $trend['e'], 8, 2 ) ); } $labels[] = $l; } $pick = static function ( $map ) use ( $months ) { return array_map( static function ( $m ) use ( $map ) { return isset( $map[ $m ] ) ? (float) $map[ $m ] : 0.0; }, $months ); }; $bars = array(); $lines = array(); if ( $has_clicks ) { $bars[] = array( 'label' => __( 'Google clicks', 'turtlereport' ), 'color' => self::CLICKS, 'data' => $pick( $d['search']['monthly'] ), ); } if ( $has_leads ) { $lines[] = array( 'label' => $d['conversions']['label'], 'color' => self::LEAD, 'data' => $pick( $d['conversions']['monthly'] ), ); } if ( $has_calls ) { $lines[] = array( 'label' => __( 'Profile calls', 'turtlereport' ), 'color' => self::CALL, 'data' => $pick( $d['business']['monthly'] ), ); } $top = $this->y; $this->card( self::MARGIN, $top, self::inner(), self::CARD_TOP + $plot + 44, __( 'Last 6 months', 'turtlereport' ), $bars && $lines ? __( 'Clicks on the left axis, the rest on the right', 'turtlereport' ) : '' ); $this->y = $top + self::CARD_TOP; if ( $bars ) { $this->chart( $labels, array(), $lines, $bars, $plot, self::CARD_PAD ); } else { $this->chart( $labels, $lines, array(), array(), $plot, self::CARD_PAD ); } $this->y = $top + self::CARD_TOP + $plot + 44 + self::GAP; return 0; } /** * Draws a chart with up to two value axes. * * @param string[] $labels X labels. * @param array[] $left Line series on the left axis (label, color, data, area). * @param array[] $right Line series on the right axis. * @param array[] $bars Bar series on the left axis. * @param float $height Plot height. * @param float $pad Inset from the margins (inside a card). * @return void */ private function chart( array $labels, array $left, array $right, array $bars, $height, $pad = 0.0 ) { $x0 = self::MARGIN + $pad + 30; $x1 = Document::WIDTH - self::MARGIN - $pad - ( $right ? 26 : 4 ); $top = $this->y + 6; $bottom = $top + $height; $n = max( 1, count( $labels ) ); $lmax = self::nice_max( array_merge( self::values( $left ), self::values( $bars ) ) ); $rmax = self::nice_max( self::values( $right ) ); $step = ( $x1 - $x0 ) / ( $bars ? $n : max( 1, $n - 1 ) ); $xat = static function ( $i ) use ( $x0, $step, $bars ) { return $bars ? $x0 + $step * ( $i + 0.5 ) : $x0 + $step * $i; }; // Grid and axis labels. for ( $g = 0; $g <= 4; $g++ ) { $gy = $bottom - $height * $g / 4; $this->pdf->line( $x0, $gy, $x1, $gy, self::LINE, 0.6 ); $this->pdf->text( $x0 - 6, $gy + 3, self::compact( $lmax * $g / 4 ), 'r', 7.5, self::MUTED, 'right' ); if ( $right ) { $this->pdf->text( $x1 + 6, $gy + 3, self::compact( $rmax * $g / 4 ), 'r', 7.5, self::MUTED ); } } $every = max( 1, (int) ceil( $n / 8 ) ); foreach ( $labels as $i => $label ) { if ( 0 === $i % $every ) { $this->t( $xat( $i ), $bottom + 12, $label, 'r', 7.5, self::MUTED, 'center' ); } } // Bars. foreach ( $bars as $s ) { $bw = min( 46, $step * 0.6 ); foreach ( $s['data'] as $i => $v ) { $bh = $lmax > 0 ? $height * $v / $lmax : 0; $this->pdf->rect( $xat( $i ) - $bw / 2, $bottom - $bh, $bw, $bh, Document::tint( $s['color'], 0.15 ), null, min( 4, $bh / 2 ) ); } } // Lines. $draw = function ( array $series, $max ) use ( $xat, $bottom, $height ) { foreach ( $series as $s ) { $pts = array(); foreach ( $s['data'] as $i => $v ) { $pts[] = array( $xat( $i ), $bottom - ( $max > 0 ? $height * $v / $max : 0 ) ); } if ( 1 === count( $pts ) ) { $this->pdf->circle( $pts[0][0], $pts[0][1], 2.5, $s['color'] ); continue; } if ( ! empty( $s['area'] ) ) { $area = array_merge( array( array( $pts[0][0], $bottom ) ), $pts, array( array( $pts[ count( $pts ) - 1 ][0], $bottom ) ) ); $this->pdf->path( $area, null, Document::tint( $s['color'], 0.88 ) ); } $this->pdf->path( $pts, $s['color'], null, 1.6 ); if ( count( $pts ) <= 12 ) { foreach ( $pts as $pt ) { $this->pdf->circle( $pt[0], $pt[1], 2.2, $s['color'] ); } } } }; $draw( $left, $lmax ); $draw( $right, $rmax ); $this->pdf->line( $x0, $bottom, $x1, $bottom, self::PREV, 0.6 ); // Legend. $lx = $x0; $ly = $bottom + 27; foreach ( array_merge( $bars, $left, $right ) as $s ) { $this->pdf->rect( $lx, $ly - 6.5, 8, 8, $s['color'], null, 2 ); $lx += 12 + $this->pdf->width( $this->clean( $s['label'] ), 'r', 8.5 ); $this->t( $lx - $this->pdf->width( $this->clean( $s['label'] ), 'r', 8.5 ), $ly, $s['label'], 'r', 8.5, self::INK2 ); $lx += 14; } $this->y = $ly + 14; } /** * Every value in a set of series. * * @param array[] $series Series. * @return float[] */ private static function values( array $series ) { $out = array(); foreach ( $series as $s ) { $out = array_merge( $out, array_map( 'floatval', $s['data'] ) ); } return $out; } /** * A round axis maximum above the data, for four whole-number grid steps. * * @param float[] $values Values. * @param float $min Smallest maximum. * @return float */ private static function nice_max( array $values, $min = 4.0 ) { $max = max( (float) $min, $values ? (float) max( $values ) : 0.0 ); $raw = $max / 4; $exp = pow( 10, floor( log10( $raw ) ) ); $f = $raw / $exp; $nice = $f <= 1 ? 1 : ( $f <= 2 ? 2 : ( $f <= 2.5 ? 2.5 : ( $f <= 5 ? 5 : 10 ) ) ); return 4 * max( 1, $nice * $exp ); } /** * Compact axis number: 1.2K, 3.4M. * * @param float $v Value. * @return string */ private static function compact( $v ) { if ( $v >= 1e6 ) { return number_format_i18n( $v / 1e6, 1 ) . 'M'; } if ( $v >= 1e4 ) { return number_format_i18n( $v / 1e3, 0 ) . 'K'; } if ( $v >= 1e3 ) { return number_format_i18n( $v / 1e3, 1 ) . 'K'; } return abs( $v - round( $v ) ) < 0.01 ? Format::number( $v ) : number_format_i18n( $v, 1 ); } /** Height of one row of stat boxes. */ const STAT_H = 46.0; /** * The three pages that gained and lost the most clicks, side by side. * * @param bool $measure Return the height instead of drawing. * @return float */ private function pages( $measure = false ) { $s = isset( $this->c['data']['search']['pages'] ) ? $this->c['data']['search']['pages'] : null; if ( ! $s || ( ! $s['up'] && ! $s['down'] ) ) { return 0; } $h = self::CARD_TOP + 3 * self::BAR_H + 8; if ( $measure ) { return $h + self::GAP; } $up = array_slice( $s['up'], 0, 3 ); $down = array_slice( $s['down'], 0, 3 ); $max = 0.0; foreach ( array_merge( $up, $down ) as $r ) { $max = max( $max, abs( (float) $r['dClicks'] ) ); } $half = ( self::inner() - self::GAP ) / 2; $top = $this->y; foreach ( array( array( __( 'Pages that gained clicks', 'turtlereport' ), $up, self::MARGIN, __( 'No page gained clicks.', 'turtlereport' ), self::GOOD ), array( __( 'Pages that lost clicks', 'turtlereport' ), $down, self::MARGIN + $half + self::GAP, __( 'No page lost clicks.', 'turtlereport' ), self::BAD ), ) as $col ) { list( $title, $rows, $x, $empty, $color ) = $col; $this->card( $x, $top, $half, $h, $title, __( 'Change in clicks', 'turtlereport' ) ); if ( ! $rows ) { $this->t( $x + self::CARD_PAD, $top + self::CARD_TOP + 10, $empty, 'r', 8.5, self::MUTED ); continue; } $items = array(); foreach ( $rows as $r ) { $items[] = array( 'label' => self::path( $r['key'] ), 'value' => $r['dClicks'], 'text' => self::signed( $r['dClicks'] ), ); } $this->page_bars( $x + self::CARD_PAD, $top + self::CARD_TOP + 1, $half - 2 * self::CARD_PAD, $items, $color, $max ); } $this->y = $top + $h + self::GAP; return 0; } /** * Bars for pages: the path above a bar, the change on the right. * * @param float $x Left. * @param float $y Top. * @param float $w Width. * @param array[] $items Items: label, value, text. * @param string $color Colour. * @param float $max Value of a full bar. * @return void */ private function page_bars( $x, $y, $w, array $items, $color, $max ) { $label_w = $w * 0.46; $track_x = $x + $label_w + 6; $track_w = $w - $label_w - 6 - 34; foreach ( $items as $i => $it ) { $ry = $y + $i * self::BAR_H; $this->t( $x, $ry + 9.5, $it['label'], 'r', 8.5, self::INK2, 'left', $label_w ); $this->pdf->rect( $track_x, $ry + 4, $track_w, 6, '#eef0f3', null, 3 ); $bw = $max > 0 ? $track_w * min( 1, abs( (float) $it['value'] ) / $max ) : 0; if ( $bw > 0 ) { $this->pdf->rect( $track_x, $ry + 4, max( 6, $bw ), 6, $color, null, 3 ); } $this->t( $x + $w, $ry + 9.5, $it['text'], 'b', 8.5, $color, 'right' ); } } /** * The notes for this report, cut to fit what's left of page 2. * * @param bool $measure Return the height instead of drawing. * @return float */ private function notes( $measure = false ) { $notes = trim( (string) $this->c['notes'] ); if ( '' === $notes ) { return 0; } $width = self::inner() - 2 * self::CARD_PAD; $lines = $this->pdf->wrap( $this->clean( $notes ), 'r', 9.5, $width ); $room = (int) floor( ( self::BOTTOM - 22 - $this->y - self::CARD_TOP - 8 ) / 13.5 ); $show = min( count( $lines ), max( 0, $room ), 8 ); $h = self::CARD_TOP + $show * 13.5 + 6; if ( $measure ) { return $show ? $h : 0; } if ( $show < count( $lines ) ) { $lines = array_slice( $lines, 0, $show ); $lines[ $show - 1 ] = $this->pdf->fit( $lines[ $show - 1 ] . ' …', 'r', 9.5, $width ); } $top = $this->y; $this->card( self::MARGIN, $top, self::inner(), $h, __( 'Notes', 'turtlereport' ), '', $this->c['brand']['color'] ); $this->y = $top + self::CARD_TOP; foreach ( $lines as $line ) { $this->pdf->text( self::MARGIN + self::CARD_PAD, $this->y + 9.5, $line, 'r', 9.5, self::INK2 ); $this->y += 13.5; } $this->y = $top + $h + self::GAP; return 0; } /** * One small line on where the numbers come from, above the footer. * * @return void */ private function footnote() { $text = __( 'Google Search Console runs about two days behind. Conversions are GA4 event counts from organic search; each event is counted once.', 'turtlereport' ); if ( $this->missing ) { $text .= ' ' . __( 'Characters this PDF can\'t show appear as "?".', 'turtlereport' ); } $lines = $this->pdf->wrap( $this->clean( $text ), 'r', 7.5, self::inner() ); $y = 800 - 10 * count( $lines ); foreach ( $lines as $line ) { $this->pdf->text( self::MARGIN, $y, $line, 'r', 7.5, self::MUTED ); $y += 10; } } }