Badge settings. * @param array $aggregate Resolved aggregate. * @param array $palette Resolved colours. * @param float $rating Rating. * @param int $count Review count. * * @return array{value: string, count_label: string, footer_text: string, marks: array, show_stars: bool} */ protected static function content_parts($settings, $aggregate, $palette, $rating, $count) { // Resolved before the labels, because the medal's count names the // platform when there is exactly one of them. $aggregate_providers = ( isset($aggregate['providers']) && is_array($aggregate['providers']) ) ? array_values(array_filter($aggregate['providers'], 'is_string')) : array(); $style = ( isset($settings['style']) && in_array($settings['style'], array( 'chip', 'card', 'medal' ), true) ) ? (string) $settings['style'] : 'card'; list($value, $count_label) = self::rating_texts($settings, $rating, $count, $style, $aggregate_providers); // A 0-10 Booking score cannot be drawn on five stars without silently // rescaling it. prepare() suppresses the row for exactly that reason // (`'show_stars' => $args['stars'] && ! $is_ten`); this drew them anyway, // so the same badge showed a score-and-word on the page and five stars // in an inbox. $is_ten = ( isset($aggregate['scale']) && is_numeric($aggregate['scale']) ? (int) $aggregate['scale'] : 5 ) === 10; $show_stars = ! empty($settings['show_stars']) && ! $is_ten; // The attribution is not optional and not this file's decision. Below // Pro Plus, or on a lapsed licence, SBR_Badge_Renderer::show_powered_by() // forces it and calls itself "intentionally NOT filterable" — but this // endpoint is unauthenticated and never asked, so the emailed badge // carried none for any tier. Asked through the renderer so the image and // the page can only ever give the same answer. $forced_attribution = SBR_Badge_Renderer::show_powered_by(array( 'powered_by' => false )); $footer_text = $forced_attribution ? __('Powered by Smash Balloon', 'reviews-feed') : ( ! empty($settings['show_footer']) && ! empty($settings['footer_text']) && is_scalar($settings['footer_text']) ? (string) $settings['footer_text'] : '' ); // The platforms come from the AGGREGATE, which is where the renderer gets // them (prepare() reads $aggregate['providers']). Looking them up again // from the badge's stored `sources` returned nothing for a badge that // resolves its feed by id rather than by source — while the page showed // three marks. One lookup, one answer — resolved above, so the labels and // the marks cannot disagree about which platforms are on the badge. $marks = ! empty($settings['show_logos']) ? self::logo_pngs($settings, $aggregate_providers, $palette) : array(); return array( 'value' => $value, 'count_label' => $count_label, 'footer_text' => $footer_text, 'marks' => $marks, 'show_stars' => $show_stars, ); } /** * How big the CARD is, and where its parts sit. * * Split out because build_svg() was doing three jobs — decide what to show, * work out how big it is, then draw it — and the first two are pure * arithmetic over the card's measured metrics. Together they carried a * cyclomatic complexity of 31 against a limit of 20. * * Card only. Chip and medal do their own arithmetic in layout_chip() and * layout_medal(); what all three share is content_parts(). * * @param array $settings Sanitised badge settings. * @param array $aggregate Rating aggregate. * @param array $palette Resolved colours. * @param float $rating Average rating. * @param int $count Review count. * @param array{pad_x: int, pad_y: int, gap: int, col_gap: int, val_size: int, cnt_size: int, star_size: int, star_gap: int, foot_h: int, scale: int} $m The card's metrics, at 1x. * * @return array{value: string, count_label: string, footer_text: string, marks: array, val_w: float, stars_w: int, cnt_w: float, col_w: float, logos_w: int, mark_h: int, col_h: float, main_h: float, w: int, h: int} */ protected static function measure($settings, $aggregate, $palette, $rating, $count, $m) { $parts = self::content_parts($settings, $aggregate, $palette, $rating, $count); $value = $parts['value']; $count_label = $parts['count_label']; $footer_text = $parts['footer_text']; $marks = $parts['marks']; $show_stars = $parts['show_stars']; // Column widths, so the canvas can hug its content the way an // inline-flex badge does. A fixed canvas left the content in the // top-left corner of a box sized for a logo row that was never drawn. $val_w = $value !== '' ? self::text_width($value, $m['val_size'], true) : 0; $stars_w = $show_stars ? ( $m['star_size'] * 5 ) + ( $m['star_gap'] * 4 ) : 0; $cnt_w = $count_label !== '' ? self::text_width($count_label, $m['cnt_size'], false) : 0; $col_w = max($stars_w, $cnt_w); // Square marks overlap by 3, matching .sbr-tb-logos on the page; a full // lockup is one wide element and overlaps nothing. Widths come back from // the rasteriser rather than being assumed, so a lockup keeps its own // aspect instead of being squashed into a 24px box. $logos_w = 0; $mark_h = 0; foreach ($marks as $i => $mark) { $logos_w += $mark['w'] - ( $i > 0 ? 3 : 0 ); $mark_h = max($mark_h, $mark['h']); } $inner = $val_w; if ($col_w > 0) { $inner += ( $inner > 0 ? $m['gap'] : 0 ) + $col_w; } if ($logos_w > 0) { // The divider is a 1px rule with `gap` on its left and 14 on its // right — .sbr-tb-card-src's border-left plus its padding-left. $inner += $m['gap'] + 1 + 14 + $logos_w; } $col_h = ( $stars_w > 0 && $cnt_w > 0 ) ? $m['star_size'] + $m['col_gap'] + 15 : max($stars_w > 0 ? $m['star_size'] : 0, $cnt_w > 0 ? 15 : 0); $main_h = max($m['val_size'], $col_h, $mark_h) + ( $m['pad_y'] * 2 ); $w = (int) round(( $inner + ( $m['pad_x'] * 2 ) + 2 ) * $m['scale']); $h = (int) round(( $main_h + ( $footer_text !== '' ? $m['foot_h'] : 0 ) + 2 ) * $m['scale']); return array( 'value' => $value, 'count_label' => $count_label, 'footer_text' => $footer_text, 'marks' => $marks, 'val_w' => $val_w, 'stars_w' => $stars_w, 'cnt_w' => $cnt_w, 'col_w' => $col_w, 'logos_w' => $logos_w, 'mark_h' => $mark_h, 'col_h' => $col_h, 'main_h' => $main_h, 'w' => $w, 'h' => $h, ); } /** * The footer strip. * * A BAND, not a line of text at the bottom: .sbr-tb-foot is a tinted strip * the full width of the badge, and the first version of this drew only the * words, which is why the email badge read as unfinished next to the page. * The path follows the surface's bottom corners so the tint stops where the * radius does instead of squaring them off. * * @param string $text Footer copy. * @param int $w Canvas width. * @param int $h Canvas height. * @param int $band_y Top of the band. * @param int $radius Corner radius, already scaled. * @param int $size Font size, already scaled. * @param string $font Font stack. * @param array{foot: string, muted: string} $palette Resolved colours. * @param int $scale Pixel doubling. * * @return string */ protected static function footer_band_svg($text, $w, $h, $band_y, $radius, $size, $font, $palette, $scale) { return sprintf( '', $band_y, $w - 2, $h - 2 - $radius, $radius, 2 + $radius, esc_attr($palette['foot']) ) . sprintf( '%s', (int) ( $w / 2 ), $band_y + ( 17 * $scale ), esc_attr($font), $size, esc_attr($palette['muted']), esc_html($text) ); } /** * The divider and the platform marks. * * Split out of build_svg(), which was carrying a cyclomatic complexity of 34 * against a limit of 20 once the logo row landed — and the row is a separate * job from laying out the rating anyway. * * @param array $marks Rasterised marks. * @param float $rule_x Left edge of the divider. * @param float $mid Vertical centre of the content row. * @param int $mark_h Tallest mark, for the divider. * @param array{surface: string, line: string} $palette Resolved colours. * @param int $scale Pixel doubling. * @param array|null $rasters Collector, forwarded * from build_svg(). * @param bool $rule Draw the card's border-left rule. * * @return string */ protected static function logo_row_svg($marks, $rule_x, $mid, $mark_h, $palette, $scale, &$rasters = null, $rule = true) { // The rule is .sbr-tb-card-src's border-left. Only the card has one: chip // and medal put the logo row on its own centred line with nothing to // separate it from, and drawing it there left a stray tick beside them. $svg = $rule ? sprintf( '', (int) $rule_x, (int) ( $mid - ( ( $mark_h / 2 ) * $scale ) ), $scale, $mark_h * $scale, esc_attr($palette['line']) ) : ''; $mx = $rule ? $rule_x + ( 15 * $scale ) : $rule_x; foreach ($marks as $i => $mark) { if ($i > 0) { // Negative sibling margin, exactly as .sbr-tb-logos stacks them: // the ring is what separates the marks, so a real gap would read // as merely spaced rather than stacked. $mx -= 3 * $scale; } if ($mark['tint'] !== '') { // The chip plus its ring. On the page the ring is a box-shadow // in the surface colour; a stroke renders identically and is // something MSVG can draw. $svg .= sprintf( '', (int) ( $mx + ( ( $mark['w'] / 2 ) * $scale ) ), (int) $mid, (int) ( ( $mark['w'] / 2 ) * $scale ), esc_attr($mark['tint']), esc_attr($palette['surface']), (int) ( 1.5 * $scale ) ); } $svg .= self::raster_tag( (int) ( $mx + ( ( ( $mark['w'] - $mark['iw'] ) / 2 ) * $scale ) ), (int) ( $mid - ( ( $mark['ih'] / 2 ) * $scale ) ), $mark['iw'] * $scale, $mark['ih'] * $scale, $mark['png'], $rasters ); $mx += $mark['w'] * $scale; } return $svg; } /** * The circle a mark sits in. * * Seven providers carry their own light brand tint in sbr-trust-badge.css; * everything else falls back to the neutral, which the design says is the * right answer for a mark we have no brand colour for rather than a guess at * one. The oklch values are transcribed from that stylesheet, not re-picked, * so the chip behind a Google mark is the same colour in an inbox as on the * page. * * Under mono the chip goes neutral for every provider — a brand tint under a * single-ink mark is the one thing mono is meant to remove. * * @param string $provider Provider slug. * @param bool $mono Whether the row is drawn in one ink. * @param array $palette Resolved colours. * * @return string Hex colour. */ protected static function provider_tint($provider, $mono, $palette) { // L, C, H — the same triples as the stylesheet. static $tints = array( 'google' => array( 0.953, 0.021, 262 ), 'yelp' => array( 0.952, 0.021, 18 ), 'facebook' => array( 0.952, 0.021, 259 ), 'trustpilot' => array( 0.945, 0.026, 169 ), 'wordpressorg' => array( 0.945, 0.015, 229 ), 'wordpress.org' => array( 0.945, 0.015, 229 ), 'tripadvisor' => array( 0.957, 0.031, 166 ), 'woocommerce' => array( 0.944, 0.032, 302 ), ); if ($mono || ! isset($tints[ $provider ])) { // gray-100 on light; on dark and tinted the stylesheet lightens the // badge's own surface instead, which the palette already holds. return isset($palette['chip']) ? (string) $palette['chip'] : '#f3f4f6'; } $tint = $tints[ $provider ]; return self::oklch_to_hex((float) $tint[0], (float) $tint[1], (float) $tint[2]); } /** * One provider mark, from whichever rasteriser can actually draw it. * * Imagick reads the same SVG the page serves. GD cannot read SVG at all, so it * loads the derived PNG that build/generate-badge-marks.php produced from that * same SVG — one source of truth, two transforms, and BadgeMarkAssetsTest * fails if the committed PNGs stop matching. * * The order matters and used to be wrong. This branched on * `class_exists('\Imagick')` alone, which is the question `rasteriser()` can * answer — not the question that matters. On a host where Imagick is INSTALLED * but cannot draw SVG (a policy.xml with the SVG/MSVG coder disabled, standard * hardening after CVE-2016-3714), render_png() falls back to GD for the badge * body while this still asked Imagick for the marks, got null for the same * reason, and logo_pngs()' all-or-nothing rule dropped the WHOLE logo row. So * the badge rendered on exactly the host the fallback exists for, crediting no * platform at all. * * Now it tries Imagick and falls through to GD when that comes back empty, * which also covers the per-mark case: an SVG MSVG cannot draw (facebook-mono * uses a ) no longer costs the row every other mark in it. * * @param string $provider Provider key. * @param string $variant 'icon', 'mono' or 'lockup'. * @param string $ink Colour to flatten to, or '' to keep the artwork. * @param int $w Target width in CSS pixels. * @param int $h Target height in CSS pixels. * * @return array{png: string, w: int, h: int}|null */ protected static function mark_for($provider, $variant, $ink, $w, $h) { // `static::`, not `self::`: self:: is early-bound to this class, so a // subclass overriding either seam would be ignored and the test double // below could not reach the hardened-host combination at all. if (static::imagick_available()) { $svg = ''; if ($variant === 'lockup' && DisplayElements::has_provider_lockup($provider)) { $svg = DisplayElements::get_provider_lockup($provider); } elseif ($variant === 'mono' && DisplayElements::has_provider_mono_icon($provider)) { $svg = DisplayElements::get_provider_mono_icon($provider); } elseif (DisplayElements::has_provider_icon($provider)) { $svg = DisplayElements::get_provider_icon($provider); } if (is_string($svg) && $svg !== '') { $mark = static::rasterise_mark($svg, $ink, $w, $h); if ($mark !== null) { return $mark; } } } return static::gd_mark($provider, $variant, $ink, $w, $h); } /** * Whether this host has the Imagick class at all. * * A method rather than an inline `class_exists()` so a test can force the * branch — the failure this guards is only reachable on a host with Imagick * installed and unable to draw, which no test machine can be asked to become. * * @return bool */ protected static function imagick_available() { return class_exists('\Imagick'); } /** * One provider mark from GD's derived PNG set. * * @param string $provider Provider key. * @param string $variant 'icon', 'mono' or 'lockup'. * @param string $ink Colour to flatten to, or '' to keep the artwork. * @param int $w Target width in CSS pixels. * @param int $h Target height in CSS pixels. * * @return array{png: string, w: int, h: int}|null */ protected static function gd_mark($provider, $variant, $ink, $w, $h) { if (! SBR_Badge_Gd::available()) { return null; } $gd = SBR_Badge_Gd::mark($provider, $variant, $ink, $w, $h); // Rebuilt rather than passed through: the shape is the contract both // rungs promise their callers, and asserting it here is cheaper than // trusting two renderers to keep agreeing about it. if (! is_array($gd) || ! isset($gd['png'], $gd['w'], $gd['h'])) { return null; } return array( 'png' => (string) $gd['png'], 'w' => (int) $gd['w'], 'h' => (int) $gd['h'], ); } /** * Rasterise one mark to a PNG at 2x, or null when MSVG cannot draw it. * * @param string $svg SVG markup. * @param string $ink Colour to flatten the artwork to, or '' to keep it. * @param int $w Target width in CSS pixels. * @param int $h Target height in CSS pixels. * * @return array{png: string, w: int, h: int}|null */ protected static function rasterise_mark(string $svg, string $ink, int $w, int $h): ?array { // Two of the fifteen marks are not vector art at all: Formidable and // AliExpress each wrap an embedded bitmap in a , and MSVG draws // neither patterns nor . The bitmap IS the mark, so it is decoded // and used directly rather than being asked of a renderer that cannot // reach it. This is what took the row from "drops out whenever one of // these two is connected" to complete. $bitmap = self::embedded_bitmap($svg); if ($bitmap !== '') { return self::rasterise_blob($bitmap, '', $w, $h); } $svg = self::msvg_safe($svg); if ($ink !== '') { // The mono artwork paints with `fill="currentColor"`, which MSVG // does not resolve — it has no cascade to inherit from. Substituting // the literal ink is the equivalent, and it also flattens any stray // brand colour in a mark that has no mono variant. $svg = str_replace('currentColor', $ink, $svg); $svg = (string) preg_replace('/fill="(?!none)[^"]*"/', 'fill="' . $ink . '"', $svg); } return self::rasterise_blob($svg, $ink, $w, $h); } /** * Rasterise any blob Imagick can read, at 2x. * * @param string $blob Image bytes — SVG markup or a bitmap. * @param string $ink Unused here; kept so both callers read alike. * @param int $w Target width in CSS pixels. * @param int $h Target height in CSS pixels. * * @return array{png: string, w: int, h: int}|null */ protected static function rasterise_blob($blob, $ink, $w, $h) { try { $image = new \Imagick(); $image->setBackgroundColor(new \ImagickPixel('transparent')); $image->readImageBlob($blob); $image->setImageFormat('png32'); $image->resizeImage($w * 2, $h * 2, \Imagick::FILTER_LANCZOS, 1, true); if (self::is_blank_png($image)) { return null; } // The real size after bestfit, so a wide lockup is not squashed into // a square box by the caller. return array( 'png' => $image->getImageBlob(), 'w' => (int) round($image->getImageWidth() / 2), 'h' => (int) round($image->getImageHeight() / 2), ); } catch (\Throwable $e) { return null; } } /** * The bitmap inside a pattern-wrapped SVG, decoded. * * @param string $svg SVG markup. * * @return string Image bytes, or '' when the file is real vector art. */ protected static function embedded_bitmap($svg) { // Only when the whole file is a bitmap in a pattern. A vector mark that // happens to carry a small embedded image somewhere must NOT be replaced // by that image, so the pattern is part of the test rather than the // data URI alone. if (strpos($svg, ' is ignored, so a mark that keeps its colours in a * class renders as one flat shape. Facebook is a CorelDRAW export and * came out a black disc; folding the classes into presentation * attributes is lossless for the `fill:` declarations these files carry. * 2. is unsupported and takes the artwork with it. Airbnb and * Booking.com both clip to a rect the size of their own canvas, so the * clip is inert and dropping the reference restores the mark unchanged. * 3. A FRACTIONAL viewBox is rejected outright. TripAdvisor's is * `0 0 1333.31 1333.31` and drew nothing until it was rounded — that one * cost the longest to find, because every other property of the file is * ordinary. Rounding up never crops; it adds at most a pixel of margin. * * @param string $svg SVG markup. * * @return string */ protected static function msvg_safe($svg) { // 1 — classes to attributes. if (preg_match_all('/\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}/', $svg, $rules, PREG_SET_ORDER)) { foreach ($rules as $rule) { $attrs = ''; foreach (explode(';', $rule[2]) as $declaration) { // strpos + substr rather than explode: a limited explode is // typed as a plain list, so neither a count check nor list() // proves to the analyser that both halves are there. $colon = strpos($declaration, ':'); if ($colon === false) { continue; } $property = trim(substr($declaration, 0, $colon)); $value = trim(substr($declaration, $colon + 1)); if ($property !== '' && $value !== '') { $attrs .= ' ' . $property . '="' . $value . '"'; } } $svg = str_replace('class="' . $rule[1] . '"', trim($attrs), $svg); } $svg = (string) preg_replace('/]*>.*?<\/style>/s', '', $svg); } // 2 — inert clips. $svg = (string) preg_replace('/\s*clip-path="url\(#[^)]*\)"/', '', $svg); $svg = (string) preg_replace('//s', '', $svg); // 3 — integer viewBox, with width/height to match. if (preg_match('/]*>/', $svg, $tag)) { $open = $tag[0]; if (preg_match('/viewBox="\s*([\d.\-]+)\s+([\d.\-]+)\s+([\d.]+)\s+([\d.]+)\s*"/', $open, $vb)) { $width = (int) ceil((float) $vb[3]); $height = (int) ceil((float) $vb[4]); $rewritten = (string) preg_replace( '/viewBox="[^"]*"/', sprintf('viewBox="%d %d %d %d"', (int) floor((float) $vb[1]), (int) floor((float) $vb[2]), $width, $height), $open ); if (strpos($rewritten, ' width=') === false) { $rewritten = str_replace('trimImage(0); return $probe->getImageWidth() <= 1 && $probe->getImageHeight() <= 1; } catch (\Throwable $e) { // Trim throws on an image it cannot reduce. Treat that as "has // content" — a mark that draws is the common case. return false; } } /** * The five colours a themed badge needs, resolved to literal values. * * The stylesheet derives these from tokens and, for tinted, from a hue at * render time. An SVG handed to a rasteriser cannot do either, so the same * derivations are computed here — the tinted formula is the design's own * `oklch(0.45 0.13 h)` family, converted to sRGB. * * @param string $theme light|dark|tinted. * @param int $tint_hue Hue for the tinted theme. * @param int $accent_hue Star accent hue, or -1 for the default gold. * * @return array{surface: string, ink: string, muted: string, line: string, star: string, starBase: string, foot: string, chip: string} */ protected static function palette($theme, $tint_hue, $accent_hue = -1) { // The accent the author picked, derived exactly as sbr-trust-badge.css // derives it: `oklch(0.6 0.25 h)` on light, `oklch(0.78 0.19 h)` on dark // — a different lightness per theme from one stored hue, which is why the // hue is stored rather than a hex. // // It was missing here entirely, so a badge with cyan stars on the page // mailed out with gold ones. Tinted is excluded on purpose and matches // the stylesheet: its stars are white, and the accent does not apply. $accent_star = ( $accent_hue >= 0 && $theme !== 'tinted' ) ? ( $theme === 'dark' ? self::oklch_to_hex(0.78, 0.19, $accent_hue) : self::oklch_to_hex(0.6, 0.25, $accent_hue) ) : ''; if ($theme === 'dark') { return array( 'surface' => '#3a4150', 'ink' => '#ffffff', 'muted' => '#9aa1ae', 'line' => '#4a5160', 'star' => $accent_star !== '' ? $accent_star : '#f5b400', 'starBase' => '#5b6270', // The footer band. On the page it is `oklch(1 0 0 / 0.05)` over // the surface; an SVG handed to a rasteriser cannot composite // alpha over a sibling, so it is pre-flattened. 'foot' => '#434a58', // The neutral chip: the stylesheet lightens the badge's own // surface here rather than using a grey, so the logo row belongs // to the badge instead of sitting on top of it. 'chip' => '#4a5261', ); } if ($theme === 'tinted') { $rgb = self::oklch_to_hex(0.45, 0.13, $tint_hue); return array( 'surface' => $rgb, 'ink' => '#ffffff', 'muted' => self::oklch_to_hex(0.88, 0.04, $tint_hue), 'line' => self::oklch_to_hex(0.55, 0.12, $tint_hue), 'star' => '#ffffff', 'starBase' => self::oklch_to_hex(0.58, 0.11, $tint_hue), // Derived from the hue like everything else here, so the footer // band and the chips belong to the tint rather than sitting on // it as grey rectangles. 'foot' => self::oklch_to_hex(0.41, 0.13, $tint_hue), 'chip' => self::oklch_to_hex(0.52, 0.12, $tint_hue), ); } return array( 'surface' => '#ffffff', 'ink' => '#101828', 'muted' => '#6b7280', 'line' => '#e5e7eb', 'star' => $accent_star !== '' ? $accent_star : '#f5b400', 'starBase' => '#e5e7eb', 'foot' => '#f9fafb', 'chip' => '#f3f4f6', ); } /** * The star row, rasterised. * * Returned as an rather than paths, and the half star is why. * templates/frontend/badges/stars.php draws a half by CLIPPING a whole star, * which is what a browser wants — but MSVG applies `clip-path` to the wrong * side, so 4.8 came out with the fifth star filled on the RIGHT: the exact * inverse of the page. Two other SVG routes were measured and neither * survives MSVG either — a two-stop `linearGradient` (the design's own * technique) resolves in the wrong coordinate space, and a nested `` * viewport is dropped entirely. * * So the halving happens where it is unambiguous: each glyph is rasterised * whole, and the accent copy is CROPPED to its left half before being * composited over the base copy. Cropping a bitmap has one meaning on every * renderer there is. * * @param float $rating Rating out of 5. * @param array{star: string, starBase: string} $palette Resolved colours. * @param int $size Glyph box in CSS pixels. * @param int $gap Space between glyphs in CSS pixels. * @param int $scale Pixel doubling. * * @return string PNG bytes, or '' when the row cannot be drawn. */ protected static function stars_png($rating, $palette, $size, $gap, $scale) { // GD draws the row from the same STAR_PATH, as a polygon. Checked before the // Imagick branch would return '' — a badge with no stars is a worse answer // than one drawn by the other rasteriser. if (! class_exists('\Imagick')) { return SBR_Badge_Gd::star_row($rating, $palette, $size, $gap, $scale); } $full = self::star_png($palette['star'], $size * $scale); $base = self::star_png($palette['starBase'], $size * $scale); if ($full === null || $base === null) { return ''; } // Floors to a half for the same reason the template does: five glyphs // cannot draw 4.8. $display = floor(max(0, min(5, (float) $rating)) * 2) / 2; try { $row = new \Imagick(); $row->newImage( ( ( $size * 5 ) + ( $gap * 4 ) ) * $scale, $size * $scale, new \ImagickPixel('transparent'), 'png32' ); for ($i = 0; $i < 5; $i++) { $left = $i * ( $size + $gap ) * $scale; $remainder = $display - $i; if ($remainder >= 1) { $row->compositeImage(clone $full, \Imagick::COMPOSITE_OVER, (int) $left, 0); continue; } $row->compositeImage(clone $base, \Imagick::COMPOSITE_OVER, (int) $left, 0); if ($remainder === 0.5) { $half = clone $full; // LEFT half, which is the half the page fills. $half->cropImage((int) ( ( $size * $scale ) / 2 ), $size * $scale, 0, 0); $row->compositeImage($half, \Imagick::COMPOSITE_OVER, (int) $left, 0); } } $row->setImageFormat('png32'); return $row->getImageBlob(); } catch (\Throwable $e) { return ''; } } /** * One star glyph, filled and rasterised. * * @param string $fill Hex colour. * @param int $px Square size in pixels. * * @return \Imagick|null */ protected static function star_png($fill, $px) { $path = self::STAR_PATH; $svg = sprintf( '', $px, $path, esc_attr($fill) ); try { $glyph = new \Imagick(); $glyph->setBackgroundColor(new \ImagickPixel('transparent')); $glyph->readImageBlob($svg); $glyph->setImageFormat('png32'); return $glyph; } catch (\Throwable $e) { return null; } } /** * oklch -> sRGB hex. * * The same conversion the sidebar's colour readout uses in JS, ported so the * image, the badge and the picker all describe one colour. Out-of-gamut * channels clamp, which is what a browser does with an oklch() it cannot * display. * * @param float $l Lightness 0-1. * @param float $c Chroma. * @param float $h Hue in degrees. * * @return string */ protected static function oklch_to_hex($l, $c, $h) { $hr = deg2rad((float) $h); $a = $c * cos($hr); $b = $c * sin($hr); $l_ = pow($l + 0.3963377774 * $a + 0.2158037573 * $b, 3); $m_ = pow($l - 0.1055613458 * $a - 0.0638541728 * $b, 3); $s_ = pow($l - 0.0894841775 * $a - 1.2914855480 * $b, 3); $linear = array( 4.0767416621 * $l_ - 3.3077115913 * $m_ + 0.2309699292 * $s_, -1.2684380046 * $l_ + 2.6097574011 * $m_ - 0.3413193965 * $s_, -0.0041960863 * $l_ - 0.7034186147 * $m_ + 1.7076147010 * $s_, ); $hex = '#'; foreach ($linear as $channel) { $v = max(0.0, min(1.0, $channel)); $v = $v <= 0.0031308 ? 12.92 * $v : 1.055 * pow($v, 1 / 2.4) - 0.055; $hex .= str_pad(dechex((int) round($v * 255)), 2, '0', STR_PAD_LEFT); } return $hex; } }