From 4c656ea33ddf06924c631c4dd852cd95faf24656 Mon Sep 17 00:00:00 2001 From: Dario Stromajer Date: Wed, 29 Jul 2026 16:41:27 +0200 Subject: [PATCH] Fixed text rendered incorrectly with embedded font - In case an svg had an embedded font family, but it did not contain a glyph for all characters, those characters would be drawn as wider whitespace. - The fix was to use a fallback font in case the primary one does not contain a glyph for a character --- Svg.Tests.Win/SvgTextTests.cs | 50 ++++++++++++++++++ Svg/Platform/SkiaGraphics.cs | 15 +++++- Svg/Platform/SkiaTextRenderer.cs | 87 ++++++++++++++++++++++++++------ Svg/Svg.csproj | 4 +- 4 files changed, 139 insertions(+), 17 deletions(-) diff --git a/Svg.Tests.Win/SvgTextTests.cs b/Svg.Tests.Win/SvgTextTests.cs index af46a2282..0f2b72db9 100644 --- a/Svg.Tests.Win/SvgTextTests.cs +++ b/Svg.Tests.Win/SvgTextTests.cs @@ -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 @@ -15,6 +18,53 @@ public void SetUp() Svg.SvgEngine.Register(() => 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(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() { diff --git a/Svg/Platform/SkiaGraphics.cs b/Svg/Platform/SkiaGraphics.cs index ce1caf942..a6a7adfec 100644 --- a/Svg/Platform/SkiaGraphics.cs +++ b/Svg/Platform/SkiaGraphics.cs @@ -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) diff --git a/Svg/Platform/SkiaTextRenderer.cs b/Svg/Platform/SkiaTextRenderer.cs index 60e4a8925..7307a6019 100644 --- a/Svg/Platform/SkiaTextRenderer.cs +++ b/Svg/Platform/SkiaTextRenderer.cs @@ -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; @@ -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 _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); + } + /// - /// Skia ignores spaces when measuring text - /// So we need to solve it manually. - /// See here: https://github.com/mono/SkiaSharp/issues/605 + /// Splits into runs, using a fallback typeface + /// for any characters the paint's typeface has no glyph for. + /// The returned instances are owned by the caller and must be disposed. /// - /// - /// - /// + internal static List BuildTextRuns(SKPaint paint, string text, out float totalWidth) + { + var runs = new List(); + 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); } } diff --git a/Svg/Svg.csproj b/Svg/Svg.csproj index ef8ecaf67..f838c6de0 100644 --- a/Svg/Svg.csproj +++ b/Svg/Svg.csproj @@ -3,10 +3,12 @@ netstandard2.0;net48;net10.0 PackageReference - 3.2.0-optiq09 + 3.2.0-optiq10 gentledpp,zepr Opti-Q GmbH + #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