diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 9f5515a2..3eded7f1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -10,7 +10,7 @@ on: branches: - main - release/* - types: [ labeled, opened, synchronize, reopened ] + types: [ opened, synchronize, reopened ] jobs: # Prime a single LFS cache and expose the exact key for the matrix WarmLFS: diff --git a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs index b1a59ac9..4a0a6d13 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs @@ -163,7 +163,7 @@ private GlyphMetrics CreateCffGlyphMetrics( this, glyphId, codePoint, - new SvgGlyphSource(svg), + this.GetOrCreateSvgGlyphSource(svg), bounds, advanceWidth, advancedHeight, diff --git a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs index 0806ca17..26b57fae 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs @@ -281,7 +281,7 @@ private GlyphMetrics CreateTrueTypeGlyphMetrics( this, glyphId, codePoint, - new SvgGlyphSource(svg), + this.GetOrCreateSvgGlyphSource(svg), bounds, advanceWidth, advancedHeight, diff --git a/src/SixLabors.Fonts/StreamFontMetrics.cs b/src/SixLabors.Fonts/StreamFontMetrics.cs index a5fcf313..1642e178 100644 --- a/src/SixLabors.Fonts/StreamFontMetrics.cs +++ b/src/SixLabors.Fonts/StreamFontMetrics.cs @@ -12,6 +12,7 @@ using SixLabors.Fonts.Tables.General; using SixLabors.Fonts.Tables.General.Kern; using SixLabors.Fonts.Tables.General.Post; +using SixLabors.Fonts.Tables.General.Svg; using SixLabors.Fonts.Tables.TrueType; using SixLabors.Fonts.Tables.TrueType.Hinting; using SixLabors.Fonts.Unicode; @@ -34,6 +35,7 @@ internal partial class StreamFontMetrics : FontMetrics private readonly ConcurrentDictionary<(int CodePoint, ushort Id, TextAttributes Attributes, ColorFontSupport ColorSupport, bool IsVerticalLayout), GlyphMetrics> glyphCache; private readonly ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> glyphIdCache; private readonly ConcurrentDictionary codePointCache; + private SvgGlyphSource? svgGlyphSource; private readonly FontDescription description; private readonly HorizontalMetrics horizontalMetrics; private readonly VerticalMetrics verticalMetrics; @@ -104,7 +106,8 @@ private StreamFontMetrics( TrueTypeFontTables tables, GlyphVariationProcessor processor, ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> sharedGlyphIdCache, - ConcurrentDictionary sharedCodePointCache) + ConcurrentDictionary sharedCodePointCache, + SvgGlyphSource? svgGlyphSource) { this.trueTypeFontTables = tables; this.outlineType = OutlineType.TrueType; @@ -113,6 +116,7 @@ private StreamFontMetrics( this.glyphIdCache = sharedGlyphIdCache; this.codePointCache = sharedCodePointCache; this.glyphCache = new(); + this.svgGlyphSource = svgGlyphSource; (HorizontalMetrics HorizontalMetrics, VerticalMetrics VerticalMetrics) metrics = this.Initialize(tables); this.horizontalMetrics = metrics.HorizontalMetrics; @@ -130,7 +134,8 @@ private StreamFontMetrics( CompactFontTables tables, GlyphVariationProcessor processor, ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> sharedGlyphIdCache, - ConcurrentDictionary sharedCodePointCache) + ConcurrentDictionary sharedCodePointCache, + SvgGlyphSource? svgGlyphSource) { this.compactFontTables = tables; this.outlineType = OutlineType.CFF; @@ -139,6 +144,7 @@ private StreamFontMetrics( this.glyphIdCache = sharedGlyphIdCache; this.codePointCache = sharedCodePointCache; this.glyphCache = new(); + this.svgGlyphSource = svgGlyphSource; (HorizontalMetrics HorizontalMetrics, VerticalMetrics VerticalMetrics) metrics = this.Initialize(tables); this.horizontalMetrics = metrics.HorizontalMetrics; @@ -519,7 +525,7 @@ internal StreamFontMetrics CreateVariationInstance(FontVariation[] variations) tables.Cvar, userCoordinates); - return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache); + return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache, this.svgGlyphSource); } else { @@ -535,7 +541,7 @@ internal StreamFontMetrics CreateVariationInstance(FontVariation[] variations) tables.MVar, userCoordinates: userCoordinates); - return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache); + return new StreamFontMetrics(tables, processor, this.glyphIdCache, this.codePointCache, this.svgGlyphSource); } } @@ -825,4 +831,7 @@ private GlyphMetrics CreateGlyphMetrics( OutlineType.CFF => this.CreateCffGlyphMetrics(in codePoint, glyphId, glyphType, textAttributes, textDecorations, colorSupport, isVerticalLayout, paletteIndex), _ => throw new NotSupportedException(), }; + + private SvgGlyphSource GetOrCreateSvgGlyphSource(SvgTable svgTable) + => this.svgGlyphSource ??= new SvgGlyphSource(svgTable); } diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs index b8b8f05f..9790ce16 100644 --- a/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs +++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrTable.cs @@ -283,7 +283,7 @@ internal bool TryGetColrV1Layers( // 2) Flatten paint graph to layers. Start with no current glyph id. List acc = []; - this.FlattenPaintToLayers(root, null, Matrix3x2.Identity, CompositeMode.SrcOver, processor, acc); + this.FlattenPaintToLayers(root, null, Matrix3x2.Identity, Matrix3x2.Identity, false, CompositeMode.SrcOver, processor, acc); // 3) If nothing emitted, the graph did not bind any geometry (no PaintGlyph/ColrGlyph reached). if (acc.Count == 0) @@ -311,14 +311,18 @@ internal bool TryGetColrV1Layers( /// /// The glyph id whose outline will receive the paint. Set by PaintGlyph/PaintColrGlyph. /// - /// Accumulated transform. + /// The accumulated transform to apply to the glyph's geometry. + /// The accumulated transform to apply to the paint. + /// Whether wrapper transforms should be applied to the paint (true) or to the glyph geometry (false). /// Accumulated composite mode. /// The glyph variation processor, or null for non-variable fonts. /// Accumulator for resolved layers. private void FlattenPaintToLayers( Paint node, ushort? currentGlyphId, - Matrix3x2 transform, + Matrix3x2 glyphTransform, + Matrix3x2 paintTransform, + bool transformPaint, CompositeMode compositeMode, GlyphVariationProcessor? processor, List outLayers) @@ -346,7 +350,7 @@ private void FlattenPaintToLayers( if (this.paintCache!.TryGetValue(off, out Paint? child) && child is not null) { - this.FlattenPaintToLayers(child, currentGlyphId, transform, compositeMode, processor, outLayers); + this.FlattenPaintToLayers(child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); } } @@ -355,11 +359,11 @@ private void FlattenPaintToLayers( case PaintColrGlyph pcg: { - // Resolve the referenced glyph's root paint and recurse under that glyph id. + // Resolve the referenced glyph's root paint and recurse through its own bindings. if (this.TryGetRootPaintOffset(pcg.GlyphId, out uint off) && off != 0 && this.paintCache!.TryGetValue(off, out Paint? colrRoot) && colrRoot is not null) { - this.FlattenPaintToLayers(colrRoot, pcg.GlyphId, transform, compositeMode, processor, outLayers); + this.FlattenPaintToLayers(colrRoot, null, glyphTransform, Matrix3x2.Identity, false, compositeMode, processor, outLayers); } return; @@ -368,7 +372,7 @@ private void FlattenPaintToLayers( case PaintGlyph pg: { // Bind geometry to the specified glyph id and recurse into its child paint. - this.FlattenPaintToLayers(pg.Child, pg.GlyphId, transform, compositeMode, processor, outLayers); + this.FlattenPaintToLayers(pg.Child, pg.GlyphId, glyphTransform, Matrix3x2.Identity, true, compositeMode, processor, outLayers); return; } @@ -378,8 +382,17 @@ private void FlattenPaintToLayers( case PaintTransform pt: { Affine2x3 a = pt.Transform; - transform *= new Matrix3x2(a.Xx, a.Yx, a.Xy, a.Yy, a.Dx, a.Dy); - this.FlattenPaintToLayers(pt.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = new(a.Xx, a.Yx, a.Xy, a.Yy, a.Dx, a.Dy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(pt.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -393,15 +406,33 @@ private void FlattenPaintToLayers( float yy = a.Yy + this.ResolveDelta(processor, vib + 3u); float dx = a.Dx + this.ResolveDelta(processor, vib + 4u); float dy = a.Dy + this.ResolveDelta(processor, vib + 5u); - transform *= new Matrix3x2(xx, yx, xy, yy, dx, dy); - this.FlattenPaintToLayers(pvt.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = new(xx, yx, xy, yy, dx, dy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(pvt.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } case PaintTranslate t: { - transform *= Matrix3x2.CreateTranslation(t.Dx, t.Dy); - this.FlattenPaintToLayers(t.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = Matrix3x2.CreateTranslation(t.Dx, t.Dy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(t.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -409,15 +440,33 @@ private void FlattenPaintToLayers( { float dx = vt.Dx + this.ResolveDelta(processor, vt.VarIndexBase + 0u); float dy = vt.Dy + this.ResolveDelta(processor, vt.VarIndexBase + 1u); - transform *= Matrix3x2.CreateTranslation(dx, dy); - this.FlattenPaintToLayers(vt.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = Matrix3x2.CreateTranslation(dx, dy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(vt.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } case PaintScale s: { - transform *= BuildScale(s.ScaleX, s.ScaleY, s.AroundCenter, s.CenterX, s.CenterY); - this.FlattenPaintToLayers(s.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildScale(s.ScaleX, s.ScaleY, s.AroundCenter, s.CenterX, s.CenterY); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(s.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -429,15 +478,33 @@ private void FlattenPaintToLayers( int centerOffset = vs.Uniform ? 1 : 2; float cx = vs.AroundCenter ? vs.CenterX + this.ResolveDelta(processor, vib + (uint)centerOffset) : 0; float cy = vs.AroundCenter ? vs.CenterY + this.ResolveDelta(processor, vib + (uint)centerOffset + 1u) : 0; - transform *= BuildScale(sx, sy, vs.AroundCenter, cx, cy); - this.FlattenPaintToLayers(vs.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildScale(sx, sy, vs.AroundCenter, cx, cy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(vs.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } case PaintRotate r: { - transform *= BuildRotate(r.Angle, r.AroundCenter, r.CenterX, r.CenterY); - this.FlattenPaintToLayers(r.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildRotate(r.Angle, r.AroundCenter, r.CenterX, r.CenterY); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(r.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -447,15 +514,33 @@ private void FlattenPaintToLayers( float angle = vr.Angle + this.ResolveDelta(processor, vib + 0u); float cx = vr.AroundCenter ? vr.CenterX + this.ResolveDelta(processor, vib + 1u) : 0; float cy = vr.AroundCenter ? vr.CenterY + this.ResolveDelta(processor, vib + 2u) : 0; - transform *= BuildRotate(angle, vr.AroundCenter, cx, cy); - this.FlattenPaintToLayers(vr.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildRotate(angle, vr.AroundCenter, cx, cy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(vr.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } case PaintSkew k: { - transform *= BuildSkew(k.XSkew, k.YSkew, k.AroundCenter, k.CenterX, k.CenterY); - this.FlattenPaintToLayers(k.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildSkew(k.XSkew, k.YSkew, k.AroundCenter, k.CenterX, k.CenterY); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(k.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -466,8 +551,17 @@ private void FlattenPaintToLayers( float ySkew = vk.YSkew + this.ResolveDelta(processor, vib + 1u); float cx = vk.AroundCenter ? vk.CenterX + this.ResolveDelta(processor, vib + 2u) : 0; float cy = vk.AroundCenter ? vk.CenterY + this.ResolveDelta(processor, vib + 3u) : 0; - transform *= BuildSkew(xSkew, ySkew, vk.AroundCenter, cx, cy); - this.FlattenPaintToLayers(vk.Child, currentGlyphId, transform, compositeMode, processor, outLayers); + Matrix3x2 next = BuildSkew(xSkew, ySkew, vk.AroundCenter, cx, cy); + if (transformPaint) + { + paintTransform *= next; + } + else + { + glyphTransform *= next; + } + + this.FlattenPaintToLayers(vk.Child, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -476,8 +570,8 @@ private void FlattenPaintToLayers( compositeMode = MapCompositeMode(comp.CompositeMode); // Backdrop first, then Source. Both inherit the current glyph id. - this.FlattenPaintToLayers(comp.Backdrop, currentGlyphId, transform, compositeMode, processor, outLayers); - this.FlattenPaintToLayers(comp.Source, currentGlyphId, transform, compositeMode, processor, outLayers); + this.FlattenPaintToLayers(comp.Backdrop, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); + this.FlattenPaintToLayers(comp.Source, currentGlyphId, glyphTransform, paintTransform, transformPaint, compositeMode, processor, outLayers); return; } @@ -497,7 +591,7 @@ private void FlattenPaintToLayers( if (currentGlyphId.HasValue) { _ = this.TryGetClipBox(currentGlyphId.Value, processor, out Bounds? clip); - outLayers.Add(new ResolvedGlyphLayer(currentGlyphId.Value, node, transform, compositeMode, clip)); + outLayers.Add(new ResolvedGlyphLayer(currentGlyphId.Value, node, glyphTransform, paintTransform, compositeMode, clip)); } return; @@ -1456,7 +1550,7 @@ internal sealed class PaintCaches /// /// Represents a resolved COLR v1 glyph layer produced by flattening the paint DAG. -/// Associates a glyph ID with its paint node, accumulated transform, composite mode, and optional clip box. +/// Associates a glyph ID with its paint node, geometry transform, paint transform, composite mode, and optional clip box. /// #pragma warning disable SA1201 // Elements should appear in the correct order [DebuggerDisplay("Id: {GlyphId}")] @@ -1468,14 +1562,16 @@ internal readonly struct ResolvedGlyphLayer /// /// The glyph ID whose outline this layer paints. /// The leaf paint node for this layer. - /// The accumulated affine transform. + /// The accumulated affine transform applied to glyph geometry. + /// The accumulated affine transform applied to the leaf paint. /// The composite mode to apply. /// The optional clip box bounds, or . - public ResolvedGlyphLayer(ushort id, Paint paint, Matrix3x2 transform, CompositeMode mode, Bounds? clipBox) + public ResolvedGlyphLayer(ushort id, Paint paint, Matrix3x2 glyphTransform, Matrix3x2 paintTransform, CompositeMode mode, Bounds? clipBox) { this.GlyphId = id; this.Paint = paint; - this.Transform = transform; + this.GlyphTransform = glyphTransform; + this.PaintTransform = paintTransform; this.CompositeMode = mode; this.ClipBox = clipBox; } @@ -1491,9 +1587,14 @@ public ResolvedGlyphLayer(ushort id, Paint paint, Matrix3x2 transform, Composite public Paint Paint { get; } /// - /// Gets the accumulated affine transform from the paint DAG traversal. + /// Gets the accumulated affine transform applied to glyph geometry. + /// + public Matrix3x2 GlyphTransform { get; } + + /// + /// Gets the accumulated affine transform applied to the leaf paint. /// - public Matrix3x2 Transform { get; } + public Matrix3x2 PaintTransform { get; } /// /// Gets the composite mode to apply when rendering this layer. diff --git a/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs index 6c172d32..289b89a8 100644 --- a/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs +++ b/src/SixLabors.Fonts/Tables/General/Colr/ColrV1GlyphSource.cs @@ -58,24 +58,14 @@ public override bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, // Flatten paint graph: accumulate wrapper transforms; attach composite mode to leaves. List leafPaints = []; - FlattenPaint(rl.Paint, rl.Transform, rl.CompositeMode, this.Cpal, this.Colr, this.processor, leafPaints); + FlattenPaint(rl.Paint, rl.PaintTransform, rl.CompositeMode, this.Cpal, this.Colr, this.processor, leafPaints); // Emit one layer per leaf paint. Bounds? clip = rl.ClipBox; for (int p = 0; p < leafPaints.Count; p++) { Rendering.Paint leaf = leafPaints[p]; - Matrix3x2 xForm = Matrix3x2.Identity; - if (leaf is SolidPaint solid) - { - // Move the transform from the paint to the layer. - // We do this so that solid paints are also transformed correctly as - // their location is defined in the local space of the layer. - xForm = solid.Transform; - solid.Transform = Matrix3x2.Identity; - } - - layers.Add(new PaintedLayer(leaf, FillRule.NonZero, xForm, clip, path)); + layers.Add(new PaintedLayer(leaf, FillRule.NonZero, rl.GlyphTransform, clip, path)); } } diff --git a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs index f6021833..361bfa36 100644 --- a/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs +++ b/src/SixLabors.Fonts/Tables/General/Svg/SvgGlyphSource.cs @@ -10,7 +10,6 @@ using System.Xml.Linq; using SixLabors.Fonts.Rendering; -#pragma warning disable SA1201 // Elements should appear in the correct order namespace SixLabors.Fonts.Tables.General.Svg; /// @@ -20,17 +19,11 @@ namespace SixLabors.Fonts.Tables.General.Svg; /// internal sealed class SvgGlyphSource : IPaintedGlyphSource { + private static readonly SolidPaint DefaultBlackFillPaint = new() { Color = GlyphColor.Black }; private readonly SvgTable svgTable; - private readonly Dictionary docCache = []; + private readonly ConcurrentDictionary<(int Start, int Length), ParsedDoc> docCache = []; private readonly ConcurrentDictionary cachedGlyphs = []; - private sealed class ParsedDoc - { - public required XDocument Doc { get; init; } - - public required Dictionary IdMap { get; init; } - } - /// /// Initializes a new instance of the class. /// @@ -58,9 +51,10 @@ public bool TryGetPaintedGlyph(ushort glyphId, out PaintedGlyph glyph, out Paint Walk( glyphRoot, rootTransform, - inheritedPaint: null, + inheritedPaint: DefaultBlackFillPaint, + inheritedOpacityMul: 1F, outputLayers: layers, - idMap: parsed.IdMap); + parsedDoc: parsed); if (layers.Count > 0) { @@ -83,12 +77,13 @@ private bool TryGetParsedDoc(ushort glyphId, [NotNullWhen(true)] out ParsedDoc? { parsed = default; - if (!this.svgTable.TryGetDocumentSpan(glyphId, out int _, out int _)) + if (!this.svgTable.TryGetDocumentSpan(glyphId, out int start, out int length)) { return false; } - if (this.docCache.TryGetValue(glyphId, out parsed)) + (int Start, int Length) docKey = (start, length); + if (this.docCache.TryGetValue(docKey, out parsed)) { return true; } @@ -124,7 +119,7 @@ private bool TryGetParsedDoc(ushort glyphId, [NotNullWhen(true)] out ParsedDoc? IdMap = idMap }; - this.docCache[glyphId] = parsed; + this.docCache[docKey] = parsed; return true; } } @@ -159,13 +154,17 @@ private static void Walk( XElement node, Matrix3x2 parentLocalTransform, Paint? inheritedPaint, + float inheritedOpacityMul, List outputLayers, - Dictionary idMap) + ParsedDoc parsedDoc) { - Matrix3x2 localTransform = parentLocalTransform * ParseTransform(node.Attribute("transform")?.Value); + Dictionary idMap = parsedDoc.IdMap; + Matrix3x2 nodeTransform = ParseTransform(node.Attribute("transform")?.Value); + Matrix3x2 localTransform = parentLocalTransform * nodeTransform; FillRule fillRule = ResolveFillRule(node, FillRule.NonZero); - Paint? paint = ResolvePaint(node, inheritedPaint, idMap, out bool fillNone, out float opacityMul); + Paint? paint = ResolvePaint(node, inheritedPaint, parsedDoc, out bool fillNone, out float opacityMul); + float combinedOpacityMul = inheritedOpacityMul * opacityMul; string name = node.Name.LocalName; switch (name) @@ -175,7 +174,7 @@ private static void Walk( { foreach (XElement child in node.Elements()) { - Walk(child, localTransform, fillNone ? null : paint, outputLayers, idMap); + Walk(child, localTransform, fillNone ? null : paint, combinedOpacityMul, outputLayers, parsedDoc); } break; @@ -191,15 +190,16 @@ private static void Walk( float ux = ParseFloat(node.Attribute("x")?.Value); float uy = ParseFloat(node.Attribute("y")?.Value); - Matrix3x2 xf = localTransform * Matrix3x2.CreateTranslation(ux, uy); + Matrix3x2 xf = parentLocalTransform + * Matrix3x2.CreateTranslation(ux, uy) + * nodeTransform; - Paint? usePaint = ResolvePaint(node, paint, idMap, out bool useNone, out float _); - Paint? childInherited = useNone ? null : usePaint; + Paint? childInherited = fillNone ? null : paint; XElement? target = LookupById(idMap, href); if (target is not null) { - Walk(target, xf, childInherited, outputLayers, idMap); + Walk(target, xf, childInherited, combinedOpacityMul, outputLayers, parsedDoc); } break; @@ -218,10 +218,10 @@ private static void Walk( break; } - List cmds = BuildCommandsFromPathData(d); + List cmds = GetOrBuildPathCommands(node, d, parsedDoc); if (cmds.Count > 0) { - Paint? layerPaint = ApplyOpacityToPaint(paint, opacityMul); + Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul); outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds)); } @@ -241,10 +241,10 @@ private static void Walk( if (coords.Length >= 4) { bool close = string.Equals(node.Name.LocalName, "polygon", StringComparison.Ordinal); - List cmds = BuildCommandsFromPoly(coords, close); + List cmds = GetOrBuildPolyCommands(node, coords, close, parsedDoc); if (cmds.Count > 0) { - Paint? layerPaint = ApplyOpacityToPaint(paint, opacityMul); + Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul); outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds)); } } @@ -267,18 +267,10 @@ private static void Walk( // TODO: Rounded corners (rx/ry) not handled here (could be approximated later if needed). if (w > 0f && h > 0f) { - float[] coords = - [ - x, y, - x + w, y, - x + w, y + h, - x, y + h - ]; - - List cmds = BuildCommandsFromPoly(coords, close: true); + List cmds = GetOrBuildRectCommands(node, x, y, w, h, parsedDoc); if (cmds.Count > 0) { - Paint? layerPaint = ApplyOpacityToPaint(paint, opacityMul); + Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul); outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds)); } } @@ -298,10 +290,10 @@ private static void Walk( float r = ParseFloat(node.Attribute("r")?.Value); if (r > 0f) { - List cmds = BuildCommandsForEllipse(cx, cy, r, r); + List cmds = GetOrBuildEllipseCommands(node, cx, cy, r, r, parsedDoc); if (cmds.Count > 0) { - Paint? layerPaint = ApplyOpacityToPaint(paint, opacityMul); + Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul); outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds)); } } @@ -322,10 +314,10 @@ private static void Walk( float ry = ParseFloat(node.Attribute("ry")?.Value); if (rx > 0f && ry > 0f) { - List cmds = BuildCommandsForEllipse(cx, cy, rx, ry); + List cmds = GetOrBuildEllipseCommands(node, cx, cy, rx, ry, parsedDoc); if (cmds.Count > 0) { - Paint? layerPaint = ApplyOpacityToPaint(paint, opacityMul); + Paint? layerPaint = ApplyOpacityToPaint(paint, combinedOpacityMul); outputLayers.Add(new(layerPaint, fillRule, localTransform, null, cmds)); } } @@ -405,7 +397,7 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault) private static Paint? ResolvePaint( XElement e, Paint? inherited, - Dictionary idMap, + ParsedDoc parsedDoc, out bool fillNone, out float opacityMul) { @@ -453,27 +445,46 @@ private static FillRule ResolveFillRule(XElement e, FillRule inheritedDefault) if (TryExtractUrlId(fill, out string? paintId) && paintId is not null) { - return ResolvePaintServer(paintId, idMap) ?? inherited; + return ResolvePaintServer(paintId, parsedDoc) ?? inherited; } return inherited; } - private static Paint? ResolvePaintServer(string id, Dictionary idMap) + /// + /// Resolves a referenced paint server and caches the parsed paint so repeated + /// uses of the same gradient id do not rebuild the gradient definition. + /// + /// The referenced paint server identifier. + /// The parsed SVG document and its caches. + /// The resolved paint, or if the reference is unknown. + private static Paint? ResolvePaintServer(string id, ParsedDoc parsedDoc) { - if (!idMap.TryGetValue(id, out XElement? server)) + if (parsedDoc.PaintServerCache.TryGetValue(id, out Paint? cached)) + { + return cached; + } + + if (!parsedDoc.IdMap.TryGetValue(id, out XElement? server)) { return null; } string tag = server.Name.LocalName; - return tag switch + Paint? paint = tag switch { // SVG only has linearGradient and radialGradient. - "linearGradient" => BuildLinearGradient(server, idMap), - "radialGradient" => BuildRadialGradient(server, idMap), + "linearGradient" => BuildLinearGradient(server, parsedDoc.IdMap), + "radialGradient" => BuildRadialGradient(server, parsedDoc.IdMap), _ => null }; + + if (paint is not null) + { + parsedDoc.PaintServerCache.TryAdd(id, paint); + } + + return paint; } private static LinearGradientPaint? BuildLinearGradient(XElement grad, Dictionary idMap) @@ -716,7 +727,7 @@ private static GradientUnits ParseGradientUnits(string value) return null; } - if (s!.EndsWith('%')) + if (s.EndsWith('%')) { if (float.TryParse(s.AsSpan(0, s.Length - 1), NumberStyles.Float, CultureInfo.InvariantCulture, out float p)) { @@ -810,21 +821,23 @@ private static GradientStop[] BuildStopsArray(List<(float Offset, GlyphColor Col return null; } - // TODO: Rewrite this using Span.Split to avoid allocations. - string[] parts = style.Split(';', StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < parts.Length; i++) + ReadOnlySpan span = style.AsSpan(); + while (span.Length > 0) { - string part = parts[i]; - int c = part.IndexOf(':'); - if (c <= 0) + int semi = span.IndexOf(';'); + ReadOnlySpan part = semi >= 0 ? span[..semi] : span; + span = semi >= 0 ? span[(semi + 1)..] : []; + + int colon = part.IndexOf(':'); + if (colon <= 0) { continue; } - string name = part.AsSpan(0, c).Trim().ToString(); - if (name.Equals(prop, StringComparison.OrdinalIgnoreCase)) + ReadOnlySpan name = part[..colon].Trim(); + if (name.Equals(prop.AsSpan(), StringComparison.OrdinalIgnoreCase)) { - return part.AsSpan(c + 1).Trim().ToString(); + return part[(colon + 1)..].Trim().ToString(); } } @@ -850,15 +863,16 @@ private static bool TryParseColor(string s, out GlyphColor color) int r = s.IndexOf(')'); if (l >= 0 && r > l) { - // TODO: Rewrite this using Span.Split to avoid allocations. - string[] comps = s.Substring(l + 1, r - l - 1).Split(','); - if (comps.Length >= 3) + ReadOnlySpan inner = s.AsSpan(l + 1, r - l - 1); + Span ranges = stackalloc Range[5]; + int count = inner.Split(ranges, ','); + if (count >= 3) { - byte rr = ParseByte(comps[0]); - byte gg = ParseByte(comps[1]); - byte bb = ParseByte(comps[2]); + byte rr = ParseByte(inner[ranges[0]]); + byte gg = ParseByte(inner[ranges[1]]); + byte bb = ParseByte(inner[ranges[2]]); byte aa = 255; - if (comps.Length >= 4 && float.TryParse(comps[3], NumberStyles.Float, CultureInfo.InvariantCulture, out float af)) + if (count >= 4 && float.TryParse(inner[ranges[3]].Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out float af)) { aa = (byte)Math.Clamp((int)Math.Round(255f * af), 0, 255); } @@ -940,6 +954,150 @@ private static bool TryExtractUrlId(string s, [NotNullWhen(true)] out string? id return e.Attribute(xlink + "href")?.Value ?? e.Attribute("href")?.Value; } + /// + /// Returns cached path commands for an SVG path element, or parses and caches them + /// when the element is a reusable definition with an id. + /// + /// The SVG node that owns the geometry. + /// The raw SVG path data. + /// The parsed SVG document and its caches. + /// The parsed path commands. + private static List GetOrBuildPathCommands(XElement node, string d, ParsedDoc parsedDoc) + { + if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId)) + { + return cached; + } + + return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPathData(d)); + } + + /// + /// Returns cached path commands for a polygon or polyline definition, or builds and caches them. + /// + /// The SVG node that owns the geometry. + /// The parsed coordinate list. + /// Whether the geometry should be explicitly closed. + /// The parsed SVG document and its caches. + /// The parsed path commands. + private static List GetOrBuildPolyCommands(XElement node, float[] coords, bool close, ParsedDoc parsedDoc) + { + if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId)) + { + return cached; + } + + return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPoly(coords, close)); + } + + /// + /// Returns cached path commands for a rectangle definition, or builds and caches them. + /// + /// The SVG node that owns the geometry. + /// The rectangle origin X. + /// The rectangle origin Y. + /// The rectangle width. + /// The rectangle height. + /// The parsed SVG document and its caches. + /// The parsed path commands. + private static List GetOrBuildRectCommands( + XElement node, + float x, + float y, + float w, + float h, + ParsedDoc parsedDoc) + { + if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId)) + { + return cached; + } + + float[] coords = + [ + x, y, + x + w, y, + x + w, y + h, + x, y + h + ]; + + return CacheGeometry(geometryId, parsedDoc, BuildCommandsFromPoly(coords, close: true)); + } + + /// + /// Returns cached path commands for an ellipse or circle definition, or builds and caches them. + /// + /// The SVG node that owns the geometry. + /// The ellipse center X. + /// The ellipse center Y. + /// The ellipse radius on the X axis. + /// The ellipse radius on the Y axis. + /// The parsed SVG document and its caches. + /// The parsed path commands. + private static List GetOrBuildEllipseCommands( + XElement node, + float cx, + float cy, + float rx, + float ry, + ParsedDoc parsedDoc) + { + if (TryGetCachedGeometry(node, parsedDoc, out List? cached, out string? geometryId)) + { + return cached; + } + + return CacheGeometry(geometryId, parsedDoc, BuildCommandsForEllipse(cx, cy, rx, ry)); + } + + /// + /// Looks up cached geometry for a reusable SVG element by its id. + /// + /// The SVG node that may have cached geometry. + /// The parsed SVG document and its caches. + /// When this method returns, contains the cached commands if found. + /// When this method returns, contains the element id used as the cache key. + /// if cached geometry was found; otherwise, . + private static bool TryGetCachedGeometry( + XElement node, + ParsedDoc parsedDoc, + [NotNullWhen(true)] out List? cached, + [NotNullWhen(true)] out string? geometryId) + { + geometryId = node.Attribute("id")?.Value; + if (geometryId is not null && parsedDoc.GeometryCache.TryGetValue(geometryId, out List? commands)) + { + cached = commands; + return true; + } + + cached = null; + return false; + } + + /// + /// Stores geometry in the per-document cache when the + /// source element has a reusable id. + /// + /// The cache key, or when the element is anonymous. + /// The parsed SVG document and its caches. + /// The newly built commands. + /// The cached or materialized command list. + private static List CacheGeometry(string? geometryId, ParsedDoc parsedDoc, List commands) + { + if (commands.Count == 0) + { + return []; + } + + if (geometryId is not null) + { + parsedDoc.GeometryCache.TryAdd(geometryId, commands); + } + + return commands; + } + private static List BuildCommandsFromPoly(float[] coords, bool close) { List cmds = []; @@ -1274,7 +1432,7 @@ private static Matrix3x2 ParseTransform(string? s) i++; } - string op = s[start..i]; + ReadOnlySpan op = s.AsSpan(start, i - start); SkipSep(s, ref i); if (i >= n || s[i] != '(') @@ -1300,78 +1458,58 @@ private static Matrix3x2 ParseTransform(string? s) i++; } - string args = s.Substring(argsStart, (i - argsStart) - 1); + ReadOnlySpan args = s.AsSpan(argsStart, (i - argsStart) - 1); float[] a = ParseFloatList(args); Matrix3x2 t = Matrix3x2.Identity; - switch (op) + if (op.SequenceEqual("matrix")) { - case "matrix": + if (a.Length >= 6) { - if (a.Length >= 6) - { - t = new Matrix3x2(a[0], a[1], a[2], a[3], a[4], a[5]); - } - - break; + t = new Matrix3x2(a[0], a[1], a[2], a[3], a[4], a[5]); } - - case "translate": + } + else if (op.SequenceEqual("translate")) + { + if (a.Length == 1) { - if (a.Length == 1) - { - t = Matrix3x2.CreateTranslation(a[0], 0f); - } - else if (a.Length >= 2) - { - t = Matrix3x2.CreateTranslation(a[0], a[1]); - } - - break; + t = Matrix3x2.CreateTranslation(a[0], 0f); } - - case "scale": + else if (a.Length >= 2) { - if (a.Length == 1) - { - t = Matrix3x2.CreateScale(a[0], a[0]); - } - else if (a.Length >= 2) - { - t = Matrix3x2.CreateScale(a[0], a[1]); - } - - break; + t = Matrix3x2.CreateTranslation(a[0], a[1]); } - - case "rotate": + } + else if (op.SequenceEqual("scale")) + { + if (a.Length == 1) { - if (a.Length >= 1) - { - t = Matrix3x2.CreateRotation(a[0] * (float)(Math.PI / 180.0)); - } - - break; + t = Matrix3x2.CreateScale(a[0], a[0]); } - - case "skewX": + else if (a.Length >= 2) { - if (a.Length >= 1) - { - t = new Matrix3x2(1f, 0f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 1f, 0f, 0f); - } - - break; + t = Matrix3x2.CreateScale(a[0], a[1]); } - - case "skewY": + } + else if (op.SequenceEqual("rotate")) + { + if (a.Length >= 1) { - if (a.Length >= 1) - { - t = new Matrix3x2(1f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 0f, 1f, 0f, 0f); - } - - break; + t = Matrix3x2.CreateRotation(a[0] * (float)(Math.PI / 180.0)); + } + } + else if (op.SequenceEqual("skewX")) + { + if (a.Length >= 1) + { + t = new Matrix3x2(1f, 0f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 1f, 0f, 0f); + } + } + else if (op.SequenceEqual("skewY")) + { + if (a.Length >= 1) + { + t = new Matrix3x2(1f, MathF.Tan(a[0] * (float)(Math.PI / 180.0)), 0f, 1f, 0f, 0f); } } @@ -1497,8 +1635,11 @@ private static float ParseFloat(ReadOnlySpan str) => str.IsEmpty ? 0 : float.Parse(str, CultureInfo.InvariantCulture); private static float[] ParseFloatList(string s) + => string.IsNullOrEmpty(s) ? [] : ParseFloatList(s.AsSpan()); + + private static float[] ParseFloatList(ReadOnlySpan s) { - if (string.IsNullOrEmpty(s)) + if (s.IsEmpty) { return []; } @@ -1560,7 +1701,7 @@ private static float[] ParseFloatList(string s) } } - if (float.TryParse(s.AsSpan(start, i - start), NumberStyles.Float, CultureInfo.InvariantCulture, out float v)) + if (float.TryParse(s[start..i], NumberStyles.Float, CultureInfo.InvariantCulture, out float v)) { vals.Add(v); } @@ -1571,4 +1712,21 @@ private static float[] ParseFloatList(string s) private static bool NearlyEqual(in Vector2 a, in Vector2 b, float eps = 1e-3f) => MathF.Abs(a.X - b.X) <= eps && MathF.Abs(a.Y - b.Y) <= eps; + + private sealed class ParsedDoc + { + public required XDocument Doc { get; init; } + + public required Dictionary IdMap { get; init; } + + /// + /// Gets the per-document cache of parsed geometry for reusable SVG defs. + /// + public ConcurrentDictionary> GeometryCache { get; } = new(StringComparer.Ordinal); + + /// + /// Gets the per-document cache of resolved paint servers. + /// + public ConcurrentDictionary PaintServerCache { get; } = new(StringComparer.Ordinal); + } } diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index a460eb6f..74a45de8 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -1023,7 +1023,21 @@ private static bool DoFontRun( charIndex += charsConsumed; // Get the glyph id for the codepoint and add to the collection. - _ = font.FontMetrics.TryGetGlyphId(current, next, out ushort glyphId, out skipNextCodePoint); + bool hasGlyph = font.FontMetrics.TryGetGlyphId(current, next, out ushort glyphId, out skipNextCodePoint); + + // Unsupported default-ignorable code points such as FE0F should not block + // GSUB sequences like emoji ZWJ ligatures. Preserve joiners explicitly. + if (!hasGlyph && + UnicodeUtility.IsDefaultIgnorableCodePoint((uint)current.Value) && + !UnicodeUtility.ShouldRenderWhiteSpaceOnly(current) && + !CodePoint.IsZeroWidthJoiner(current) && + !CodePoint.IsZeroWidthNonJoiner(current)) + { + codePointIndex++; + graphemeCodePointIndex++; + continue; + } + substitutions.AddGlyph(glyphId, current, (TextDirection)bidiRuns[bidiRunIndex].Direction, textRuns[textRunIndex], codePointIndex); codePointIndex++; diff --git a/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_COLRv1_-full-string-.png b/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_COLRv1_-full-string-.png new file mode 100644 index 00000000..b20547d8 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_COLRv1_-full-string-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2945fb185afcffad2f9e159de1ab1f11647fe436b9f83eeefa148ce5df4f485d +size 726823 diff --git a/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_SVG_-full-string-.png b/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_SVG_-full-string-.png new file mode 100644 index 00000000..47cca3e1 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderEmojiSanityMatrix_With_SVG_-full-string-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ce73a2d78bbd6f6b00ac030143642a90ad70c20ac74beb010063a5279d115d3 +size 726727 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-clown-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-clown-.png new file mode 100644 index 00000000..e12dc9b8 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-clown-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:79deeaea352f2020521150c49183b793d5ddd0daf115358cd3441f825f3936bb +size 27287 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-heart-on-fire-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-heart-on-fire-.png new file mode 100644 index 00000000..cb6d9122 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-heart-on-fire-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec8e0d7501ae6664b389d4c1013be40ad655dcda95fb8c8300119f7504ae45c5 +size 26799 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-leg-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-leg-.png new file mode 100644 index 00000000..e9ccac03 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-leg-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7ec12f3e60343de028a10a0270f4732c800e285731360150f55254904bc19c9d +size 15966 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-mending-heart-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-mending-heart-.png new file mode 100644 index 00000000..32c624ad --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-mending-heart-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74b930b3d9d3f7482e36455795f092f6124b718bde65f376613dcaabb92f4c5b +size 11865 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-robot-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-robot-.png new file mode 100644 index 00000000..57629b9a --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_COLRv1_-robot-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:724c12e35905246648e6609542b83a05efb685960129a082956b98db21dccf7d +size 10346 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-clown-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-clown-.png new file mode 100644 index 00000000..6de428e3 --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-clown-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1177e673f105d4b7dfd3624f4a39474ee69ce56d2e92ef857351ac06f6dd4891 +size 27336 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-heart-on-fire-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-heart-on-fire-.png new file mode 100644 index 00000000..08108d0c --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-heart-on-fire-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f10bdbfbb5784a37c1088bf3ff3b10b33f7fe94d94af984e8b3da2bfbb0d42cb +size 26833 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-leg-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-leg-.png new file mode 100644 index 00000000..3985edcd --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-leg-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:176d06c0e2b7713439f34b12452c9b694c0b089b7c36b5943468acc5ca67195b +size 15929 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-mending-heart-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-mending-heart-.png new file mode 100644 index 00000000..32c624ad --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-mending-heart-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74b930b3d9d3f7482e36455795f092f6124b718bde65f376613dcaabb92f4c5b +size 11865 diff --git a/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-robot-.png b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-robot-.png new file mode 100644 index 00000000..b5cc07ce --- /dev/null +++ b/tests/Images/ReferenceOutput/CanRenderProblemEmojiTransforms_With_SVG_-robot-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:412d83398876c453958d6e2cedfce5e204a528c222d19c3e5f3c1305ed753ed7 +size 10371 diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_462.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_462.cs index fe332e30..e3dddc78 100644 --- a/tests/SixLabors.Fonts.Tests/Issues/Issues_462.cs +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_462.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Runtime.CompilerServices; using SixLabors.Fonts.Rendering; using SixLabors.Fonts.Unicode; @@ -78,4 +79,144 @@ public void CanRenderEmojiFont_With_SVG() includeGeometry: true, customDecorations: true); } + + [Theory] + [InlineData("robot", "๐Ÿค–")] + [InlineData("clown", "๐Ÿคก")] + [InlineData("leg", "๐Ÿฆฟ")] + [InlineData("mending-heart", "โค๏ธโ€๐Ÿฉน")] + [InlineData("heart-on-fire", "โค๏ธโ€๐Ÿ”ฅ")] + public void CanRenderProblemEmojiTransforms_With_COLRv1(string name, string text) + => this.AssertCanRenderProblemEmojiTransforms(name, text, ColorFontSupport.ColrV1); + + [Theory] + [InlineData("robot", "๐Ÿค–")] + [InlineData("clown", "๐Ÿคก")] + [InlineData("leg", "๐Ÿฆฟ")] + [InlineData("mending-heart", "โค๏ธโ€๐Ÿฉน")] + [InlineData("heart-on-fire", "โค๏ธโ€๐Ÿ”ฅ")] + public void CanRenderProblemEmojiTransforms_With_SVG(string name, string text) + => this.AssertCanRenderProblemEmojiTransforms(name, text, ColorFontSupport.Svg); + + [Fact] + public void CanRenderEmojiSanityMatrix_With_COLRv1() + => this.AssertCanRenderEmojiSanityMatrix(ColorFontSupport.ColrV1); + + [Fact] + public void CanRenderEmojiSanityMatrix_With_SVG() + => this.AssertCanRenderEmojiSanityMatrix(ColorFontSupport.Svg); + + [Fact] + public void Svg_UsesDefaultBlackFillForUnspecifiedCatFaceDetails() + { + Font font = this.emoji.CreateFont(256); + + TextOptions options = new(font) + { + ColorFontSupport = ColorFontSupport.Svg, + FallbackFontFamilies = new[] { this.noto }, + }; + + LayerCaptureRenderer renderer = new(); + TextRenderer.RenderTextTo(renderer, "๐Ÿ˜ธ", options); + + Assert.Single(renderer.GlyphKeys); + Assert.True(renderer.SolidLayers.Count(x => x.Color == GlyphColor.Black && Math.Abs(x.Opacity - 1F) < 0.001F) >= 9); + } + + [Fact] + public void Svg_PropagatesUseOpacityToReferencedGeometry() + { + Font font = this.emoji.CreateFont(256); + + TextOptions options = new(font) + { + ColorFontSupport = ColorFontSupport.Svg, + FallbackFontFamilies = new[] { this.noto }, + }; + + LayerCaptureRenderer renderer = new(); + TextRenderer.RenderTextTo(renderer, "๐Ÿง", options); + + Assert.Single(renderer.GlyphKeys); + Assert.True(GlyphColor.TryParseHex("#CCCCCC", out GlyphColor monocleColor)); + Assert.Contains(renderer.SolidLayers, x => x.Color == monocleColor && Math.Abs(x.Opacity - 0.5F) < 0.001F); + } + + private void AssertCanRenderProblemEmojiTransforms( + string name, + string text, + ColorFontSupport support, + [CallerMemberName] string test = "") + { + Font font = this.emoji.CreateFont(256); + + TextOptions options = new(font) + { + ColorFontSupport = support, + FallbackFontFamilies = new[] { this.noto }, + }; + + GlyphRenderer renderer = new(); + TextRenderer.RenderTextTo(renderer, text, options); + Assert.Single(renderer.GlyphKeys); + + TextLayoutTestUtilities.TestLayout(text, options, test: test, properties: name); + } + + private void AssertCanRenderEmojiSanityMatrix( + ColorFontSupport support, + [CallerMemberName] string test = "") + { + Font font = this.emoji.CreateFont(64); + const string text = + "๐Ÿ˜€๐Ÿ˜ƒ๐Ÿ˜„๐Ÿ˜๐Ÿ˜†๐Ÿ˜…๐Ÿ˜‚๐Ÿคฃ๐Ÿ˜ญ๐Ÿ˜‰๐Ÿ˜—๐Ÿ˜™\n" + + "๐Ÿ˜š๐Ÿ˜˜๐Ÿฅฐ๐Ÿ˜๐Ÿคฉ๐Ÿฅณ๐Ÿ™ƒ๐Ÿ™‚๐Ÿฅฒ๐Ÿฅน๐Ÿ˜‹๐Ÿ˜›\n" + + "๐Ÿ˜๐Ÿ˜œ๐Ÿคช๐Ÿ˜‡๐Ÿ˜Šโ˜บ๏ธ๐Ÿ˜๐Ÿ˜Œ๐Ÿ˜”๐Ÿ˜‘๐Ÿ˜๐Ÿ˜ถ\n" + + "๐Ÿซก๐Ÿค”๐Ÿคซ๐Ÿซข๐Ÿคญ๐Ÿฅฑ๐Ÿค—๐Ÿซฃ๐Ÿ˜ฑ๐Ÿคจ๐Ÿง๐Ÿ˜’\n" + + "๐Ÿ™„๐Ÿ˜ฎโ€๐Ÿ’จ๐Ÿ˜ค๐Ÿ˜ ๐Ÿ˜ก๐Ÿคฌ๐Ÿฅบ๐Ÿ˜Ÿ๐Ÿ˜ฅ๐Ÿ˜ขโ˜น๏ธ๐Ÿ™\n" + + "๐Ÿซค๐Ÿ˜•๐Ÿค๐Ÿ˜ฐ๐Ÿ˜จ๐Ÿ˜ง๐Ÿ˜ฆ๐Ÿ˜ฎ๐Ÿ˜ฏ๐Ÿ˜ฒ๐Ÿ˜ณ๐Ÿคฏ\n" + + "๐Ÿ˜ฌ๐Ÿ˜“๐Ÿ˜ž๐Ÿ˜–๐Ÿ˜ฃ๐Ÿ˜ฉ๐Ÿ˜ซ๐Ÿ˜ต๐Ÿ˜ตโ€๐Ÿ’ซ๐Ÿซฅ๐Ÿ˜ด๐Ÿ˜ช\n" + + "๐Ÿคค๐ŸŒ›๐ŸŒœ๐ŸŒš๐ŸŒ๐ŸŒž๐Ÿซ ๐Ÿ˜ถโ€๐ŸŒซ๏ธ๐Ÿฅด๐Ÿฅต๐Ÿฅถ๐Ÿคข\n" + + "๐Ÿคฎ๐Ÿคง๐Ÿค’๐Ÿค•๐Ÿ˜ท๐Ÿค ๐Ÿค‘๐Ÿ˜Ž๐Ÿค“๐Ÿฅธ๐Ÿคฅ๐Ÿคก\n" + + "๐Ÿ‘ป๐Ÿ’ฉ๐Ÿ‘ฝ๐Ÿค–๐ŸŽƒ๐Ÿ˜ˆ๐Ÿ‘ฟ๐Ÿ‘น๐Ÿ‘บ๐Ÿ”ฅ๐Ÿ’ซโญ\n" + + "๐ŸŒŸโœจ๐Ÿ’ฅ๐Ÿ’ฏ๐Ÿ’ข๐Ÿ’จ๐Ÿ’ฆ๐Ÿซง๐Ÿ’ค๐Ÿ•ณ๏ธ๐ŸŽ‰๐ŸŽŠ\n" + + "๐Ÿ™ˆ๐Ÿ™‰๐Ÿ™Š๐Ÿ˜บ๐Ÿ˜ธ๐Ÿ˜น๐Ÿ˜ป๐Ÿ˜ผ๐Ÿ˜ฝ๐Ÿ™€๐Ÿ˜ฟ๐Ÿ˜พ\n" + + "โค๏ธ๐Ÿงก๐Ÿ’›๐Ÿ’š๐Ÿ’™๐Ÿ’œ๐ŸคŽ๐Ÿ–ค๐Ÿคโ™ฅ๏ธ๐Ÿ’˜๐Ÿ’\n" + + "๐Ÿ’–๐Ÿ’—๐Ÿ’“๐Ÿ’ž๐Ÿ’•๐Ÿ’Œ๐Ÿ’Ÿโฃ๏ธโค๏ธโ€๐Ÿฉน๐Ÿ’”โค๏ธโ€๐Ÿ”ฅ๐Ÿ’‹\n" + + "๐Ÿซ‚๐Ÿ‘ฅ๐Ÿ‘ค๐Ÿ—ฃ๏ธ๐Ÿ‘ฃ๐Ÿง ๐Ÿซ€๐Ÿซ๐Ÿฉธ๐Ÿฆ ๐Ÿฆท๐Ÿฆด\n" + + "โ˜ ๏ธ๐Ÿ’€๐Ÿ‘€๐Ÿ‘๏ธ๐Ÿ‘„๐Ÿซฆ๐Ÿ‘…๐Ÿ‘ƒ๐Ÿ‘‚๐Ÿฆป๐Ÿฆถ๐Ÿฆต\n" + + "๐Ÿฆฟ๐Ÿฆพ๐Ÿ’ช๐Ÿ‘๐Ÿ‘Ž๐Ÿ‘๐Ÿซถ๐Ÿ™Œ๐Ÿ‘๐Ÿคฒ๐Ÿค๐Ÿคœ\n" + + "๐Ÿค›โœŠ๐Ÿ‘Š๐Ÿซณ๐Ÿซด๐Ÿซฑ๐Ÿซฒ๐Ÿคš๐Ÿ‘‹๐Ÿ–๏ธโœ‹๐Ÿ––\n" + + "๐ŸคŸ๐Ÿค˜โœŒ๏ธ๐Ÿคž๐Ÿซฐ๐Ÿค™๐ŸคŒ๐Ÿค๐Ÿ‘Œ๐Ÿ–•โ˜๏ธ๐Ÿ‘†\n" + + "๐Ÿ‘‡๐Ÿ‘‰๐Ÿ‘ˆ๐Ÿซตโœ๏ธ๐Ÿคณ๐Ÿ™๐Ÿ’…"; + + TextOptions options = new(font) + { + ColorFontSupport = support, + FallbackFontFamilies = new[] { this.noto }, + LineSpacing = 1.15F, + }; + + GlyphRenderer renderer = new(); + TextRenderer.RenderTextTo(renderer, text, options); + Assert.NotEmpty(renderer.GlyphKeys); + + TextLayoutTestUtilities.TestLayout(text, options, test: test, properties: "full-string"); + } + + private sealed class LayerCaptureRenderer : GlyphRenderer + { + public List<(GlyphColor Color, float Opacity)> SolidLayers { get; } = []; + + public override void BeginLayer(Paint paint, FillRule fillRule, ClipQuad? clipBounds) + { + if (paint is SolidPaint solidPaint) + { + this.SolidLayers.Add((solidPaint.Color, solidPaint.Opacity)); + } + + base.BeginLayer(paint, fillRule, clipBounds); + } + } }