mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-04-19 11:44:47 -04:00
Improve SkiaEncoder's font handling (#13231)
* Improve SkiaEncoder's font handling Our previous approach didn’t work with some complex library names, even when the required fonts were present, because the font handling logic was too simplistic. Modern Unicode and the fonts have become quite complex, making it challenging to implement it correctly. This improved implementation still isn’t the most correct way, but it’s better than it used to be. It now falls back to multiple fonts to find the best one and also handles extended grapheme clusters that were incorrectly processed before. * Fix space * Remove redundant comment * Make _typefaces an array * Make Measure and Draw text function name clear * Fix rename
This commit is contained in:
parent
9f70578997
commit
e9331fe9d7
2 changed files with 161 additions and 14 deletions
|
@ -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<SkiaEncoder> _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
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -97,6 +114,11 @@ public class SkiaEncoder : IImageEncoder
|
|||
public IReadOnlyCollection<ImageFormat> SupportedOutputFormats
|
||||
=> new HashSet<ImageFormat> { ImageFormat.Webp, ImageFormat.Jpg, ImageFormat.Png, ImageFormat.Svg };
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default typeface to use.
|
||||
/// </summary>
|
||||
public static SKTypeface DefaultTypeFace => _typefaces.Last();
|
||||
|
||||
/// <summary>
|
||||
/// Check if the native lib is available.
|
||||
/// </summary>
|
||||
|
@ -705,4 +727,22 @@ public class SkiaEncoder : IImageEncoder
|
|||
_logger.LogError(ex, "Error drawing indicator overlay");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the typeface that contains the glyph for the given character.
|
||||
/// </summary>
|
||||
/// <param name="c">The text character.</param>
|
||||
/// <returns>The typeface contains the character.</returns>
|
||||
public static SKTypeface? GetFontForCharacter(string c)
|
||||
{
|
||||
foreach (var typeface in _typefaces)
|
||||
{
|
||||
if (typeface.ContainsGlyphs(c))
|
||||
{
|
||||
return typeface;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw shaped text with given SKPaint.
|
||||
/// </summary>
|
||||
/// <param name="canvas">If not null, draw text to this canvas, otherwise only measure the text width.</param>
|
||||
/// <param name="x">x position of the canvas to draw text.</param>
|
||||
/// <param name="y">y position of the canvas to draw text.</param>
|
||||
/// <param name="text">The text to draw.</param>
|
||||
/// <param name="textPaint">The SKPaint to style the text.</param>
|
||||
/// <returns>The width of the text.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw shaped text with given SKPaint, search defined type faces to render as many texts as possible.
|
||||
/// </summary>
|
||||
/// <param name="canvas">If not null, draw text to this canvas, otherwise only measure the text width.</param>
|
||||
/// <param name="x">x position of the canvas to draw text.</param>
|
||||
/// <param name="y">y position of the canvas to draw text.</param>
|
||||
/// <param name="text">The text to draw.</param>
|
||||
/// <param name="textPaint">The SKPaint to style the text.</param>
|
||||
/// <param name="isRtl">If true, render from right to left.</param>
|
||||
/// <returns>The width of the text.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue