Skip to content

Commit bd9eeed

Browse files
committed
Add text shaping support to SkiaRenderContext (#1520)
1 parent 0adca8f commit bd9eeed

8 files changed

Lines changed: 169 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file.
1414
- EdgeRenderingMode property to PlotElement, allowing customization of the way edges are treated by the renderer (#1428, #1358, #1077, #843, #145)
1515
- Color palettes Viridis, Plasma, Magma, Inferno and Cividis (#1505)
1616
- Renderer based on SkiaSharp, including exporters for PNG, JPEG, PDF and SVG (#1509)
17+
- Text shaping support to SkiaRenderContext (#1520)
1718

1819
### Changed
1920
- Legends model (#644)

Source/Examples/ExampleLibrary/Examples/RenderingCapabilities.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ public static PlotModel MeasureText()
356356
/// <returns>
357357
/// A plot model.
358358
/// </returns>
359-
private static PlotModel DrawTextWithMetrics(string text, string font, double fontSize, double expectedWidth, double expectedHeight, double baseline, double xheight, double ascent, double descent, double before, double after, string platform)
359+
public static PlotModel DrawTextWithMetrics(string text, string font, double fontSize, double expectedWidth, double expectedHeight, double baseline, double xheight, double ascent, double descent, double before, double after, string platform)
360360
{
361361
// http://msdn.microsoft.com/en-us/library/ms742190(v=vs.110).aspx
362362
// http://msdn.microsoft.com/en-us/library/xwf9s90b(v=vs.110).aspx

Source/OxyPlot.SkiaSharp.Tests/PngExporterTests.cs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace OxyPlot.SkiaSharp.Tests
88
{
99
using System;
1010
using System.IO;
11-
11+
using ExampleLibrary;
1212
using NUnit.Framework;
1313

1414
using OxyPlot.Series;
@@ -93,6 +93,41 @@ public void ExportWithResolution(double factor)
9393
Assert.IsTrue(File.Exists(fileName));
9494
}
9595

96+
[Test]
97+
[TestCase(true)]
98+
[TestCase(false)]
99+
public void ExportUseTextShapingAlignment(bool useTextShaping)
100+
{
101+
var model = RenderingCapabilities.DrawTextAlignment();
102+
model.Background = OxyColors.White;
103+
var fileName = Path.Combine(this.outputDirectory, $"Alignment, UseTextShaping={useTextShaping}.png");
104+
var exporter = new PngExporter { Width = 450, Height = 200, UseTextShaping = useTextShaping };
105+
using (var stream = File.OpenWrite(fileName))
106+
{
107+
exporter.Export(model, stream);
108+
}
109+
110+
Assert.IsTrue(File.Exists(fileName));
111+
}
112+
113+
[Test]
114+
[TestCase(true)]
115+
[TestCase(false)]
116+
public void ExportUseTextShapingMeasurements(bool useTextShaping)
117+
{
118+
var model = RenderingCapabilities.DrawTextWithMetrics("TeffVAll", "Arial", 60, double.NaN, double.NaN, 105, double.NaN, double.NaN, double.NaN, double.NaN, double.NaN, "");
119+
120+
model.Background = OxyColors.White;
121+
var fileName = Path.Combine(this.outputDirectory, $"Measurements, UseTextShaping={useTextShaping}.png");
122+
var exporter = new PngExporter { Width = 450, Height = 150, UseTextShaping = useTextShaping };
123+
using (var stream = File.OpenWrite(fileName))
124+
{
125+
exporter.Export(model, stream);
126+
}
127+
128+
Assert.IsTrue(File.Exists(fileName));
129+
}
130+
96131
private static PlotModel CreateTestModel1()
97132
{
98133
var model = new PlotModel { Title = "Test 1" };

Source/OxyPlot.SkiaSharp/OxyPlot.SkiaSharp.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
</PropertyGroup>
1616
<ItemGroup>
1717
<PackageReference Include="SkiaSharp" Version="1.68.1.1" />
18+
<PackageReference Include="SkiaSharp.HarfBuzz" Version="1.68.1.1" />
1819
</ItemGroup>
1920
<ItemGroup>
2021
<ProjectReference Include="..\OxyPlot\OxyPlot.csproj" />

Source/OxyPlot.SkiaSharp/PdfExporter.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public class PdfExporter : IExporter
2929
/// </summary>
3030
public float Width { get; set; }
3131

32+
/// <summary>
33+
/// Gets or sets a value indicating whether text shaping should be used when rendering text.
34+
/// </summary>
35+
public bool UseTextShaping { get; set; } = true;
36+
3237
/// <summary>
3338
/// Exports the specified model to a file.
3439
/// </summary>
@@ -60,7 +65,7 @@ public void Export(IPlotModel model, Stream stream)
6065
{
6166
using var document = SKDocument.CreatePdf(stream, this.Dpi);
6267
using var pdfCanvas = document.BeginPage(this.Width, this.Height);
63-
using var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = pdfCanvas };
68+
using var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = pdfCanvas, UseTextShaping = this.UseTextShaping };
6469
var dpiScale = this.Dpi / 96;
6570
context.DpiScale = dpiScale;
6671
model.Update(true);

Source/OxyPlot.SkiaSharp/PngExporter.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ public class PngExporter : IExporter
2929
/// </summary>
3030
public int Width { get; set; }
3131

32+
/// <summary>
33+
/// Gets or sets a value indicating whether text shaping should be used when rendering text.
34+
/// </summary>
35+
public bool UseTextShaping { get; set; } = true;
36+
3237
/// <summary>
3338
/// Exports the specified model to a file.
3439
/// </summary>
@@ -63,7 +68,7 @@ public void Export(IPlotModel model, Stream stream)
6368
using var bitmap = new SKBitmap(this.Width, this.Height);
6469

6570
using (var canvas = new SKCanvas(bitmap))
66-
using (var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = canvas })
71+
using (var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = canvas, UseTextShaping = this.UseTextShaping })
6772
{
6873
var dpiScale = this.Dpi / 96;
6974
context.DpiScale = dpiScale;

Source/OxyPlot.SkiaSharp/SkiaRenderContext.cs

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace OxyPlot.SkiaSharp
88
{
99
using global::SkiaSharp;
10+
using global::SkiaSharp.HarfBuzz;
1011
using System;
1112
using System.Collections.Generic;
1213
using System.Linq;
@@ -16,6 +17,7 @@ namespace OxyPlot.SkiaSharp
1617
/// </summary>
1718
public class SkiaRenderContext : IRenderContext, IDisposable
1819
{
20+
private readonly Dictionary<FontDescriptor, SKShaper> shaperCache = new Dictionary<FontDescriptor, SKShaper>();
1921
private readonly Dictionary<FontDescriptor, SKTypeface> typefaceCache = new Dictionary<FontDescriptor, SKTypeface>();
2022
private SKPaint paint = new SKPaint();
2123
private SKPath path = new SKPath();
@@ -33,6 +35,11 @@ public class SkiaRenderContext : IRenderContext, IDisposable
3335
/// </summary>
3436
public SKCanvas SkCanvas { get; set; }
3537

38+
/// <summary>
39+
/// Gets or sets a value indicating whether text shaping should be used when rendering text.
40+
/// </summary>
41+
public bool UseTextShaping { get; set; } = true;
42+
3643
/// <inheritdoc/>
3744
public void CleanUp()
3845
{
@@ -341,18 +348,11 @@ public void DrawText(
341348
return;
342349
}
343350

344-
var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight);
351+
var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight, out var shaper);
345352
paint.Color = fill.ToSKColor();
346353

347-
var width = paint.MeasureText(text);
348-
349-
var deltaX = horizontalAlignment switch
350-
{
351-
HorizontalAlignment.Left => 0,
352-
HorizontalAlignment.Center => -width / 2,
353-
HorizontalAlignment.Right => -width,
354-
_ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment))
355-
};
354+
var x = this.Convert(p.X);
355+
var y = this.Convert(p.Y);
356356

357357
var metrics = paint.FontMetrics;
358358
var deltaY = verticalAlignment switch
@@ -363,13 +363,35 @@ public void DrawText(
363363
_ => throw new ArgumentOutOfRangeException(nameof(verticalAlignment))
364364
};
365365

366-
var x = this.Convert(p.X);
367-
var y = this.Convert(p.Y);
368-
using (new SKAutoCanvasRestore(this.SkCanvas))
366+
using var _ = new SKAutoCanvasRestore(this.SkCanvas);
367+
this.SkCanvas.Translate(x, y);
368+
this.SkCanvas.RotateDegrees((float)rotation);
369+
370+
if (this.UseTextShaping)
371+
{
372+
var width = this.MeasureText(text, shaper, paint);
373+
var deltaX = horizontalAlignment switch
374+
{
375+
HorizontalAlignment.Left => 0,
376+
HorizontalAlignment.Center => -width / 2,
377+
HorizontalAlignment.Right => -width,
378+
_ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment))
379+
};
380+
381+
this.paint.TextAlign = SKTextAlign.Left;
382+
this.SkCanvas.DrawShapedText(shaper, text, deltaX, deltaY, paint);
383+
}
384+
else
369385
{
370-
this.SkCanvas.Translate(x, y);
371-
this.SkCanvas.RotateDegrees((float)rotation);
372-
this.SkCanvas.DrawText(text, new SKPoint(deltaX, deltaY), paint);
386+
paint.TextAlign = horizontalAlignment switch
387+
{
388+
HorizontalAlignment.Left => SKTextAlign.Left,
389+
HorizontalAlignment.Center => SKTextAlign.Center,
390+
HorizontalAlignment.Right => SKTextAlign.Right,
391+
_ => throw new ArgumentOutOfRangeException(nameof(horizontalAlignment))
392+
};
393+
394+
this.SkCanvas.DrawText(text, 0, deltaY, paint);
373395
}
374396
}
375397

@@ -381,8 +403,8 @@ public OxySize MeasureText(string text, string fontFamily = null, double fontSiz
381403
return new OxySize(0, 0);
382404
}
383405

384-
var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight);
385-
var width = paint.MeasureText(text);
406+
var paint = this.GetTextPaint(fontFamily, fontSize, fontWeight, out var shaper);
407+
var width = this.MeasureText(text, shaper, paint);
386408
var height = paint.GetFontMetrics(out _);
387409
return new OxySize(this.ConvertBack(width), this.ConvertBack(height));
388410
}
@@ -434,6 +456,13 @@ protected virtual void Dispose(bool disposing)
434456
}
435457

436458
this.typefaceCache.Clear();
459+
460+
foreach (var shaper in this.shaperCache.Values)
461+
{
462+
shaper.Dispose();
463+
}
464+
465+
this.shaperCache.Clear();
437466
}
438467

439468
/// <summary>
@@ -506,23 +535,6 @@ private float Convert(double value)
506535
return (float)value * this.DpiScale;
507536
}
508537

509-
/// <summary>
510-
/// Converts <see cref="double"/> dash array to a <see cref="float"/> array, taking into account DPI scaling.
511-
/// </summary>
512-
/// <param name="values">The array of values.</param>
513-
/// <param name="strokeThickness">The stroke thickness.</param>
514-
/// <returns>The array of converted values.</returns>
515-
private float[] ConvertDashArray(double[] values, float strokeThickness)
516-
{
517-
var ret = new float[values.Length];
518-
for (var i = 0; i < values.Length; i++)
519-
{
520-
ret[i] = this.Convert(values[i]) * strokeThickness;
521-
}
522-
523-
return ret;
524-
}
525-
526538
/// <summary>
527539
/// Converts <see cref="ScreenPoint"/> to a <see cref="SKPoint"/>, taking into account DPI scaling.
528540
/// </summary>
@@ -543,6 +555,23 @@ private double ConvertBack(float value)
543555
return value / this.DpiScale;
544556
}
545557

558+
/// <summary>
559+
/// Converts <see cref="double"/> dash array to a <see cref="float"/> array, taking into account DPI scaling.
560+
/// </summary>
561+
/// <param name="values">The array of values.</param>
562+
/// <param name="strokeThickness">The stroke thickness.</param>
563+
/// <returns>The array of converted values.</returns>
564+
private float[] ConvertDashArray(double[] values, float strokeThickness)
565+
{
566+
var ret = new float[values.Length];
567+
for (var i = 0; i < values.Length; i++)
568+
{
569+
ret[i] = this.Convert(values[i]) * strokeThickness;
570+
}
571+
572+
return ret;
573+
}
574+
546575
/// <summary>
547576
/// Converts a <see cref="OxyRect"/> to a <see cref="SKRect"/>, taking into account DPI scaling and snapping the corners to pixels.
548577
/// </summary>
@@ -786,8 +815,9 @@ private SKPaint GetStrokePaint(OxyColor strokeColor, double strokeThickness, Edg
786815
/// <param name="fontFamily">The font family.</param>
787816
/// <param name="fontSize">The font size.</param>
788817
/// <param name="fontWeight">The font weight.</param>
818+
/// <param name="shaper">The font shaper.</param>
789819
/// <returns>The paint.</returns>
790-
private SKPaint GetTextPaint(string fontFamily, double fontSize, double fontWeight)
820+
private SKPaint GetTextPaint(string fontFamily, double fontSize, double fontWeight, out SKShaper shaper)
791821
{
792822
var fontDescriptor = new FontDescriptor(fontFamily, fontWeight);
793823
if (!this.typefaceCache.TryGetValue(fontDescriptor, out var typeface))
@@ -796,16 +826,65 @@ private SKPaint GetTextPaint(string fontFamily, double fontSize, double fontWeig
796826
this.typefaceCache.Add(fontDescriptor, typeface);
797827
}
798828

829+
if (this.UseTextShaping)
830+
{
831+
if (!this.shaperCache.TryGetValue(fontDescriptor, out shaper))
832+
{
833+
shaper = new SKShaper(typeface);
834+
this.shaperCache.Add(fontDescriptor, shaper);
835+
}
836+
}
837+
else
838+
{
839+
shaper = null;
840+
}
841+
799842
this.paint.Typeface = typeface;
800843
this.paint.TextSize = this.Convert(fontSize);
801844
this.paint.IsAntialias = true;
802845
this.paint.Style = SKPaintStyle.Fill;
803846
this.paint.HintingLevel = this.RendersToScreen ? SKPaintHinting.Full : SKPaintHinting.NoHinting;
804847
this.paint.SubpixelText = this.RendersToScreen;
805-
this.paint.TextAlign = SKTextAlign.Left;
806848
return this.paint;
807849
}
808850

851+
/// <summary>
852+
/// Measures text using the specified <see cref="SKShaper"/> and <see cref="SKPaint"/>.
853+
/// </summary>
854+
/// <param name="text">The text to measure.</param>
855+
/// <param name="shaper">The text shaper.</param>
856+
/// <param name="paint">The paint.</param>
857+
/// <returns>The width of the text when rendered using the specified shaper and paint.</returns>
858+
private float MeasureText(string text, SKShaper shaper, SKPaint paint)
859+
{
860+
if (!this.UseTextShaping)
861+
{
862+
return paint.MeasureText(text);
863+
}
864+
865+
// we have to get a bit creative here as SKShaper does not offer a direct overload for this.
866+
// see also https://github.com/mono/SkiaSharp/blob/master/source/SkiaSharp.HarfBuzz/SkiaSharp.HarfBuzz.Shared/SKShaper.cs
867+
using var buffer = new HarfBuzzSharp.Buffer();
868+
switch (paint.TextEncoding)
869+
{
870+
case SKTextEncoding.Utf8:
871+
buffer.AddUtf8(text);
872+
break;
873+
case SKTextEncoding.Utf16:
874+
buffer.AddUtf16(text);
875+
break;
876+
case SKTextEncoding.Utf32:
877+
buffer.AddUtf32(text);
878+
break;
879+
default:
880+
throw new NotSupportedException("TextEncoding is not supported.");
881+
}
882+
883+
buffer.GuessSegmentProperties();
884+
shaper.Shape(buffer, paint);
885+
return buffer.GlyphPositions.Sum(gp => gp.XAdvance) * paint.TextSize / 512;
886+
}
887+
809888
/// <summary>
810889
/// Gets a value indicating whether anti-aliasing should be used taking in account the specified edge rendering mode.
811890
/// </summary>

Source/OxyPlot.SkiaSharp/SkiaSvgExporter.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ public void Export(IPlotModel model, Stream stream)
3030
using var skStream = new SKManagedWStream(stream);
3131
using var writer = new SKXmlStreamWriter(skStream);
3232
using var canvas = SKSvgCanvas.Create(new SKRect(0, 0, this.Width, this.Height), writer);
33-
using var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = canvas };
33+
// SVG export does not work with UseTextShaping=true. However SVG does text shaping by itself anyway, so we can just disable it
34+
using var context = new SkiaRenderContext { RendersToScreen = false, SkCanvas = canvas, UseTextShaping = false };
3435
model.Update(true);
3536
model.Render(context, new OxyRect(0, 0, this.Width, this.Height));
3637
}

0 commit comments

Comments
 (0)