diff --git a/src/Svg.Model/Drawables/Elements/TextDrawable.cs b/src/Svg.Model/Drawables/Elements/TextDrawable.cs index a6acbe82b5..809d1c2fc7 100644 --- a/src/Svg.Model/Drawables/Elements/TextDrawable.cs +++ b/src/Svg.Model/Drawables/Elements/TextDrawable.cs @@ -301,6 +301,66 @@ internal void EndDraw(SKCanvas skCanvas, DrawAttributes ignoreAttributes, MaskDr skCanvas.Restore(); } + private float DrawTextRuns( + SvgTextBase svgTextBase, + string text, + float anchorX, + float anchorY, + SKRect skBounds, + SKPaint skPaint, + SKCanvas skCanvas) + { + PaintingService.SetPaintText(svgTextBase, skBounds, skPaint); + + var textAlign = skPaint.TextAlign; + var typefaceSpans = AssetLoader.FindTypefaces(text, skPaint); + if (typefaceSpans.Count == 0) + { + return 0f; + } + + var totalAdvance = 0f; + foreach (var span in typefaceSpans) + { + totalAdvance += span.Advance; + } + + var currentX = anchorX; + if (textAlign == SKTextAlign.Center) + { + currentX -= totalAdvance * 0.5f; + } + else if (textAlign == SKTextAlign.Right) + { + currentX -= totalAdvance; + } + + skPaint.TextAlign = SKTextAlign.Left; + + foreach (var typefaceSpan in typefaceSpans) + { + skPaint.Typeface = typefaceSpan.Typeface; +#if USE_TEXT_SHAPER + if (skPaint.Typeface is { } typeface) + { + using var skShaper = new SKShaper(typeface); + skCanvas.DrawShapedText(skShaper, typefaceSpan.Text, currentX, anchorY, skPaint); + } + else + { + skCanvas.DrawText(typefaceSpan.Text, currentX, anchorY, skPaint); + } +#else + skPaint.TextAlign = SKTextAlign.Left; + skCanvas.DrawText(typefaceSpan.Text, currentX, anchorY, skPaint); +#endif + currentX += typefaceSpan.Advance; + skPaint = skPaint.Clone(); // Don't modify stored skPaint objects + } + + return totalAdvance; + } + internal void DrawTextString(SvgTextBase svgTextBase, string text, ref float x, ref float y, SKRect skViewport, DrawAttributes ignoreAttributes, SKCanvas skCanvas, DrawableBase? until) { // Use element geometry for bounds so that paints relying on @@ -311,26 +371,9 @@ internal void DrawTextString(SvgTextBase svgTextBase, string text, ref float x, if (PaintingService.IsValidFill(svgTextBase)) { var skPaint = PaintingService.GetFillPaint(svgTextBase, skBounds, AssetLoader, References, ignoreAttributes); - if (skPaint is { }) { - PaintingService.SetPaintText(svgTextBase, skBounds, skPaint); - - foreach (var typefaceSpan in AssetLoader.FindTypefaces(text, skPaint)) - { - skPaint.Typeface = typefaceSpan.Typeface; -#if USE_TEXT_SHAPER - if (skPaint.Typeface is { } typeface) - { - using var skShaper = new SKShaper(skPaint.Typeface); - skCanvas.DrawShapedText(skShaper, typefaceSpan.text, x + fillAdvance, y, skPaint); - } -#else - skCanvas.DrawText(typefaceSpan.Text, x + fillAdvance, y, skPaint); -#endif - skPaint = skPaint.Clone(); // Don't modify stored skPaint objects - fillAdvance += typefaceSpan.Advance; - } + fillAdvance = DrawTextRuns(svgTextBase, text, x, y, skBounds, skPaint, skCanvas); } } @@ -341,23 +384,7 @@ internal void DrawTextString(SvgTextBase svgTextBase, string text, ref float x, var skPaint = PaintingService.GetStrokePaint(svgTextBase, skBounds, AssetLoader, References, ignoreAttributes); if (skPaint is { }) { - PaintingService.SetPaintText(svgTextBase, skBounds, skPaint); - - foreach (var typefaceSpan in AssetLoader.FindTypefaces(text, skPaint)) - { - skPaint.Typeface = typefaceSpan.Typeface; -#if USE_TEXT_SHAPER - if (skPaint.Typeface is { } typeface) - { - using var skShaper = new SKShaper(skPaint.Typeface); - skCanvas.DrawShapedText(skShaper, typefaceSpan.text, x + strokeAdvance, y, skPaint); - } -#else - skCanvas.DrawText(typefaceSpan.Text, x + strokeAdvance, y, skPaint); -#endif - skPaint = skPaint.Clone(); // Don't modify stored skPaint objects - strokeAdvance += typefaceSpan.Advance; - } + strokeAdvance = DrawTextRuns(svgTextBase, text, x, y, skBounds, skPaint, skCanvas); } } diff --git a/src/Svg.Skia/SkiaModel.cs b/src/Svg.Skia/SkiaModel.cs index bb3a37daa9..2b3496ae99 100644 --- a/src/Svg.Skia/SkiaModel.cs +++ b/src/Svg.Skia/SkiaModel.cs @@ -1,5 +1,6 @@ // Copyright (c) Wiesław Šoltés. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for details. +using System; using System.Collections.Generic; using ShimSkiaSharp; @@ -7,6 +8,17 @@ namespace Svg.Skia; public class SkiaModel { + private static readonly char[] s_fontFamilyTrimChars = { '\'', '"' }; + + private static readonly Dictionary s_genericFontFamilyMap = new(StringComparer.OrdinalIgnoreCase) + { + ["sans-serif"] = new[] { "sans-serif", "Helvetica Neue", "Helvetica", "Arial", "Roboto", "Segoe UI", "DejaVu Sans" }, + ["serif"] = new[] { "serif", "Times New Roman", "Times", "Georgia", "Droid Serif", "DejaVu Serif" }, + ["monospace"] = new[] { "monospace", "Courier New", "Courier", "Menlo", "Consolas", "Roboto Mono", "DejaVu Sans Mono" }, + ["cursive"] = new[] { "cursive", "Snell Roundhand", "Comic Sans MS", "Apple Chancery" }, + ["fantasy"] = new[] { "fantasy", "Impact", "Papyrus" } + }; + public SKSvgSettings Settings { get; } public SkiaModel(SKSvgSettings settings) @@ -178,29 +190,114 @@ public SkiaSharp.SKFontStyleSlant ToSKFontStyleSlant(SKFontStyleSlant fontStyleS }; } + private IEnumerable EnumerateFontFamilyCandidates(string? fontFamily) + { + var yielded = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(fontFamily)) + { + foreach (var rawFamily in fontFamily.Split(',')) + { + var candidate = rawFamily.Trim(); + if (candidate.Length == 0) + { + continue; + } + + candidate = candidate.Trim(s_fontFamilyTrimChars); + if (candidate.Length == 0 || !yielded.Add(candidate)) + { + continue; + } + + yield return candidate; + + if (s_genericFontFamilyMap.TryGetValue(candidate, out var mappedFamilies)) + { + foreach (var mapped in mappedFamilies) + { + if (yielded.Add(mapped)) + { + yield return mapped; + } + } + } + } + } + + if (yielded.Count == 0 && s_genericFontFamilyMap.TryGetValue("sans-serif", out var fallbackFamilies)) + { + foreach (var mapped in fallbackFamilies) + { + if (yielded.Add(mapped)) + { + yield return mapped; + } + } + } + } + + private SkiaSharp.SKTypeface? ResolveTypeface(string candidate, SkiaSharp.SKFontStyle style) + { + if (string.IsNullOrEmpty(candidate)) + { + return null; + } + + var fontManager = SkiaSharp.SKFontManager.Default; + var matched = fontManager.MatchFamily(candidate, style); + if (matched is { }) + { + return matched; + } + + return SkiaSharp.SKTypeface.FromFamilyName(candidate, style.Weight, style.Width, style.Slant); + } + public SkiaSharp.SKTypeface? ToSKTypeface(SKTypeface? typeface) { - if (typeface is null || typeface.FamilyName is null) - return SkiaSharp.SKTypeface.Default; + var fontFamily = typeface?.FamilyName; + var fontWeight = ToSKFontStyleWeight(typeface?.FontWeight ?? SKFontStyleWeight.Normal); + var fontWidth = ToSKFontStyleWidth(typeface?.FontWidth ?? SKFontStyleWidth.Normal); + var fontStyle = ToSKFontStyleSlant(typeface?.FontSlant ?? SKFontStyleSlant.Upright); + var style = new SkiaSharp.SKFontStyle(fontWeight, fontWidth, fontStyle); + + foreach (var candidate in EnumerateFontFamilyCandidates(fontFamily)) + { + if (Settings.TypefaceProviders is { } && Settings.TypefaceProviders.Count > 0) + { + foreach (var typefaceProvider in Settings.TypefaceProviders) + { + var providerTypeface = typefaceProvider.FromFamilyName(candidate, fontWeight, fontWidth, fontStyle); + if (providerTypeface is { }) + { + return providerTypeface; + } + } + } - var fontFamily = typeface.FamilyName; - var fontWeight = ToSKFontStyleWeight(typeface.FontWeight); - var fontWidth = ToSKFontStyleWidth(typeface.FontWidth); - var fontStyle = ToSKFontStyleSlant(typeface.FontSlant); + var resolved = ResolveTypeface(candidate, style); + if (resolved is { }) + { + return resolved; + } + } if (Settings.TypefaceProviders is { } && Settings.TypefaceProviders.Count > 0) { foreach (var typefaceProvider in Settings.TypefaceProviders) { - var skTypeface = typefaceProvider.FromFamilyName(fontFamily, fontWeight, fontWidth, fontStyle); - if (skTypeface is { }) + var providerTypeface = typefaceProvider.FromFamilyName(SkiaSharp.SKTypeface.Default.FamilyName, fontWeight, fontWidth, fontStyle); + if (providerTypeface is { }) { - return skTypeface; + return providerTypeface; } } } - return SkiaSharp.SKTypeface.FromFamilyName(fontFamily, fontWeight, fontWidth, fontStyle); + var defaultTypeface = SkiaSharp.SKTypeface.FromFamilyName(null, fontWeight, fontWidth, fontStyle); + + return defaultTypeface ?? SkiaSharp.SKTypeface.Default; } public SkiaSharp.SKColor ToSKColor(SKColor color) @@ -950,7 +1047,7 @@ public SkiaSharp.SKFilterQuality ToSKFilterQuality(SKFilterQuality filterQuality var blendMode = ToSKBlendMode(paint.BlendMode); var filterQuality = ToSKFilterQuality(paint.FilterQuality); - return new SkiaSharp.SKPaint + var skPaint = new SkiaSharp.SKPaint { Style = style, IsAntialias = paint.IsAntialias, @@ -972,6 +1069,24 @@ public SkiaSharp.SKFilterQuality ToSKFilterQuality(SKFilterQuality filterQuality BlendMode = blendMode, FilterQuality = filterQuality }; + + ApplyTypefaceAdjustments(paint, skPaint); + + return skPaint; + } + + private void ApplyTypefaceAdjustments(ShimSkiaSharp.SKPaint sourcePaint, SkiaSharp.SKPaint targetPaint) + { + if (sourcePaint.Typeface is null || targetPaint.Typeface is null) + { + return; + } + + var desiredWeight = (int)ToSKFontStyleWeight(sourcePaint.Typeface.FontWeight); + if (targetPaint.Typeface.FontWeight < desiredWeight) + { + targetPaint.FakeBoldText = true; + } } public SkiaSharp.SKClipOperation ToSKClipOperation(SKClipOperation clipOperation) diff --git a/tests/Svg.Skia.UnitTests/Issue405Tests.cs b/tests/Svg.Skia.UnitTests/Issue405Tests.cs new file mode 100644 index 0000000000..81324b43e3 --- /dev/null +++ b/tests/Svg.Skia.UnitTests/Issue405Tests.cs @@ -0,0 +1,63 @@ +#pragma warning disable CS0618 // Typeface and FakeBoldText are deprecated on SKPaint; shim keeps the legacy surface for compatibility + +using ShimSkiaSharp; +using Svg.Skia; +using Xunit; + +namespace Svg.Skia.UnitTests; + +public class Issue405Tests +{ + private readonly SkiaModel _model = new(new SKSvgSettings()); + private readonly SkiaSvgAssetLoader _assetLoader; + + public Issue405Tests() + { + _assetLoader = new SkiaSvgAssetLoader(_model); + } + + [Fact] + public void SansSerifBold_ResolvesSingleTypefaceSpan() + { + var paint = new SKPaint + { + Typeface = SKTypeface.FromFamilyName( + "sans-serif", + SKFontStyleWeight.Bold, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright) + }; + + var spans = _assetLoader.FindTypefaces("Bold Text 20px", paint); + + Assert.Single(spans); + var span = spans[0]; + Assert.NotNull(span.Typeface); + Assert.True(span.Typeface!.FontWeight >= SKFontStyleWeight.SemiBold, + "Expected resolved typeface to be semi-bold or heavier."); + } + + [Fact] + public void FakeBoldMatchesDesiredWeight() + { + var paint = new SKPaint + { + Typeface = SKTypeface.FromFamilyName( + "sans-serif", + SKFontStyleWeight.ExtraBlack, + SKFontStyleWidth.Normal, + SKFontStyleSlant.Upright) + }; + + using var skPaint = _model.ToSKPaint(paint); + Assert.NotNull(skPaint); + + var desiredWeight = (int)SkiaSharp.SKFontStyleWeight.ExtraBlack; + var actualWeight = skPaint!.Typeface?.FontWeight ?? 0; + var shouldFakeBold = actualWeight < desiredWeight; + + Assert.Equal(shouldFakeBold, skPaint.FakeBoldText); + } +} + +#pragma warning restore CS0618