diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index 2dac5598f0..99f7fa7f96 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using BlurHashSharp.SkiaSharp; using Jellyfin.Extensions; using MediaBrowser.Common.Configuration; @@ -24,6 +25,7 @@ public class SkiaEncoder : IImageEncoder private readonly ILogger _logger; private readonly IApplicationPaths _appPaths; private static readonly SKImageFilter _imageFilter; + private static readonly SKTypeface[] _typefaces; #pragma warning disable CA1810 static SkiaEncoder() @@ -46,6 +48,21 @@ public class SkiaEncoder : IImageEncoder kernelOffset, SKShaderTileMode.Clamp, true); + + // Initialize the list of typefaces + // We have to statically build a list of typefaces because MatchCharacter only accepts a single character or code point + // But in reality a human-readable character (grapheme cluster) could be multiple code points. For example, 🚵🏻‍♀️ is a single emoji but 5 code points (U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F) + _typefaces = + [ + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '鸡'), // CJK Simplified Chinese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '雞'), // CJK Traditional Chinese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ノ'), // CJK Japanese + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, '각'), // CJK Korean + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 128169), // Emojis, 128169 is the 💩emoji + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ז'), // Hebrew + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, 'ي'), // Arabic + SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) // Default font + ]; } /// @@ -97,6 +114,11 @@ public class SkiaEncoder : IImageEncoder public IReadOnlyCollection SupportedOutputFormats => new HashSet { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg }; + /// + /// Gets the default typeface to use. + /// + public static SKTypeface DefaultTypeFace => _typefaces.Last(); + /// /// Check if the native lib is available. /// @@ -705,4 +727,22 @@ public class SkiaEncoder : IImageEncoder _logger.LogError(ex, "Error drawing indicator overlay"); } } + + /// + /// Return the typeface that contains the glyph for the given character. + /// + /// The text character. + /// The typeface contains the character. + public static SKTypeface? GetFontForCharacter(string c) + { + foreach (var typeface in _typefaces) + { + if (typeface.ContainsGlyphs(c)) + { + return typeface; + } + } + + return null; + } } diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index 4aff26c16b..b0c9c0b3cc 100644 --- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Text.RegularExpressions; using SkiaSharp; @@ -23,9 +24,6 @@ public partial class StripCollageBuilder _skiaEncoder = skiaEncoder; } - [GeneratedRegex(@"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]")] - private static partial Regex NonCjkPatternRegex(); - [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")] private static partial Regex IsRtlTextRegex(); @@ -123,14 +121,7 @@ public partial class StripCollageBuilder }; canvas.DrawRect(0, 0, width, height, paintColor); - var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); - - // use the system fallback to find a typeface for the given CJK character - var filteredName = NonCjkPatternRegex().Replace(libraryName ?? string.Empty, string.Empty); - if (!string.IsNullOrEmpty(filteredName)) - { - typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); - } + var typeFace = SkiaEncoder.DefaultTypeFace; // draw library name using var textPaint = new SKPaint @@ -138,7 +129,7 @@ public partial class StripCollageBuilder Color = SKColors.White, Style = SKPaintStyle.Fill, TextSize = 112, - TextAlign = SKTextAlign.Center, + TextAlign = SKTextAlign.Left, Typeface = typeFace, IsAntialias = true }; @@ -155,13 +146,23 @@ public partial class StripCollageBuilder return bitmap; } + var realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + if (realWidth > width * 0.95) + { + textPaint.TextSize = 0.9f * width * textPaint.TextSize / realWidth; + realWidth = DrawText(null, 0, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); + } + + var padding = (width - realWidth) / 2; + if (IsRtlTextRegex().IsMatch(libraryName)) { - canvas.DrawShapedText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + textPaint.TextAlign = SKTextAlign.Right; + DrawText(canvas, width - padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint, true); } else { - canvas.DrawText(libraryName, width / 2f, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), textPaint); + DrawText(canvas, padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint); } return bitmap; @@ -200,4 +201,110 @@ public partial class StripCollageBuilder return bitmap; } + + /// + /// Draw shaped text with given SKPaint. + /// + /// If not null, draw text to this canvas, otherwise only measure the text width. + /// x position of the canvas to draw text. + /// y position of the canvas to draw text. + /// The text to draw. + /// The SKPaint to style the text. + /// The width of the text. + private static float MeasureAndDrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint) + { + var width = textPaint.MeasureText(text); + canvas?.DrawShapedText(text, x, y, textPaint); + return width; + } + + /// + /// Draw shaped text with given SKPaint, search defined type faces to render as many texts as possible. + /// + /// If not null, draw text to this canvas, otherwise only measure the text width. + /// x position of the canvas to draw text. + /// y position of the canvas to draw text. + /// The text to draw. + /// The SKPaint to style the text. + /// If true, render from right to left. + /// The width of the text. + private static float DrawText(SKCanvas? canvas, float x, float y, string text, SKPaint textPaint, bool isRtl = false) + { + float width = 0; + + if (textPaint.ContainsGlyphs(text)) + { + // Current font can render all characters in text + return MeasureAndDrawText(canvas, x, y, text, textPaint); + } + + // Iterate over all text elements using TextElementEnumerator + // We cannot use foreach here because a human-readable character (grapheme cluster) can be multiple code points + // We cannot render character by character because glyphs do not always have same width + // And the result will look very unnatural due to the width difference and missing natural spacing + var start = 0; + var enumerator = StringInfo.GetTextElementEnumerator(text); + while (enumerator.MoveNext()) + { + bool notAtEnd; + var textElement = enumerator.GetTextElement(); + if (textPaint.ContainsGlyphs(textElement)) + { + continue; + } + + // If we get here, we have a text element which cannot be rendered with current font + // Draw previous characters which can be rendered with current font + if (start != enumerator.ElementIndex) + { + var regularText = text.Substring(start, enumerator.ElementIndex - start); + width += MeasureAndDrawText(canvas, MoveX(x, width), y, regularText, textPaint); + start = enumerator.ElementIndex; + } + + // Search for next point where current font can render the character there + while ((notAtEnd = enumerator.MoveNext()) && !textPaint.ContainsGlyphs(enumerator.GetTextElement())) + { + // Do nothing, just move enumerator to the point where current font can render the character + } + + // Now we have a substring that should pick another font + // The enumerator may or may not be already at the end of the string + var subtext = notAtEnd + ? text.Substring(start, enumerator.ElementIndex - start) + : text[start..]; + + var fallback = SkiaEncoder.GetFontForCharacter(textElement); + + if (fallback is not null) + { + using var fallbackTextPaint = new SKPaint(); + fallbackTextPaint.Color = textPaint.Color; + fallbackTextPaint.Style = textPaint.Style; + fallbackTextPaint.TextSize = textPaint.TextSize; + fallbackTextPaint.TextAlign = textPaint.TextAlign; + fallbackTextPaint.Typeface = fallback; + fallbackTextPaint.IsAntialias = textPaint.IsAntialias; + + // Do the search recursively to select all possible fonts + width += DrawText(canvas, MoveX(x, width), y, subtext, fallbackTextPaint, isRtl); + } + else + { + // Used up all fonts and no fonts can be found, just use current font + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + } + + start = notAtEnd ? enumerator.ElementIndex : text.Length; + } + + // Render the remaining text that current fonts can render + if (start < text.Length) + { + width += MeasureAndDrawText(canvas, MoveX(x, width), y, text[start..], textPaint); + } + + return width; + float MoveX(float currentX, float dWidth) => isRtl ? currentX - dWidth : currentX + dWidth; + } }