< Summary - Jellyfin

Information
Class: Jellyfin.Drawing.Skia.StripCollageBuilder
Assembly: Jellyfin.Drawing.Skia
File(s): /srv/git/jellyfin/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 115
Coverable lines: 115
Total lines: 311
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 50
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%210%
GetEncodedFormat(...)0%110100%
BuildSquareCollage(...)100%210%
BuildThumbCollage(...)100%210%
BuildThumbCollageBitmap(...)0%110100%
BuildSquareCollageBitmap(...)0%4260%
MeasureAndDrawText(...)0%620%
DrawText(...)0%506220%

File(s)

/srv/git/jellyfin/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs

#LineLine coverage
 1using System;
 2using System.Collections.Generic;
 3using System.Globalization;
 4using System.IO;
 5using System.Text.RegularExpressions;
 6using SkiaSharp;
 7using SkiaSharp.HarfBuzz;
 8
 9namespace Jellyfin.Drawing.Skia;
 10
 11/// <summary>
 12/// Used to build collages of multiple images arranged in vertical strips.
 13/// </summary>
 14public partial class StripCollageBuilder
 15{
 16    private readonly SkiaEncoder _skiaEncoder;
 17
 18    /// <summary>
 19    /// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
 20    /// </summary>
 21    /// <param name="skiaEncoder">The encoder to use for building collages.</param>
 22    public StripCollageBuilder(SkiaEncoder skiaEncoder)
 23    {
 024        _skiaEncoder = skiaEncoder;
 025    }
 26
 27    [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")]
 28    private static partial Regex IsRtlTextRegex();
 29
 30    /// <summary>
 31    /// Check which format an image has been encoded with using its filename extension.
 32    /// </summary>
 33    /// <param name="outputPath">The path to the image to get the format for.</param>
 34    /// <returns>The image format.</returns>
 35    public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
 36    {
 037        ArgumentNullException.ThrowIfNull(outputPath);
 38
 039        var ext = Path.GetExtension(outputPath.AsSpan());
 40
 041        if (ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
 042            || ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
 43        {
 044            return SKEncodedImageFormat.Jpeg;
 45        }
 46
 047        if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase))
 48        {
 049            return SKEncodedImageFormat.Webp;
 50        }
 51
 052        if (ext.Equals(".gif", StringComparison.OrdinalIgnoreCase))
 53        {
 054            return SKEncodedImageFormat.Gif;
 55        }
 56
 057        if (ext.Equals(".bmp", StringComparison.OrdinalIgnoreCase))
 58        {
 059            return SKEncodedImageFormat.Bmp;
 60        }
 61
 62        // default to png
 063        return SKEncodedImageFormat.Png;
 64    }
 65
 66    /// <summary>
 67    /// Create a square collage.
 68    /// </summary>
 69    /// <param name="paths">The paths of the images to use in the collage.</param>
 70    /// <param name="outputPath">The path at which to place the resulting collage image.</param>
 71    /// <param name="width">The desired width of the collage.</param>
 72    /// <param name="height">The desired height of the collage.</param>
 73    public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
 74    {
 075        using var bitmap = BuildSquareCollageBitmap(paths, width, height);
 076        using var outputStream = new SKFileWStream(outputPath);
 077        using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
 078        pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
 079    }
 80
 81    /// <summary>
 82    /// Create a thumb collage.
 83    /// </summary>
 84    /// <param name="paths">The paths of the images to use in the collage.</param>
 85    /// <param name="outputPath">The path at which to place the resulting image.</param>
 86    /// <param name="width">The desired width of the collage.</param>
 87    /// <param name="height">The desired height of the collage.</param>
 88    /// <param name="libraryName">The name of the library to draw on the collage.</param>
 89    public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? library
 90    {
 091        using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
 092        using var outputStream = new SKFileWStream(outputPath);
 093        using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
 094        pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
 095    }
 96
 97    private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
 98    {
 099        var bitmap = new SKBitmap(width, height);
 100
 0101        using var canvas = new SKCanvas(bitmap);
 0102        canvas.Clear(SKColors.Black);
 103
 0104        using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
 0105        if (backdrop is null)
 106        {
 0107            return bitmap;
 108        }
 109
 110        // resize to the same aspect as the original
 0111        var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
 0112        using var resizedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.Co
 0113        using var paint = new SKPaint();
 114        // draw the backdrop
 0115        canvas.DrawImage(resizedBackdrop, 0, 0, SkiaEncoder.DefaultSamplingOptions, paint);
 116
 117        // draw shadow rectangle
 0118        using var paintColor = new SKPaint();
 0119        paintColor.Color = SKColors.Black.WithAlpha(0x78);
 0120        paintColor.Style = SKPaintStyle.Fill;
 0121        canvas.DrawRect(0, 0, width, height, paintColor);
 122
 0123        var typeFace = SkiaEncoder.DefaultTypeFace;
 124
 125        // draw library name
 0126        using var textFont = new SKFont();
 0127        textFont.Size = 112;
 0128        textFont.Typeface = typeFace;
 0129        using var textPaint = new SKPaint();
 0130        textPaint.Color = SKColors.White;
 0131        textPaint.Style = SKPaintStyle.Fill;
 0132        textPaint.IsAntialias = true;
 133
 134        // scale down text to 90% of the width if text is larger than 95% of the width
 0135        var textWidth = textFont.MeasureText(libraryName);
 0136        if (textWidth > width * 0.95)
 137        {
 0138            textFont.Size = 0.9f * width * textFont.Size / textWidth;
 139        }
 140
 0141        if (string.IsNullOrWhiteSpace(libraryName))
 142        {
 0143            return bitmap;
 144        }
 145
 0146        var realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFo
 0147        if (realWidth > width * 0.95)
 148        {
 0149            textFont.Size = 0.9f * width * textFont.Size / realWidth;
 0150            realWidth = DrawText(null, 0, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFo
 151        }
 152
 0153        var padding = (width - realWidth) / 2;
 154
 0155        if (IsRtlTextRegex().IsMatch(libraryName))
 156        {
 0157            DrawText(canvas, width - padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, te
 158        }
 159        else
 160        {
 0161            DrawText(canvas, padding, (height / 2f) + (textFont.Metrics.XHeight / 2), libraryName, textPaint, textFont);
 162        }
 163
 0164        return bitmap;
 0165    }
 166
 167    private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
 168    {
 0169        var bitmap = new SKBitmap(width, height);
 0170        var imageIndex = 0;
 0171        var cellWidth = width / 2;
 0172        var cellHeight = height / 2;
 173
 0174        using var canvas = new SKCanvas(bitmap);
 0175        for (var x = 0; x < 2; x++)
 176        {
 0177            for (var y = 0; y < 2; y++)
 178            {
 0179                using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex
 0180                imageIndex = newIndex;
 181
 0182                if (currentBitmap is null)
 183                {
 0184                    continue;
 185                }
 186
 187                // Scale image
 0188                var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType,
 0189                using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo);
 0190                using var paint = new SKPaint();
 191
 192                // draw this image into the strip at the next position
 0193                var xPos = x * cellWidth;
 0194                var yPos = y * cellHeight;
 0195                canvas.DrawImage(resizeImage, xPos, yPos, SkiaEncoder.DefaultSamplingOptions, paint);
 196            }
 197        }
 198
 0199        return bitmap;
 0200    }
 201
 202    /// <summary>
 203    /// Draw shaped text with given SKPaint.
 204    /// </summary>
 205    /// <param name="canvas">If not null, draw text to this canvas, otherwise only measure the text width.</param>
 206    /// <param name="x">x position of the canvas to draw text.</param>
 207    /// <param name="y">y position of the canvas to draw text.</param>
 208    /// <param name="text">The text to draw.</param>
 209    /// <param name="textPaint">The SKPaint to style the text.</param>
 210    /// <param name="textFont">The SKFont to style the text.</param>
 211    /// <param name="alignment">The alignment of the text. Default aligns to left.</param>
 212    /// <returns>The width of the text.</returns>
 213    private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont t
 214    {
 0215        var width = textFont.MeasureText(text);
 0216        canvas?.DrawShapedText(text, x, y, alignment, textFont, textPaint);
 0217        return width;
 218    }
 219
 220    /// <summary>
 221    /// Draw shaped text with given SKPaint, search defined type faces to render as many texts as possible.
 222    /// </summary>
 223    /// <param name="canvas">If not null, draw text to this canvas, otherwise only measure the text width.</param>
 224    /// <param name="x">x position of the canvas to draw text.</param>
 225    /// <param name="y">y position of the canvas to draw text.</param>
 226    /// <param name="text">The text to draw.</param>
 227    /// <param name="textPaint">The SKPaint to style the text.</param>
 228    /// <param name="textFont">The SKFont to style the text.</param>
 229    /// <param name="isRtl">If true, render from right to left.</param>
 230    /// <returns>The width of the text.</returns>
 231    private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, SKFont textFont, b
 232    {
 0233        float width = 0;
 0234        var alignment = isRtl ? SKTextAlign.Right : SKTextAlign.Left;
 235
 0236        if (textFont.ContainsGlyphs(text))
 237        {
 238            // Current font can render all characters in text
 0239            return MeasureAndDrawText(canvas, x, y, text, textPaint, textFont, alignment);
 240        }
 241
 242        // Iterate over all text elements using TextElementEnumerator
 243        // We cannot use foreach here because a human-readable character (grapheme cluster) can be multiple code points
 244        // We cannot render character by character because glyphs do not always have same width
 245        // And the result will look very unnatural due to the width difference and missing natural spacing
 0246        var start = 0;
 0247        var enumerator = StringInfo.GetTextElementEnumerator(text);
 0248        while (enumerator.MoveNext())
 249        {
 250            bool notAtEnd;
 0251            var textElement = enumerator.GetTextElement();
 0252            if (textFont.ContainsGlyphs(textElement))
 253            {
 254                continue;
 255            }
 256
 257            // If we get here, we have a text element which cannot be rendered with current font
 258            // Draw previous characters which can be rendered with current font
 0259            if (start != enumerator.ElementIndex)
 260            {
 0261                var regularText = text.Substring(start, enumerator.ElementIndex - start);
 0262                width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint, textFont, alignment);
 0263                start = enumerator.ElementIndex;
 264            }
 265
 266            // Search for next point where current font can render the character there
 0267            while ((notAtEnd = enumerator.MoveNext()) && !textFont.ContainsGlyphs(enumerator.GetTextElement()))
 268            {
 269                // Do nothing, just move enumerator to the point where current font can render the character
 270            }
 271
 272            // Now we have a substring that should pick another font
 273            // The enumerator may or may not be already at the end of the string
 0274            var subtext = notAtEnd
 0275                ? text.Substring(start, enumerator.ElementIndex - start)
 0276                : text[start..];
 277
 0278            var fallback = SkiaEncoder.GetFontForCharacter(textElement);
 279
 0280            if (fallback is not null)
 281            {
 0282                using var fallbackTextFont = new SKFont();
 0283                fallbackTextFont.Size = textFont.Size;
 0284                fallbackTextFont.Typeface = fallback;
 0285                using var fallbackTextPaint = new SKPaint();
 0286                fallbackTextPaint.Color = textPaint.Color;
 0287                fallbackTextPaint.Style = textPaint.Style;
 0288                fallbackTextPaint.IsAntialias = textPaint.IsAntialias;
 289
 290                // Do the search recursively to select all possible fonts
 0291                width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, fallbackTextFont, isRtl);
 292            }
 293            else
 294            {
 295                // Used up all fonts and no fonts can be found, just use current font
 0296                width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment);
 297            }
 298
 0299            start = notAtEnd ? enumerator.ElementIndex : text.Length;
 300        }
 301
 302        // Render the remaining text that current fonts can render
 0303        if (start < text.Length)
 304        {
 0305            width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint, textFont, alignment);
 306        }
 307
 0308        return width;
 309        float MoveX(float currentX, float dWidth) => isRtl ? currentX - dWidth : currentX + dWidth;
 310    }
 311}