From badd1835f13ca114621e702812c990c1345131e8 Mon Sep 17 00:00:00 2001 From: Jeroen De Dauw Date: Mon, 6 Jul 2026 01:11:40 +0200 Subject: [PATCH] Support thumb-style framed image embeds with visible captions Fixes https://github.com/ProfessionalWiki/NativeMarkdown/issues/2 Adds an opt-in `thumb` keyword rendering a file embed as MediaWiki's framed thumbnail with the caption below the image, keeping inline rendering the default and leaving frame, frameless and alignment for later. --- src/Application/FileEmbed.php | 33 ++++++- src/NativeMarkdownExtension.php | 15 ++- .../MediaWikiFileEmbedRenderer.php | 47 +++++++++- .../Application/MarkdownRendererTest.php | 40 ++++++++ .../MediaWikiFileEmbedRendererTest.php | 91 ++++++++++++++++--- 5 files changed, 205 insertions(+), 21 deletions(-) diff --git a/src/Application/FileEmbed.php b/src/Application/FileEmbed.php index 230fa95..842e459 100644 --- a/src/Application/FileEmbed.php +++ b/src/Application/FileEmbed.php @@ -6,7 +6,8 @@ /** * A file embedded by the page, such as via `[[File:Cat.png|300px|alt=A cat|Caption]]`. - * Supports the minimal wikitext parameter subset: width, alt and caption. + * Supports the minimal wikitext parameter subset: width, alt, caption and the + * `thumb` keyword, which frames the image and shows the caption below it. */ final class FileEmbed { @@ -14,7 +15,8 @@ public function __construct( public readonly WikiTitle $title, public readonly ?int $width, public readonly ?string $altText, - public readonly ?string $caption + public readonly ?string $caption, + public readonly bool $thumbnail ) { } @@ -35,13 +37,15 @@ public function plainTextLabel(): string { /** * @param string|null $paramText Pipe-separated parameters, as wikitext does it: - * `NNNpx` sets the width, `alt=` sets the alt text, and the last remaining - * parameter becomes the caption. Height-only and zero sizes are ignored. + * `NNNpx` sets the width, `thumb`/`thumbnail` requests a framed thumbnail with a + * visible caption, `alt=` sets the alt text, and the last remaining parameter + * becomes the caption. Height-only and zero sizes are ignored. */ public static function fromTitleAndParams( WikiTitle $title, ?string $paramText ): self { $width = null; $altText = null; $caption = null; + $thumbnail = false; foreach ( explode( '|', $paramText ?? '' ) as $param ) { $param = trim( $param ); @@ -64,6 +68,11 @@ public static function fromTitleAndParams( WikiTitle $title, ?string $paramText continue; } + if ( self::isThumbnailKeyword( $param ) ) { + $thumbnail = true; + continue; + } + if ( str_starts_with( $param, 'alt=' ) ) { $altText = trim( substr( $param, 4 ) ); continue; @@ -72,7 +81,21 @@ public static function fromTitleAndParams( WikiTitle $title, ?string $paramText $caption = $param; } - return new self( title: $title, width: $width, altText: $altText, caption: $caption ); + return new self( + title: $title, + width: $width, + altText: $altText, + caption: $caption, + thumbnail: $thumbnail + ); + } + + /** + * Matches wikitext's `img_thumbnail` magic word, which is case-insensitive + * and accepts both spellings. + */ + private static function isThumbnailKeyword( string $param ): bool { + return in_array( strtolower( $param ), [ 'thumb', 'thumbnail' ], true ); } } diff --git a/src/NativeMarkdownExtension.php b/src/NativeMarkdownExtension.php index d2a9cbf..7ba624f 100644 --- a/src/NativeMarkdownExtension.php +++ b/src/NativeMarkdownExtension.php @@ -59,7 +59,8 @@ private function newMarkdownRenderer(): MarkdownRenderer { ), fileEmbedRenderer: new MediaWikiFileEmbedRenderer( $services->getRepoGroup(), - $services->getLinkRenderer() + $services->getLinkRenderer(), + $this->defaultThumbnailWidth( $services ) ), allowExternalImages: (bool)$services->getMainConfig()->get( 'NativeMarkdownAllowExternalImages' ), maxNestingLevel: self::MAX_NESTING_LEVEL, @@ -68,6 +69,18 @@ private function newMarkdownRenderer(): MarkdownRenderer { ); } + /** + * The width a bare `thumb` embed renders at, mirroring how the wikitext + * parser sizes a thumbnail: the default thumbnail preference indexed into + * the configured thumbnail sizes. + */ + private function defaultThumbnailWidth( MediaWikiServices $services ): int { + $thumbLimits = (array)$services->getMainConfig()->get( 'ThumbLimits' ); + $defaultThumbSize = (int)$services->getUserOptionsLookup()->getDefaultOption( 'thumbsize' ); + + return (int)( $thumbLimits[$defaultThumbSize] ?? 300 ); + } + public function newMarkdownDefaultPolicy(): MarkdownDefaultPolicy { $config = MediaWikiServices::getInstance()->getMainConfig(); diff --git a/src/Persistence/MediaWikiFileEmbedRenderer.php b/src/Persistence/MediaWikiFileEmbedRenderer.php index cbe154b..c4f5331 100644 --- a/src/Persistence/MediaWikiFileEmbedRenderer.php +++ b/src/Persistence/MediaWikiFileEmbedRenderer.php @@ -15,9 +15,10 @@ use RepoGroup; /** - * Renders embedded files the way wikitext renders inline (unframed) images: - * a linked image for existing files, an upload red link for missing ones, - * and a plain file page link for media that cannot display inline. + * Renders embedded files the way wikitext does: an inline (unframed) linked + * image by default, or a framed thumbnail with a visible caption when the embed + * requests `thumb`. Missing files become an upload red link, and media that + * cannot display inline becomes a plain file page link. */ final class MediaWikiFileEmbedRenderer implements FileEmbedRenderer { @@ -26,7 +27,8 @@ final class MediaWikiFileEmbedRenderer implements FileEmbedRenderer { public function __construct( private readonly RepoGroup $repoGroup, - private readonly LinkRenderer $linkRenderer + private readonly LinkRenderer $linkRenderer, + private readonly int $defaultThumbnailWidth ) { } @@ -56,9 +58,46 @@ public function renderEmbed( FileEmbed $embed ): string { return $this->linkRenderer->makeLink( $this->fileTitle( $embed ), $embed->caption ); } + if ( $embed->thumbnail ) { + return $this->thumbnailFrameHtml( $embed, $file ); + } + return $this->embeddedImageHtml( $embed, $file ); } + /** + * Delegates to core so the framed markup, magnify link and responsive + * variants match a wikitext `|thumb` exactly. A width is always passed: + * the requested one, or the wiki's default thumbnail size, since + * makeThumbLink2 would otherwise fall back to a smaller built-in default. + */ + private function thumbnailFrameHtml( FileEmbed $embed, File $file ): string { + return Linker::makeThumbLink2( + $this->fileTitle( $embed ), + $file, + $this->thumbnailFrameParams( $embed ), + [ 'width' => $embed->width ?? $this->defaultThumbnailWidth ] + ); + } + + /** + * The caption becomes the visible figcaption, which the thumbnail markup + * emits as raw HTML, so it is escaped here. An explicit alt sets the image + * alt; a thumbnail's caption is not reused as the alt, matching how wikitext + * treats a visible caption. + * + * @return array + */ + private function thumbnailFrameParams( FileEmbed $embed ): array { + $frameParams = [ 'caption' => htmlspecialchars( $embed->caption ?? '', ENT_QUOTES ) ]; + + if ( $embed->altText !== null ) { + $frameParams['alt'] = $embed->altText; + } + + return $frameParams; + } + private function embeddedImageHtml( FileEmbed $embed, File $file ): string { $handlerParams = [ 'width' => $embed->width ?? $file->getWidth() ]; $thumbnail = $file->transform( $handlerParams ); diff --git a/tests/phpunit/Application/MarkdownRendererTest.php b/tests/phpunit/Application/MarkdownRendererTest.php index 3cfbe9f..d5b4b6c 100644 --- a/tests/phpunit/Application/MarkdownRendererTest.php +++ b/tests/phpunit/Application/MarkdownRendererTest.php @@ -19,6 +19,7 @@ /** * @covers \ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer * @covers \ProfessionalWiki\NativeMarkdown\Application\RenderedMarkdown + * @covers \ProfessionalWiki\NativeMarkdown\Application\FileEmbed * @covers \ProfessionalWiki\NativeMarkdown\Application\HeadingAnchorBuilder * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\WikiLinkParser * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\WikiLinkNode @@ -361,6 +362,45 @@ public function testFileEmbedLastCaptionWins(): void { ); } + public function testFileEmbedIsInlineByDefault(): void { + $this->assertFalse( + $this->render( '[[File:Cat.png|300px|A caption]]' )->files[0]->thumbnail + ); + } + + public function testFileEmbedThumbKeywordRequestsThumbnail(): void { + $this->assertTrue( + $this->render( '[[File:Cat.png|thumb]]' )->files[0]->thumbnail + ); + } + + public function testFileEmbedThumbnailKeywordRequestsThumbnail(): void { + $this->assertTrue( + $this->render( '[[File:Cat.png|thumbnail]]' )->files[0]->thumbnail + ); + } + + public function testFileEmbedThumbKeywordIsCaseInsensitive(): void { + $this->assertTrue( + $this->render( '[[File:Cat.png|Thumb]]' )->files[0]->thumbnail + ); + } + + public function testFileEmbedThumbKeywordIsNotTreatedAsCaption(): void { + $this->assertNull( + $this->render( '[[File:Cat.png|thumb]]' )->files[0]->caption + ); + } + + public function testFileEmbedCombinesThumbWithOtherParams(): void { + $embed = $this->render( '[[File:Cat.png|300px|thumb|alt=A cat|The caption]]' )->files[0]; + + $this->assertTrue( $embed->thumbnail ); + $this->assertSame( 300, $embed->width ); + $this->assertSame( 'A cat', $embed->altText ); + $this->assertSame( 'The caption', $embed->caption ); + } + public function testFileEmbedHeightOnlySizeIsIgnoredNotCaption(): void { $embed = $this->render( '[[File:Cat.png|x300px]]' )->files[0]; diff --git a/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php b/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php index 8f0c6df..b090d49 100644 --- a/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php +++ b/tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php @@ -24,16 +24,12 @@ private function renderEmbed( File $file, ?int $width = 300 ): string { $repoGroup = $this->createStub( RepoGroup::class ); $repoGroup->method( 'findFile' )->willReturn( $file ); - $renderer = new MediaWikiFileEmbedRenderer( - repoGroup: $repoGroup, - linkRenderer: $this->getServiceContainer()->getLinkRenderer() - ); - - return $renderer->renderEmbed( new FileEmbed( - title: new WikiTitle( namespace: NS_FILE, dbKey: 'Chart.png', prefixedText: 'File:Chart.png' ), + return $this->newRendererWithRepoGroup( $repoGroup )->renderEmbed( new FileEmbed( + title: $this->chartTitle(), width: $width, altText: 'A chart', - caption: null + caption: null, + thumbnail: false ) ); } @@ -59,6 +55,48 @@ public function testEmbeddedImageGetsResponsiveSrcset(): void { $this->assertStringContainsString( '450px-Chart.png', $html ); } + public function testThumbnailRendersFramedFigureWithVisibleCaption(): void { + $html = $this->renderThumbnail( $this->newFileRenderingThumbnails(), caption: 'Quarterly revenue' ); + + $this->assertStringContainsString( 'typeof="mw:File/Thumb"', $html ); + $this->assertStringContainsString( '
Quarterly revenue
', $html ); + } + + public function testThumbnailCaptionIsHtmlEscaped(): void { + $html = $this->renderThumbnail( $this->newFileRenderingThumbnails(), caption: '' ); + + $this->assertStringNotContainsString( '