Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Svg.Tests.Win/SvgTextTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using Shouldly;
using NUnit.Framework;
using SkiaSharp;
using Svg.Editor.Tests;
using Svg.Interfaces;
using Svg.Platform;
using System.IO;
using System.Linq;

namespace Svg.Tests.Win
Expand All @@ -15,6 +18,53 @@ public void SetUp()
Svg.SvgEngine.Register<IFileLoader>(() => new FileLoader());
}

// When the embedded font has no glyph for a character,
// that character must be measured/drawn via a fallback font, not
// as the primary font's blank .notdef glyph
[Test]
public void EmbeddedSubsetFont_MissingGlyph_IsMeasuredViaFallback_NotNotdef()
{
// Top3_1.svg embeds a PdfToSvg.NET subset font (family "f37kpsI") that only contains the
// handful of glyphs the source PDF used, so most characters are absent from it.
var svgPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "Top3_1.svg");
using var src = File.OpenRead(svgPath);
using var doc = SvgDocument.Open<SvgDocument>(src);

var fontFamily = (SkiaFontFamily)SvgEngine.Factory.LoadCustomFontFamily(
"f37kpsI", SvgFontWeight.Normal, SvgFontStyle.Normal, doc);
var primary = fontFamily.Typeface;

using var paint = new SKPaint { Typeface = primary, TextSize = 20f };

// Find a character absent from the subset font but present in a system fallback, whose
// fallback advance differs from the primary's blank .notdef advance (so the test is
// meaningful). BuildTextRuns resolves fallbacks with SKFontManager.Default.MatchCharacter,
// so we mirror that here to compute the expected width.
char missing = default;
float expectedFallbackWidth = 0f, notdefWidth = 0f;
var found = false;
foreach (var c in "QWXYZqwxyz@#€§µ")
{
if (primary.GetGlyph(c) != 0) continue; // primary HAS it -> not missing
var fb = SKFontManager.Default.MatchCharacter(c);
if (fb == null || fb.GetGlyph(c) == 0) continue; // no usable fallback
using var fbFont = new SKFont(fb, paint.TextSize);
var fw = fbFont.MeasureText(c.ToString(), paint);
var nd = paint.MeasureText(c.ToString()); // primary .notdef advance
if (System.Math.Abs(fw - nd) < 0.5f) continue; // not discriminating
missing = c; expectedFallbackWidth = fw; notdefWidth = nd; found = true;
break;
}

found.ShouldBeTrue("expected a character absent from the subset font but present in a fallback");

var (fixedWidth, _) = paint.MeasureTextWithWhiteSpace(missing.ToString());

fixedWidth.ShouldBe(expectedFallbackWidth, 0.6f);
// Red before the fix: it would equal the primary .notdef advance instead.
System.Math.Abs(fixedWidth - notdefWidth).ShouldBeGreaterThan(0.5f);
}

[Test]
public void SvgTextSpan_ShouldBeTextBase()
{
Expand Down
15 changes: 14 additions & 1 deletion Svg/Platform/SkiaGraphics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,20 @@ public void DrawText(string text, float x, float y, Pen pen)
if (text == null)
return;
var paint = (SkiaPen)pen;
_canvas.DrawText(text, x, y, paint.Paint);
var skPaint = paint.Paint;

// Divide text into runs first and then draw each run
// This fixed an issue where if the primary font did not include all glyphs (e.g. for whitespace),
// we can use a different font (fallback) for those parts of the text
var runs = SKPaintExtensions.BuildTextRuns(skPaint, text, out _);

var drawX = x;
foreach (var run in runs)
{
_canvas.DrawText(run.Text, drawX, y, run.Font, skPaint);
drawX += run.Width;
run.Font.Dispose();
}
}

private void SetSmoothingMode(SKPaint paint)
Expand Down
87 changes: 72 additions & 15 deletions Svg/Platform/SkiaTextRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using SkiaSharp;
using Svg.Interfaces;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
Expand Down Expand Up @@ -262,26 +263,82 @@ private SKTextAlign FromAnchor(SvgTextAnchor textAnchor)

public static class SKPaintExtensions
{
// Fallback typefaces for characters missing from a primary font, keyed by codepoint
private static readonly ConcurrentDictionary<int, SKTypeface> _fallbackCache = new();

internal readonly struct TextRun
{
public readonly string Text;
public readonly SKFont Font;
public readonly float Width;

public TextRun(string text, SKFont font, float width)
{
Text = text;
Font = font;
Width = width;
}
}

private static SKTypeface ResolveFallback(char c)
{
return _fallbackCache.GetOrAdd(c,
cp => SKFontManager.Default.MatchCharacter(cp) ?? SKTypeface.Default);
}

/// <summary>
/// Skia ignores spaces when measuring text
/// So we need to solve it manually.
/// See here: https://github.com/mono/SkiaSharp/issues/605
/// Splits <paramref name="text"/> into runs, using a fallback typeface
/// for any characters the paint's typeface has no glyph for.
/// The returned <see cref="TextRun.Font"/> instances are owned by the caller and must be disposed.
/// </summary>
/// <param name="paint"></param>
/// <param name="text"></param>
/// <returns></returns>
internal static List<TextRun> BuildTextRuns(SKPaint paint, string text, out float totalWidth)
{
var runs = new List<TextRun>();
totalWidth = 0f;
if (string.IsNullOrEmpty(text))
return runs;

var primary = paint.Typeface ?? SKTypeface.Default;
var size = paint.TextSize;

var i = 0;
while (i < text.Length)
{
var primaryHasGlyph = primary.GetGlyph(text[i]) != 0;
var start = i;
while (i < text.Length && (primary.GetGlyph(text[i]) != 0) == primaryHasGlyph)
i++;

var runText = text.Substring(start, i - start);
var typeface = primaryHasGlyph ? primary : ResolveFallback(text[start]);
var font = new SKFont(typeface, size);
// SKFont.MeasureText returns the advance width (includes whitespace), which is the
// exact amount SKCanvas.DrawText advances the pen for this run.
var width = font.MeasureText(runText, paint);

runs.Add(new TextRun(runText, font, width));
totalWidth += width;
}

return runs;
}

public static (float width, float height) MeasureTextWithWhiteSpace(this SKPaint paint, string text)
{
SKRect rect = new SKRect();

var wrapper = ".";
var wrapperWidth = paint.MeasureText(wrapper);
var textWidth = paint.MeasureText(wrapper + text + wrapper, ref rect);
textWidth = textWidth - (wrapperWidth + wrapperWidth);
var runs = BuildTextRuns(paint, text, out var width);

// Height is the ink extent measured with the primary font. Missing (blank) glyphs
// contribute no ink, so measuring the whole string with the primary font is enough.
float height;
using (var primaryFont = new SKFont(paint.Typeface ?? SKTypeface.Default, paint.TextSize))
{
primaryFont.MeasureText(text, out var rect, paint);
height = rect.Bottom - rect.Top;
}

foreach (var run in runs)
run.Font.Dispose();

var width = textWidth;
var height = rect.Bottom - rect.Top;

return (width, height);
}
}
Expand Down
4 changes: 3 additions & 1 deletion Svg/Svg.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net48;net10.0</TargetFrameworks>
<RestoreProjectStyle>PackageReference</RestoreProjectStyle>
<Version>3.2.0-optiq09</Version>
<Version>3.2.0-optiq10</Version>
<Authors>gentledpp,zepr</Authors>
<Company>Opti-Q GmbH</Company>
<PackageReleaseNotes>
#3.2.0-optiq10
use a fallback font when the embedded font does not provide a glyph for a character
#3.2.0-optiq09
SvgVisualElement always rebuild the cached pens/brushes when the token differs
fix pin color under select tool
Expand Down
Loading