1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
|
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.RegularExpressions;
using SkiaSharp;
using SkiaSharp.HarfBuzz;
namespace Jellyfin.Drawing.Skia;
/// <summary>
/// Used to build collages of multiple images arranged in vertical strips.
/// </summary>
public partial class StripCollageBuilder
{
private readonly SkiaEncoder _skiaEncoder;
/// <summary>
/// Initializes a new instance of the <see cref="StripCollageBuilder"/> class.
/// </summary>
/// <param name="skiaEncoder">The encoder to use for building collages.</param>
public StripCollageBuilder(SkiaEncoder skiaEncoder)
{
_skiaEncoder = skiaEncoder;
}
[GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")]
private static partial Regex IsRtlTextRegex();
/// <summary>
/// Check which format an image has been encoded with using its filename extension.
/// </summary>
/// <param name="outputPath">The path to the image to get the format for.</param>
/// <returns>The image format.</returns>
public static SKEncodedImageFormat GetEncodedFormat(string outputPath)
{
ArgumentNullException.ThrowIfNull(outputPath);
var ext = Path.GetExtension(outputPath.AsSpan());
if (ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Jpeg;
}
if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Webp;
}
if (ext.Equals(".gif", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Gif;
}
if (ext.Equals(".bmp", StringComparison.OrdinalIgnoreCase))
{
return SKEncodedImageFormat.Bmp;
}
// default to png
return SKEncodedImageFormat.Png;
}
/// <summary>
/// Create a square collage.
/// </summary>
/// <param name="paths">The paths of the images to use in the collage.</param>
/// <param name="outputPath">The path at which to place the resulting collage image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
public void BuildSquareCollage(IReadOnlyList<string> paths, string outputPath, int width, int height)
{
using var bitmap = BuildSquareCollageBitmap(paths, width, height);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
}
/// <summary>
/// Create a thumb collage.
/// </summary>
/// <param name="paths">The paths of the images to use in the collage.</param>
/// <param name="outputPath">The path at which to place the resulting image.</param>
/// <param name="width">The desired width of the collage.</param>
/// <param name="height">The desired height of the collage.</param>
/// <param name="libraryName">The name of the library to draw on the collage.</param>
public void BuildThumbCollage(IReadOnlyList<string> paths, string outputPath, int width, int height, string? libraryName)
{
using var bitmap = BuildThumbCollageBitmap(paths, width, height, libraryName);
using var outputStream = new SKFileWStream(outputPath);
using var pixmap = new SKPixmap(new SKImageInfo(width, height), bitmap.GetPixels());
pixmap.Encode(outputStream, GetEncodedFormat(outputPath), 90);
}
private SKBitmap BuildThumbCollageBitmap(IReadOnlyList<string> paths, int width, int height, string? libraryName)
{
var bitmap = new SKBitmap(width, height);
using var canvas = new SKCanvas(bitmap);
canvas.Clear(SKColors.Black);
using var backdrop = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, 0, out _);
if (backdrop is null)
{
return bitmap;
}
// resize to the same aspect as the original
var backdropHeight = Math.Abs(width * backdrop.Height / backdrop.Width);
using var residedBackdrop = SkiaEncoder.ResizeImage(backdrop, new SKImageInfo(width, backdropHeight, backdrop.ColorType, backdrop.AlphaType, backdrop.ColorSpace));
// draw the backdrop
canvas.DrawImage(residedBackdrop, 0, 0);
// draw shadow rectangle
using var paintColor = new SKPaint
{
Color = SKColors.Black.WithAlpha(0x78),
Style = SKPaintStyle.Fill
};
canvas.DrawRect(0, 0, width, height, paintColor);
var typeFace = SkiaEncoder.DefaultTypeFace;
// draw library name
using var textPaint = new SKPaint
{
Color = SKColors.White,
Style = SKPaintStyle.Fill,
TextSize = 112,
TextAlign = SKTextAlign.Left,
Typeface = typeFace,
IsAntialias = true
};
// scale down text to 90% of the width if text is larger than 95% of the width
var textWidth = textPaint.MeasureText(libraryName);
if (textWidth > width * 0.95)
{
textPaint.TextSize = 0.9f * width * textPaint.TextSize / textWidth;
}
if (string.IsNullOrWhiteSpace(libraryName))
{
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))
{
textPaint.TextAlign = SKTextAlign.Right;
DrawText(canvas, width - padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint, true);
}
else
{
DrawText(canvas, padding, (height / 2f) + (textPaint.FontMetrics.XHeight / 2), libraryName, textPaint);
}
return bitmap;
}
private SKBitmap BuildSquareCollageBitmap(IReadOnlyList<string> paths, int width, int height)
{
var bitmap = new SKBitmap(width, height);
var imageIndex = 0;
var cellWidth = width / 2;
var cellHeight = height / 2;
using var canvas = new SKCanvas(bitmap);
for (var x = 0; x < 2; x++)
{
for (var y = 0; y < 2; y++)
{
using var currentBitmap = SkiaHelper.GetNextValidImage(_skiaEncoder, paths, imageIndex, out int newIndex);
imageIndex = newIndex;
if (currentBitmap is null)
{
continue;
}
// Scale image. The FromBitmap creates a copy
var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace);
using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo);
// draw this image into the strip at the next position
var xPos = x * cellWidth;
var yPos = y * cellHeight;
canvas.DrawImage(resizeImage, xPos, yPos);
}
}
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;
}
}
|