Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions src/Application/FileEmbed.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

/**
* 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 {

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
) {
}

Expand All @@ -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 );
Expand All @@ -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;
Expand All @@ -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 );
}

}
15 changes: 14 additions & 1 deletion src/NativeMarkdownExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,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,
Expand All @@ -69,6 +70,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 newRedirectSyntax(): RedirectSyntax {
return new RedirectSyntax(
magicWordSynonyms: MediaWikiServices::getInstance()
Expand Down
47 changes: 43 additions & 4 deletions src/Persistence/MediaWikiFileEmbedRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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
) {
}

Expand Down Expand Up @@ -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<string, string>
*/
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 );
Expand Down
40 changes: 40 additions & 0 deletions tests/phpunit/Application/MarkdownRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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];

Expand Down
91 changes: 80 additions & 11 deletions tests/phpunit/Persistence/MediaWikiFileEmbedRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) );
}

Expand All @@ -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( '<figcaption>Quarterly revenue</figcaption>', $html );
}

public function testThumbnailCaptionIsHtmlEscaped(): void {
$html = $this->renderThumbnail( $this->newFileRenderingThumbnails(), caption: '<script>alert(1)</script>' );

$this->assertStringNotContainsString( '<script>', $html );
$this->assertStringContainsString( '&lt;script&gt;', $html );
}

public function testThumbnailWithoutExplicitWidthUsesConfiguredDefault(): void {
$html = $this->renderThumbnail( $this->newFileRenderingThumbnails(), defaultThumbnailWidth: 250 );

$this->assertStringContainsString( '250px-Chart.png', $html );
}

public function testThumbnailUsesExplicitWidthOverConfiguredDefault(): void {
$html = $this->renderThumbnail(
$this->newFileRenderingThumbnails(),
width: 120,
defaultThumbnailWidth: 250
);

$this->assertStringContainsString( '120px-Chart.png', $html );
$this->assertStringNotContainsString( '250px-Chart.png', $html );
}

public function testThumbnailUsesExplicitAltTextForImageWhileShowingCaption(): void {
$html = $this->renderThumbnail(
$this->newFileRenderingThumbnails(),
altText: 'Bar chart of revenue',
caption: 'Quarterly revenue'
);

$this->assertStringContainsString( 'alt="Bar chart of revenue"', $html );
$this->assertStringContainsString( '<figcaption>Quarterly revenue</figcaption>', $html );
}

public function testPreloadedFileRendersWithoutPerEmbedLookup(): void {
$repoGroup = $this->createMock( RepoGroup::class );
$repoGroup->method( 'findFiles' )->willReturn( [ 'Chart.png' => $this->newFileRenderingThumbnails() ] );
Expand All @@ -85,10 +123,14 @@ public function testPreloadedMissingFileRendersUploadLinkWithoutPerEmbedLookup()
$this->assertStringContainsString( 'Special:Upload', $renderer->renderEmbed( $this->chartEmbed() ) );
}

private function newRendererWithRepoGroup( RepoGroup $repoGroup ): MediaWikiFileEmbedRenderer {
private function newRendererWithRepoGroup(
RepoGroup $repoGroup,
int $defaultThumbnailWidth = 300
): MediaWikiFileEmbedRenderer {
return new MediaWikiFileEmbedRenderer(
repoGroup: $repoGroup,
linkRenderer: $this->getServiceContainer()->getLinkRenderer()
linkRenderer: $this->getServiceContainer()->getLinkRenderer(),
defaultThumbnailWidth: $defaultThumbnailWidth
);
}

Expand All @@ -97,7 +139,34 @@ private function chartTitle(): WikiTitle {
}

private function chartEmbed(): FileEmbed {
return new FileEmbed( title: $this->chartTitle(), width: 300, altText: 'A chart', caption: null );
return new FileEmbed(
title: $this->chartTitle(),
width: 300,
altText: 'A chart',
caption: null,
thumbnail: false
);
}

private function renderThumbnail(
File $file,
?int $width = null,
?string $altText = null,
?string $caption = null,
int $defaultThumbnailWidth = 300
): string {
$repoGroup = $this->createStub( RepoGroup::class );
$repoGroup->method( 'findFile' )->willReturn( $file );

return $this->newRendererWithRepoGroup( $repoGroup, $defaultThumbnailWidth )->renderEmbed(
new FileEmbed(
title: $this->chartTitle(),
width: $width,
altText: $altText,
caption: $caption,
thumbnail: true
)
);
}

private function newFileWithFailingTransform(): File {
Expand Down
Loading