From 195c5bf4447a3a00871f67faca3af63f32af83e8 Mon Sep 17 00:00:00 2001 From: Ismar Tricic Date: Tue, 20 Oct 2026 16:17:50 +0200 Subject: [PATCH 1/2] feat: draw code commits as a code window in their share image A commit with a fenced code block now shares as a dark editor window with its first block highlighted, instead of the raw fences in Geist. Commits without a code block draw as before. --- KNOWLEDGE_BASE.md | 4 +- app/Actions/CreateCommitOpenGraphImage.php | 170 ++++++++++++++++-- app/Services/HighlightedCode.php | 84 +++++++++ app/Services/OpenGraphCanvas.php | 124 ++++++++++++- .../CreateCommitOpenGraphImageTest.php | 33 ++++ tests/Unit/Services/HighlightedCodeTest.php | 76 ++++++++ tests/Unit/Services/OpenGraphCanvasTest.php | 92 ++++++++++ 7 files changed, 565 insertions(+), 18 deletions(-) create mode 100644 app/Services/HighlightedCode.php create mode 100644 tests/Unit/Services/HighlightedCodeTest.php create mode 100644 tests/Unit/Services/OpenGraphCanvasTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2bfc962a5..28a5bbe02 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -204,6 +204,8 @@ Sources: [translation endpoint](app/Http/Controllers/CommitTranslationController Commit and reply text supports inline code in backticks and fenced code blocks with syntax highlighting, a **Wrap lines** toggle, and a copy control. Text outside code supports emoji shortcodes, clickable URLs, and `@username` mentions. This is developer-oriented text formatting, not a full rich-text editor. +When a commit or reply has a fenced code block, its link preview image shows the first block, highlighted, in a dark editor window named after the block's language, with the first line of any text before it above. Later blocks and the text after the first block are left out of the image, but not the commit. Shorter code is drawn larger. Lines too wide for the window are cut with an ellipsis rather than wrapped, and code too long for it ends in an ellipsis line. Emoji in the code are left out of the image. Inline code and empty blocks keep the ordinary preview image. + In commits, replies, and remotes, start a line with `>` to render a quote block. Consecutive quoted lines stay together; an unquoted line ends the block. Quotes keep inline code, links, mentions, and emoji formatting. A `>` inside code or in the middle of a line stays literal. Typing **@** in a composer opens developer suggestions. Use ↑/↓ to move, Enter or Tab to select, and Esc to dismiss. A bare @ suggests people you follow; typing more searches names and usernames. In replies, matching suggestions put the direct recipient first, then the root commit author, then other visible participants in that thread, followed by ordinary suggestions. Quotes prioritize only the quoted commit author. These priorities also apply to a bare @; typing a term still filters everyone, and yourself, muted developers, and blocked developers are excluded. In an org's messages, suggestions search the loaded member list instantly and update as membership refreshes. Suggestions are limited to the org's members, but you can still type anyone's handle yourself. Mentions link to profiles and can notify the mentioned developer after approval. Email addresses and scoped packages such as `@inertiajs/react` are not treated as mentions. @@ -212,7 +214,7 @@ Typing **#123** in a composer looks up that exact issue or pull request in the / A hex color such as `#ff8a4c` or `#fff` shows with a small swatch of the color, outside code. A color written only with digits counts when it has 6 or 8 digits or starts with a zero, which issue numbers here don't: `#000`, `#000000` and `#333333` are colors, while `#123` and `#333` stay plain text. -Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). +Sources: [body renderer](resources/js/components/commit-body.tsx), [colors](resources/js/lib/colors.ts), [code blocks](resources/js/components/code-block.tsx), [code preview images](app/Actions/CreateCommitOpenGraphImage.php), [mention suggestions](app/Http/Controllers/MentionSuggestionController.php), [mention parsing](app/Services/Mentions.php). ### Draft previews diff --git a/app/Actions/CreateCommitOpenGraphImage.php b/app/Actions/CreateCommitOpenGraphImage.php index f501feb46..d4c52c61d 100644 --- a/app/Actions/CreateCommitOpenGraphImage.php +++ b/app/Actions/CreateCommitOpenGraphImage.php @@ -6,6 +6,8 @@ use App\Models\Commit; use App\Models\User; +use App\Services\CodeSpans; +use App\Services\HighlightedCode; use App\Services\OpenGraphCanvas; use App\Services\Urls; use Illuminate\Support\Facades\Cache; @@ -32,7 +34,26 @@ */ private const array BODY_SIZES = [[56, 72], [48, 64], [40, 56], [34, 48], [32, 44]]; - public function __construct(private Urls $urls) + private const int CODE_AVATAR_SIZE = 56; + + private const int CODE_GAP = 16; + + private const int CODE_MARGIN = 28; + + private const int PROSE_SIZE = 26; + + private const int PROSE_LINE_HEIGHT = 34; + + private const int PROSE_LINES = 1; + + /** + * Shorter code is drawn larger, as long as it fits. + * + * @var list + */ + private const array CODE_SIZES = [[28, 40], [24, 34], [22, 31], [20, 28]]; + + public function __construct(private Urls $urls, private CodeSpans $codeSpans, private HighlightedCode $highlightedCode) { // } @@ -54,34 +75,153 @@ private function render(User $user, Commit $commit): string $lane = $canvas->lane($user); $replyingTo = $commit->parent?->user?->handle; + $code = $this->code($commit->body); + + if ($code !== null) { + $this->drawCode($canvas, $user, $lane, $replyingTo, ...$code); + } else { + $this->drawBody($canvas, $user, $lane, $replyingTo, $commit->body); + } + + $canvas->footer(); + $this->meta($canvas, $commit); + + return $canvas->png(); + } + + private function drawBody(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, string $body): void + { $headerHeight = self::AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::BODY_GAP; - [$size, $lineHeight, $body] = $this->fit($canvas, $this->urls->withShortenedUrls($commit->body), OpenGraphCanvas::FOOTER_RULE_Y - self::MIN_BOTTOM - self::MIN_TOP - $headerHeight); + [$size, $lineHeight, $lines] = $this->fit($canvas, $this->urls->withShortenedUrls($body), OpenGraphCanvas::FOOTER_RULE_Y - self::MIN_BOTTOM - self::MIN_TOP - $headerHeight); + + $top = max(self::MIN_TOP, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - count($lines) * $lineHeight, 2)); + + $this->header($canvas, $user, $lane, $replyingTo, $top, self::AVATAR_SIZE); + $this->lines($canvas, $lines, $size, $lineHeight, $top + $headerHeight); + } + + /** + * The code block in an editor window, under a smaller header and the first line of the prose before it. + * + * @param non-empty-list}>> $lines + */ + private function drawCode(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, string $prose, ?string $language, array $lines): void + { + $headerHeight = self::CODE_AVATAR_SIZE + ($replyingTo === null ? 0 : self::REPLYING_TO_HEIGHT) + self::CODE_GAP; + + $prose = $prose === '' ? [] : $canvas->clamp($canvas->wrap($this->urls->withShortenedUrls($prose), 'Geist-Regular', self::PROSE_SIZE, self::BODY_WIDTH), self::PROSE_LINES, 'Geist-Regular', self::PROSE_SIZE, self::BODY_WIDTH); + $proseHeight = $prose === [] ? 0 : count($prose) * self::PROSE_LINE_HEIGHT + self::CODE_GAP; + + $chrome = OpenGraphCanvas::WINDOW_TITLE_HEIGHT + OpenGraphCanvas::WINDOW_PADDING; + [$size, $lineHeight, $lines] = $this->fitCode($canvas, $lines, OpenGraphCanvas::FOOTER_RULE_Y - 2 * self::CODE_MARGIN - $headerHeight - $proseHeight - $chrome); + $windowHeight = $chrome + count($lines) * $lineHeight; + + $top = max(self::CODE_MARGIN, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - $proseHeight - $windowHeight, 2)); + + $this->header($canvas, $user, $lane, $replyingTo, $top, self::CODE_AVATAR_SIZE); + $this->lines($canvas, $prose, self::PROSE_SIZE, self::PROSE_LINE_HEIGHT, $top + $headerHeight); - $top = max(self::MIN_TOP, intdiv(OpenGraphCanvas::FOOTER_RULE_Y - $headerHeight - count($body) * $lineHeight, 2)); + $windowTop = $top + $headerHeight + $proseHeight; + + $canvas->window(OpenGraphCanvas::CONTENT_X, $windowTop, self::BODY_WIDTH, $windowHeight, $language); + $canvas->code($lines, $size, $lineHeight, OpenGraphCanvas::CONTENT_X + OpenGraphCanvas::WINDOW_PADDING, $windowTop + OpenGraphCanvas::WINDOW_TITLE_HEIGHT, self::BODY_WIDTH - 2 * OpenGraphCanvas::WINDOW_PADDING); + } + + /** + * The avatar on the rail, with the name and handle beside it sized to the avatar. + * + * @param positive-int $avatarSize + */ + private function header(OpenGraphCanvas $canvas, User $user, int $lane, ?string $replyingTo, int $top, int $avatarSize): void + { + $nameSize = (int) round($avatarSize * 0.45); + $handleSize = (int) round($avatarSize * 0.325); - $canvas->rail($top + intdiv(self::AVATAR_SIZE, 2), $lane); - $canvas->avatar($user, OpenGraphCanvas::CONTENT_X + 10, $top, self::AVATAR_SIZE, $lane); + $canvas->rail($top + intdiv($avatarSize, 2), $lane); + $canvas->avatar($user, OpenGraphCanvas::CONTENT_X + 10, $top, $avatarSize, $lane); - $textX = OpenGraphCanvas::CONTENT_X + 10 + self::AVATAR_SIZE + 10 + 28; + $textX = OpenGraphCanvas::CONTENT_X + 10 + $avatarSize + 10 + 28; $textWidth = OpenGraphCanvas::CONTENT_RIGHT - $textX; - $canvas->text($canvas->truncate($canvas->name($user), 'Geist-SemiBold', 36, $textWidth), 'Geist-SemiBold', 36, $textX, $top + 36, OpenGraphCanvas::INK); - $canvas->handle($user, 26, $textX, $top + 70, $textWidth, $lane); + $canvas->text($canvas->truncate($canvas->name($user), 'Geist-SemiBold', $nameSize, $textWidth), 'Geist-SemiBold', $nameSize, $textX, $top + $nameSize, OpenGraphCanvas::INK); + $canvas->handle($user, $handleSize, $textX, $top + (int) round($avatarSize * 0.875), $textWidth, $lane); if ($replyingTo !== null) { - $canvas->text($canvas->truncate("↳ replying to @{$replyingTo}", 'GeistMono-Regular', 26, self::BODY_WIDTH), 'GeistMono-Regular', 26, OpenGraphCanvas::CONTENT_X, $top + self::AVATAR_SIZE + 38, OpenGraphCanvas::INK_MUTED); + $canvas->text($canvas->truncate("↳ replying to @{$replyingTo}", 'GeistMono-Regular', 26, self::BODY_WIDTH), 'GeistMono-Regular', 26, OpenGraphCanvas::CONTENT_X, $top + $avatarSize + 38, OpenGraphCanvas::INK_MUTED); } + } - $bodyTop = $top + $headerHeight; + /** + * @param list $lines + */ + private function lines(OpenGraphCanvas $canvas, array $lines, int $size, int $lineHeight, int $top): void + { + foreach ($lines as $index => $line) { + $canvas->text($line, 'Geist-Regular', $size, OpenGraphCanvas::CONTENT_X, $top + $index * $lineHeight + intdiv($lineHeight + $size, 2) - intdiv($size, 5), OpenGraphCanvas::INK); + } + } - foreach ($body as $index => $line) { - $canvas->text($line, 'Geist-Regular', $size, OpenGraphCanvas::CONTENT_X, $bodyTop + $index * $lineHeight + intdiv($lineHeight + $size, 2) - intdiv($size, 5), OpenGraphCanvas::INK); + /** + * The prose before the first code block, and that block's language and lines, or null when the body has no block + * or the first one is empty. Blank lines around the code are dropped, since the window has its own padding. + * + * @return array{string, string|null, non-empty-list}>>}|null + */ + private function code(string $body): ?array + { + $parts = $this->codeSpans->split($body); + + foreach ($parts as $index => $part) { + if ($index % 2 === 0 || ! str_starts_with($part, '```')) { + continue; + } + + [$block] = $this->codeSpans->highlight($part); + assert($block['type'] === 'block'); + + $lines = $this->highlightedCode->lines($block['html']); + + while ($lines !== [] && $lines[0] === []) { + array_shift($lines); + } + + while ($lines !== [] && array_last($lines) === []) { + array_pop($lines); + } + + if ($lines === []) { + return null; + } + + return [mb_trim(implode('', array_slice($parts, 0, $index))), $block['language'], $lines]; } - $canvas->footer(); - $this->meta($canvas, $commit); + return null; + } - return $canvas->png(); + /** + * Shorter code is drawn larger, as long as every line fits, then as long as their number does, cutting the + * longest. Code too long for the smallest size is cut after a muted ellipsis line. + * + * @param non-empty-list}>> $lines + * @return array{int, int, list}>>} + */ + private function fitCode(OpenGraphCanvas $canvas, array $lines, int $height): array + { + $longest = collect($lines)->max(fn (array $runs): int => mb_strlen(collect($runs)->implode('text'))); + $width = self::BODY_WIDTH - 2 * OpenGraphCanvas::WINDOW_PADDING; + + foreach ([true, false] as $isWidthFitted) { + foreach (self::CODE_SIZES as [$size, $lineHeight]) { + if (count($lines) * $lineHeight <= $height && (! $isWidthFitted || $longest <= $canvas->columns($size, $width))) { + return [$size, $lineHeight, $lines]; + } + } + } + + $count = max(1, intdiv($height, $lineHeight)); + + return [$size, $lineHeight, [...array_slice($lines, 0, $count - 1), [['text' => '…', 'classes' => ['hl-comment']]]]]; } /** diff --git a/app/Services/HighlightedCode.php b/app/Services/HighlightedCode.php new file mode 100644 index 000000000..4fd23e15a --- /dev/null +++ b/app/Services/HighlightedCode.php @@ -0,0 +1,84 @@ +}>> + */ + public function lines(string $html): array + { + $parts = preg_split('/(|<\/span>)/', $html, flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: []; + + /** @var list> $stack */ + $stack = []; + $lines = [[]]; + $column = 0; + + foreach ($parts as $part) { + if ($part === '') { + array_pop($stack); + + continue; + } + + if (str_starts_with($part, ' $text) { + if ($index > 0) { + $lines[] = []; + $column = 0; + } + + if ($text === '') { + continue; + } + + $text = $this->expandTabs($text, $column); + $column += mb_strlen($text); + + $lines[array_key_last($lines)][] = ['text' => $text, 'classes' => array_merge(...$stack)]; + } + } + + return $lines; + } + + /** + * Replaces each tab with the spaces up to the next tab stop, counting from where the text starts on its line. + */ + private function expandTabs(string $text, int $column): string + { + if (! str_contains($text, "\t")) { + return $text; + } + + $expanded = ''; + + foreach (mb_str_split($text) as $character) { + $expanded .= $character === "\t" + ? str_repeat(' ', self::TAB_WIDTH - ($column + mb_strlen($expanded)) % self::TAB_WIDTH) + : $character; + } + + return $expanded; + } +} diff --git a/app/Services/OpenGraphCanvas.php b/app/Services/OpenGraphCanvas.php index 2afe7c578..6d1aed0e2 100644 --- a/app/Services/OpenGraphCanvas.php +++ b/app/Services/OpenGraphCanvas.php @@ -34,6 +34,10 @@ public const int FOOTER_BASELINE = 524; + public const int WINDOW_TITLE_HEIGHT = 48; + + public const int WINDOW_PADDING = 24; + private const int SCALE = 2; private const int SHELL = 0xF6F7F8; @@ -46,6 +50,38 @@ private const array LANES = [0xD9480F, 0x2F9E44, 0x9C36B5, 0x0C8599, 0xC2255C, 0xB7791F]; + /** + * Geist has no emoji, so they are dropped rather than rendered as empty boxes. + */ + private const string EMOJI = '/[\p{Extended_Pictographic}\x{FE0F}\x{200D}\x{20E3}]/u'; + + private const int WINDOW_RADIUS = 16; + + private const int CODE_SHELL = 0x18181A; + + private const int CODE_INK = 0xECECEC; + + private const int CODE_INK_MUTED = 0xA1A1A6; + + /** + * The dark theme's colors for the classes tempest/highlight emits. Keep in sync with resources/css/app.css. + * + * @var array + */ + private const array CODE_COLORS = [ + 'hl-keyword' => 0x7AA2F7, + 'hl-type' => 0xFF2D20, + 'hl-generic' => 0xFF2D20, + 'hl-property' => 0x3BC9DB, + 'hl-value' => 0x51CF66, + 'hl-number' => 0xFF8A4C, + 'hl-literal' => 0xFF8A4C, + 'hl-variable' => 0xF06595, + 'hl-comment' => self::CODE_INK_MUTED, + 'hl-addition' => 0x51CF66, + 'hl-deletion' => 0xFF6B6B, + ]; + private GdImage $image; public function __construct() @@ -172,6 +208,82 @@ public function footer(): void } } + /** + * A dark editor window with three dots in the title bar, and the code's language on its right. + */ + public function window(int $x, int $y, int $width, int $height, ?string $language): void + { + $radius = self::WINDOW_RADIUS; + + $this->rect($x + $radius, $y, $x + $width - $radius, $y + $height, self::CODE_SHELL); + $this->rect($x, $y + $radius, $x + $width, $y + $height - $radius, self::CODE_SHELL); + + foreach ([[$x + $radius, $y + $radius], [$x + $width - $radius, $y + $radius], [$x + $radius, $y + $height - $radius], [$x + $width - $radius, $y + $height - $radius]] as [$cornerX, $cornerY]) { + $this->circle($cornerX, $cornerY, $radius, self::CODE_SHELL); + } + + foreach ([self::LANES[0], self::LANES[5], self::LANES[1]] as $index => $color) { + $this->circle($x + self::WINDOW_PADDING + 6 + $index * 24, $y + self::WINDOW_TITLE_HEIGHT / 2, 6, $color); + } + + $language = $this->clean((string) $language); + + if ($language !== '') { + $label = $this->truncate($language, 'GeistMono-Regular', 20, intdiv($width, 2)); + + $this->text($label, 'GeistMono-Regular', 20, $x + $width - self::WINDOW_PADDING - $this->width($label, 'GeistMono-Regular', 20), $y + intdiv(self::WINDOW_TITLE_HEIGHT, 2) + 7, self::CODE_INK_MUTED); + } + } + + /** + * Highlighted lines of code from the top of their first line, in the color of the innermost class that has one. + * GeistMono is monospaced, so each run is placed by its column, which keeps indentation that a measured width of + * leading spaces would lose. Lines are cut with an ellipsis at the width rather than wrapped. + * + * @param list}>> $lines + */ + public function code(array $lines, int $size, int $lineHeight, int $x, int $y, int $width): void + { + $advance = $this->advance($size); + $columns = $this->columns($size, $width); + + foreach ($lines as $index => $runs) { + $baseline = $y + $index * $lineHeight + intdiv($lineHeight + $size, 2) - intdiv($size, 5); + + $runs = collect($runs)->map(fn (array $run): array => [ + 'text' => (string) preg_replace(self::EMOJI, '', $run['text']), + 'color' => collect($run['classes'])->reverse()->map(fn (string $class): ?int => self::CODE_COLORS[$class] ?? null)->first(fn (?int $color): bool => $color !== null) ?? self::CODE_INK, + ]); + + $length = $runs->sum(fn (array $run): int => mb_strlen($run['text'])); + $limit = $length > $columns ? $columns - 1 : $length; + $column = 0; + + foreach ($runs as $run) { + $text = mb_substr($run['text'], 0, $limit - $column); + + if ($text !== '') { + $this->text($text, 'GeistMono-Regular', $size, (int) round($x + $column * $advance), $baseline, $run['color']); + $column += mb_strlen($text); + } + + if ($column === $limit && $length > $columns) { + $this->text('…', 'GeistMono-Regular', $size, (int) round($x + $column * $advance), $baseline, $run['color']); + + break; + } + } + } + } + + /** + * How many characters of code fit in the width at the size. + */ + public function columns(int $size, int $width): int + { + return (int) floor($width / $this->advance($size)); + } + /** * The user's name as Geist can draw it, or their handle when nothing is left. */ @@ -230,11 +342,11 @@ public function truncate(string $text, string $font, int $size, int $width, bool } /** - * Geist has no emoji, so they are dropped rather than rendered as empty boxes. + * Drops the emoji and collapses the whitespace. */ public function clean(string $text): string { - $text = (string) preg_replace('/[\p{Extended_Pictographic}\x{FE0F}\x{200D}\x{20E3}]/u', '', $text); + $text = (string) preg_replace(self::EMOJI, '', $text); return mb_trim((string) preg_replace('/\s+/u', ' ', $text)); } @@ -348,6 +460,14 @@ private function box(string $text, string $font, int $size): array ]; } + /** + * The width of one GeistMono character, measured over many so that rounding doesn't add up across a line. + */ + private function advance(int $size): float + { + return $this->width(str_repeat('0', 100), 'GeistMono-Regular', $size) / 100; + } + private function points(int $pixels): float { return $pixels * self::SCALE * 0.75; diff --git a/tests/Unit/Actions/CreateCommitOpenGraphImageTest.php b/tests/Unit/Actions/CreateCommitOpenGraphImageTest.php index 6cb18e9c7..4945d7275 100644 --- a/tests/Unit/Actions/CreateCommitOpenGraphImageTest.php +++ b/tests/Unit/Actions/CreateCommitOpenGraphImageTest.php @@ -6,6 +6,7 @@ use App\Enums\CommitStatus; use App\Models\Commit; use App\Models\User; +use App\Services\OpenGraphCanvas; use Illuminate\Support\Facades\Cache; it('fits commits that would overflow the card', function (string $body): void { @@ -20,6 +21,38 @@ 'an unbroken word wider than the card' => [str_repeat('supercalifragilistic', 14)], 'paragraphs' => ["First line.\n\nSecond line.\nThird line.\n\n\nFourth line.\nFifth line.\nSixth line.\nSeventh line."], 'nothing Geist can draw' => ['🚀🔥✨'], + 'a code block' => ["```php\necho 'Hello, world.';\n```"], + 'prose before a code block' => [str_repeat('Writing PHP on purpose and shipping it before lunch. ', 3)."\n```php\necho 1;\n```\nAfter."], + 'inline code before a code block' => ["Run `composer test` first:\n```\ncomposer test\n```"], + 'blank lines around the code' => ["```js\n\n\nconst a = 1;\n\n\n```"], + 'a code line wider than the card' => ["```\n".str_repeat('x', 500)."\n```"], + 'a code line wider than the card at every size but the smallest' => ["```\n".str_repeat('x', 70)."\n```"], + 'more code than fits' => ["```php\n".str_repeat("echo 1;\n", 200).'```'], + 'code with crlf' => ["```php\r\necho 1;\r\n```"], + 'an empty code block' => ["Nothing here:\n```\n```"], + 'two code blocks' => ["```php\necho 1;\n```\nand\n```js\nconsole.log(1);\n```"], +]); + +it('draws the first code block in a window', function (string $body, bool $isDrawn): void { + $commit = Commit::factory()->for(User::factory()->withBlankProfile())->create(['body' => $body]); + + $image = imagecreatefromstring(resolve(CreateCommitOpenGraphImage::class)->handle($commit->user, $commit)); + + assert($image instanceof GdImage); + + $colors = []; + + for ($y = 0; $y < OpenGraphCanvas::FOOTER_RULE_Y; $y++) { + $colors[] = imagecolorat($image, OpenGraphCanvas::CONTENT_X + 8, $y); + } + + expect(in_array(0x18181A, $colors, true))->toBe($isDrawn); +})->with([ + 'a code block' => ["```php\necho 1;\n```", true], + 'prose before a code block' => ["Look:\n```php\necho 1;\n```", true], + 'inline code only' => ['Run `composer test` first.', false], + 'an empty code block' => ["```\n\n```", false], + 'no code' => ['Hello, world.', false], ]); it('draws the parent of a reply only when it is approved', function (CommitStatus $status, bool $isDrawn): void { diff --git a/tests/Unit/Services/HighlightedCodeTest.php b/tests/Unit/Services/HighlightedCodeTest.php new file mode 100644 index 000000000..e6c2fd6a0 --- /dev/null +++ b/tests/Unit/Services/HighlightedCodeTest.php @@ -0,0 +1,76 @@ +lines('return string $name;'); + + expect($lines)->toBe([[ + ['text' => 'return', 'classes' => ['hl-keyword']], + ['text' => ' ', 'classes' => []], + ['text' => 'string', 'classes' => ['hl-injection', 'hl-type']], + ['text' => ' $name', 'classes' => ['hl-injection']], + ['text' => ';', 'classes' => []], + ]]); +}); + +it('keeps the classes of a span that runs across lines', function (): void { + $lines = resolve(HighlightedCode::class)->lines("a\n/*\n * b\n */\n\nc"); + + expect($lines)->toBe([ + [['text' => 'a', 'classes' => []]], + [['text' => '/*', 'classes' => ['hl-comment']]], + [['text' => ' * b', 'classes' => ['hl-comment']]], + [['text' => ' */', 'classes' => ['hl-comment']]], + [], + [['text' => 'c', 'classes' => []]], + ]); +}); + +it('decodes the escaped text', function (): void { + $lines = resolve(HighlightedCode::class)->lines('<?php "a" && 'b''); + + expect(array_column($lines[0], 'text'))->toBe(['lines('ab'); + + expect($lines)->toBe([[ + ['text' => 'a', 'classes' => []], + ['text' => 'b', 'classes' => ['hl-value', 'hl-literal']], + ]]); +}); + +it('expands tabs to the next tab stop', function (string $html, string $line): void { + $lines = resolve(HighlightedCode::class)->lines($html); + + expect(implode('', array_column($lines[0], 'text')))->toBe($line); +})->with([ + 'a leading tab' => ["\treturn;", ' return;'], + 'two leading tabs' => ["\t\treturn;", ' return;'], + 'a tab after text' => ["ab\tc", 'ab c'], + 'a tab after a span' => ["abc\td", 'abc d'], + 'no tabs' => ['a b', 'a b'], +]); + +it('reads nothing from an empty block', function (): void { + expect(resolve(HighlightedCode::class)->lines(''))->toBe([[]]); +}); + +it('reads back the code the highlighter was given', function (string $language, string $code): void { + [$block] = resolve(CodeSpans::class)->highlight("```{$language}\n{$code}\n```"); + + assert($block['type'] === 'block'); + + $lines = resolve(HighlightedCode::class)->lines($block['html']); + + expect(implode("\n", array_map(fn (array $runs): string => implode('', array_column($runs, 'text')), $lines)))->toBe($code); +})->with([ + 'php' => ['php', " ['js', 'const a = `b` + 1; // c'], + 'an unknown language' => ['brainfuck', '++[>+<-] &'], +]); diff --git a/tests/Unit/Services/OpenGraphCanvasTest.php b/tests/Unit/Services/OpenGraphCanvasTest.php new file mode 100644 index 000000000..f22b0a6a4 --- /dev/null +++ b/tests/Unit/Services/OpenGraphCanvasTest.php @@ -0,0 +1,92 @@ +highlight($body); + + assert($block['type'] === 'block'); + + $canvas = new OpenGraphCanvas; + $canvas->window(184, 60, 920, 300, $block['language']); + $canvas->code(resolve(HighlightedCode::class)->lines($block['html']), 22, 32, 208, 108, 872); + + $image = imagecreatefromstring($canvas->png()); + + assert($image instanceof GdImage); + + return $image; +} + +/** + * Every color in the rectangle, once each. + * + * @return list + */ +function colorsIn(GdImage $image, int $x1, int $y1, int $x2, int $y2): array +{ + $colors = []; + + for ($y = $y1; $y < $y2; $y++) { + for ($x = $x1; $x < $x2; $x++) { + $colors[(int) imagecolorat($image, $x, $y)] = true; + } + } + + return array_keys($colors); +} + +it('draws a dark window with three dots in the lanes', function (): void { + $image = drawCodeWindow("```php\necho 1;\n```"); + + expect(imagecolorat($image, 644, 352))->toBe(0x18181A) + ->and(imagecolorat($image, 214, 84))->toBe(0xD9480F) + ->and(imagecolorat($image, 238, 84))->toBe(0xB7791F) + ->and(imagecolorat($image, 262, 84))->toBe(0x2F9E44) + ->and(imagecolorat($image, 185, 61))->toBe(0xF6F7F8); +}); + +it('names the language in the title bar only when the block has one', function (string $body, bool $isNamed): void { + $image = drawCodeWindow($body); + + expect(colorsIn($image, 984, 68, 1096, 100) !== [0x18181A])->toBe($isNamed); +})->with([ + 'a language' => ["```php\necho 1;\n```", true], + 'no language' => ["```\necho 1;\n```", false], + 'a language GeistMono has no glyphs for' => ["```🚀\necho 1;\n```", false], +]); + +it('draws each token in its color', function (): void { + $image = drawCodeWindow("```php\nreturn \$user;\n```"); + + expect(colorsIn($image, 208, 108, 288, 140))->toContain(0x7AA2F7) + ->and(colorsIn($image, 298, 108, 388, 140))->toContain(0xF06595); +}); + +it('cuts a line wider than the window instead of drawing past it', function (): void { + $image = drawCodeWindow("```\n".str_repeat('x', 500)."\n```"); + + expect(colorsIn($image, 1040, 108, 1080, 140))->not->toBe([0x18181A]) + ->and(colorsIn($image, 1082, 108, 1102, 140))->toBe([0x18181A]); +}); + +it('keeps the indentation of each line', function (): void { + $image = drawCodeWindow("```\nx\n\tx\n```"); + + expect(colorsIn($image, 208, 140, 248, 172))->toBe([0x18181A]) + ->and(colorsIn($image, 248, 140, 288, 172))->not->toBe([0x18181A]); +}); + +it('draws nothing for emoji, which GeistMono has no glyphs for', function (): void { + $image = drawCodeWindow("```\n🚀🔥\n```"); + + expect(colorsIn($image, 208, 108, 1080, 140))->toBe([0x18181A]); +}); From fd76a837941bb619240cbc12cc5690c9d8064bfe Mon Sep 17 00:00:00 2001 From: Ismar Tricic Date: Tue, 27 Oct 2026 08:18:44 +0100 Subject: [PATCH 2/2] fix: draw character references in share images as typed GD draws numeric character references such as `A` as the character they name, so code like that showed differently in the share image than in the commit. Escape ampersands wherever text is drawn or measured. --- app/Services/OpenGraphCanvas.php | 13 +++++++++++-- tests/Unit/Services/OpenGraphCanvasTest.php | 7 +++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/Services/OpenGraphCanvas.php b/app/Services/OpenGraphCanvas.php index 6d1aed0e2..1b930ff5a 100644 --- a/app/Services/OpenGraphCanvas.php +++ b/app/Services/OpenGraphCanvas.php @@ -353,7 +353,7 @@ public function clean(string $text): string public function text(string $text, string $font, int $size, int $x, int $y, int $color): void { - imagettftext($this->image, $this->points($size), 0, $x * self::SCALE, $y * self::SCALE, $color, $this->font($font), $text); + imagettftext($this->image, $this->points($size), 0, $x * self::SCALE, $y * self::SCALE, $color, $this->font($font), $this->literal($text)); } public function width(string $text, string $font, int $size): int @@ -438,6 +438,15 @@ private function download(?string $url): ?GdImage return $avatar instanceof GdImage ? $avatar : null; } + /** + * The text with its ampersands escaped, since GD draws numeric character references such as `A` as the + * character they name rather than as typed. + */ + private function literal(string $text): string + { + return str_replace('&', '&', $text); + } + private function ellipsis(string $text): string { return mb_rtrim($text, ' ,;:.-').'…'; @@ -448,7 +457,7 @@ private function ellipsis(string $text): string */ private function box(string $text, string $font, int $size): array { - $box = imagettfbbox($this->points($size), 0, $this->font($font), $text); + $box = imagettfbbox($this->points($size), 0, $this->font($font), $this->literal($text)); throw_if($box === false, RuntimeException::class, "Unable to measure text with [{$font}]."); diff --git a/tests/Unit/Services/OpenGraphCanvasTest.php b/tests/Unit/Services/OpenGraphCanvasTest.php index f22b0a6a4..c6cc34df1 100644 --- a/tests/Unit/Services/OpenGraphCanvasTest.php +++ b/tests/Unit/Services/OpenGraphCanvasTest.php @@ -90,3 +90,10 @@ function colorsIn(GdImage $image, int $x1, int $y1, int $x2, int $y2): array expect(colorsIn($image, 208, 108, 1080, 140))->toBe([0x18181A]); }); + +it('draws character references as typed rather than as the character they name', function (): void { + $image = drawCodeWindow("```html\nA\n```"); + + expect(colorsIn($image, 240, 108, 280, 140))->not->toBe([0x18181A]) + ->and((new OpenGraphCanvas)->width('A', 'GeistMono-Regular', 22))->toBeGreaterThan((new OpenGraphCanvas)->width('', 'GeistMono-Regular', 22)); +});