diff --git a/src/SixLabors.Fonts/Rendering/Paint.cs b/src/SixLabors.Fonts/Rendering/Paint.cs index 04ee4792f..38b02bd7a 100644 --- a/src/SixLabors.Fonts/Rendering/Paint.cs +++ b/src/SixLabors.Fonts/Rendering/Paint.cs @@ -19,10 +19,11 @@ public abstract class Paint public float Opacity { get; init; } = 1f; /// - /// Gets or sets an optional transform to apply to the paint. - /// Used to pre-apply gradientTransform in SVG or equivalent. + /// Gets the transform from the paint's own coordinate space to the space the glyph is + /// rendered in. Gradient geometry is defined in the paint's space, so a renderer maps each + /// sample through the inverse of this transform before evaluating the gradient. /// - internal Matrix3x2 Transform { get; set; } + public Matrix3x2 Transform { get; init; } = Matrix3x2.Identity; /// /// Gets the composite mode to use when applying this paint over existing content. diff --git a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs index f8459dedf..a5f56099d 100644 --- a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs +++ b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs @@ -398,20 +398,21 @@ private static void StreamCompositeCommands( } /// - /// Converts a into device-space geometry for the target layer, - /// removing (baking in) any paint-local transforms. Geometry path commands have already - /// been transformed elsewhere; this method only resolves paint geometry (start/end points, - /// centers, radii, angles) into device space so the renderer can construct brushes directly. + /// Resolves a for the target layer. Geometry path commands have already + /// been transformed elsewhere; this method only resolves the paint geometry so the renderer + /// can construct brushes directly. /// /// Rules: /// - /// UserSpaceOnUse: Apply in user space, then apply - /// to obtain device-space positions. Emit device-space values. - /// ObjectBoundingBox: Apply in normalized [0..1] box space, - /// then denormalize to device space using . Emit device-space values. + /// Linear gradients: the points are transformed into device space, applying + /// in user space or in normalized [0..1] box space first, then + /// , with denormalizing box space. + /// The returned paint has an identity . + /// Radial and sweep gradients: the centers, radii and angles stay in the + /// paint's own space, flipped to y-down, and the returned maps + /// that space to device space. A skew or a non-uniform scale in the paint's transform + /// therefore reaches the renderer instead of being reduced to a circle. /// Color stops (ratios) remain normalized in [0..1] and are passed through unchanged. - /// All returned paints have identity and are suitable for direct - /// consumption by Drawing brushes (e.g. LinearGradientBrush expects device-space points). /// /// /// @@ -423,7 +424,7 @@ private static void StreamCompositeCommands( /// using instead. /// /// - /// A paint expressed in device-space with identity transform, or + /// A paint with geometry, or /// if the input was . /// private static Paint? TransformPaint( @@ -485,107 +486,36 @@ private static void StreamCompositeCommands( case RadialGradientPaint rg: { - Vector2 c0; - Vector2 c1; - float r0; - float r1; - - if (rg.Units == GradientUnits.UserSpaceOnUse) - { - // USOU: transform directly to device space. - Matrix3x2 paintXForm = rg.Transform * layerXform; - - // Centers get full layer transform. - c0 = Vector2.Transform(rg.Center0, paintXForm); - c1 = Vector2.Transform(rg.Center1, paintXForm); - - // Radii scale by uniform similarity only. - Similarity compSim = Similarity.FromMatrix(paintXForm); - r0 = rg.Radius0 * compSim.Scale; - r1 = rg.Radius1 * compSim.Scale; - } - else - { - // OBB: transform in normalized [0..1] space, then denormalize to device via layer bounds. - Vector2 nc0 = Vector2.Transform(rg.Center0, rg.Transform); - Vector2 nc1 = Vector2.Transform(rg.Center1, rg.Transform); - - c0 = Vector2.Transform(DenormalizePoint(nc0, layerBounds), layerXform); - c1 = Vector2.Transform(DenormalizePoint(nc1, layerBounds), layerXform); - - // Radii scale by total similarity (paint * layer). - Matrix3x2 paintXForm = rg.Transform * layerXform; - Similarity compSim = Similarity.FromMatrix(paintXForm); - r0 = rg.Radius0 * compSim.Scale; - r1 = rg.Radius1 * compSim.Scale; - } - return new RadialGradientPaint { Units = GradientUnits.UserSpaceOnUse, - Center0 = c0, - Radius0 = r0, - Center1 = c1, - Radius1 = r1, + Center0 = FlipY(rg.Center0), + Radius0 = rg.Radius0, + Center1 = FlipY(rg.Center1), + Radius1 = rg.Radius1, Spread = rg.Spread, Stops = rg.Stops, Opacity = rg.Opacity, CompositeMode = rg.CompositeMode, - Transform = Matrix3x2.Identity + Transform = GetPaintTransform(rg.Transform, rg.Units, in layerBounds, layerXform) }; } case SweepGradientPaint sg: { - Vector2 center; - float start = sg.StartAngle; - float end = sg.EndAngle; - - if (sg.Units == GradientUnits.UserSpaceOnUse) - { - // USOU: transform directly to device space. - Matrix3x2 paintXForm = sg.Transform * layerXform; - - // Center gets full layer transform. - center = Vector2.Transform(sg.Center, paintXForm); - - // Angles adjust by similarity rotation and reflection only. - Similarity compSim = Similarity.FromMatrix(paintXForm); - start += compSim.RotationDegrees; - end += compSim.RotationDegrees; - if (compSim.Reflection) - { - (start, end) = (end, start); - } - } - else - { - // OBB: transform in normalized [0..1] space, then denormalize to device via layer bounds. - Vector2 nc = Vector2.Transform(sg.Center, sg.Transform); - center = Vector2.Transform(DenormalizePoint(nc, layerBounds), layerXform); - - // Angles adjust by total similarity (paint * layer). - Matrix3x2 paintXForm = sg.Transform * layerXform; - Similarity compSim = Similarity.FromMatrix(paintXForm); - start += compSim.RotationDegrees; - end += compSim.RotationDegrees; - if (compSim.Reflection) - { - (start, end) = (end, start); - } - } - + // The paint space is y-down like the renderer's, so the counter-clockwise angles + // keep their direction and the transform carries any rotation or reflection. return new SweepGradientPaint { Units = GradientUnits.UserSpaceOnUse, - Center = center, - StartAngle = start, - EndAngle = end, + Center = FlipY(sg.Center), + StartAngle = sg.StartAngle, + EndAngle = sg.EndAngle, Spread = sg.Spread, Stops = sg.Stops, Opacity = sg.Opacity, CompositeMode = sg.CompositeMode, - Transform = Matrix3x2.Identity + Transform = GetPaintTransform(sg.Transform, sg.Units, in layerBounds, layerXform) }; } @@ -597,6 +527,33 @@ private static void StreamCompositeCommands( static Vector2 DenormalizePoint(Vector2 p, in FontRectangle bounds) => new(bounds.X + (p.X * bounds.Width), bounds.Y + (p.Y * bounds.Height)); + + static Vector2 FlipY(Vector2 p) => new(p.X, -p.Y); + } + + /// + /// Builds the transform from a gradient paint's y-down space to device space. + /// + /// The paint's own transform in its y-up space. + /// The coordinate system of the paint geometry. + /// The layer bounds that normalized geometry maps through. + /// The transform from the layer to device space. + /// The transform from the paint's y-down space to device space. + private static Matrix3x2 GetPaintTransform( + Matrix3x2 paintTransform, + GradientUnits units, + in FontRectangle layerBounds, + Matrix3x2 layerXform) + { + // The flip is its own inverse: it takes the y-down paint geometry back to the y-up + // space the paint's own transform is defined in. + Matrix3x2 transform = Matrix3x2.CreateScale(1F, -1F) * paintTransform; + if (units == GradientUnits.ObjectBoundingBox) + { + transform *= Matrix3x2.CreateScale(layerBounds.Width, layerBounds.Height) * Matrix3x2.CreateTranslation(layerBounds.X, layerBounds.Y); + } + + return transform * layerXform; } /// diff --git a/src/SixLabors.Fonts/ShapingBuffer.cs b/src/SixLabors.Fonts/ShapingBuffer.cs index 09943ab7b..4f7a7ba2c 100644 --- a/src/SixLabors.Fonts/ShapingBuffer.cs +++ b/src/SixLabors.Fonts/ShapingBuffer.cs @@ -2468,11 +2468,19 @@ private void CopyGlyphsMoved(int count) /// Moves the pass position so that the given number of records sit on the /// output side. Advancing streams records forward; rewinding returns produced /// records to the input side ahead of the read cursor, exactly reversing the - /// stream. + /// stream. Outside a pass the sides are one array, so both cursors go + /// straight to the position. /// /// The output-side record count to move to. public void MoveTo(int outputPosition) { + if (!this.IsPassActive) + { + this.ReadIndex = outputPosition; + this.PassOutputCount = outputPosition; + return; + } + if (this.PassOutputCount < outputPosition && this.ReadIndex < this.Count) { int count = Math.Min(outputPosition - this.PassOutputCount, this.Count - this.ReadIndex); diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs index 2b0e33db9..a5928c301 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs @@ -277,6 +277,7 @@ private static int FixupMatchPositions(Span matchPositions, int matchCount, /// The buffer positions the input sequence matched at. /// The number of matched positions. /// The number of glyphs in the input sequence. + /// The position one past the final matched input record. /// if the lookups were applied. public static bool ApplyLookupList( FontMetrics fontMetrics, @@ -286,7 +287,8 @@ public static bool ApplyLookupList( ShapingBuffer buffer, ReadOnlySpan matchPositions, int matchCount, - int count) + int count, + int matchEnd) { if (buffer.NestingLimitReached) { @@ -296,6 +298,7 @@ public static bool ApplyLookupList( // Positioning never changes the buffer's length, so the matched // positions stand for the whole record list. int startIndex = matchPositions[0]; + int end = buffer.PassBacktrackLength + (matchEnd - buffer.ReadIndex); buffer.PushNestedApplication(); foreach (SequenceLookupRecord lookupRecord in records) @@ -312,6 +315,9 @@ public static bool ApplyLookupList( } buffer.PopNestedApplication(); + + // Everything the rule matched is now behind the cursor. + buffer.MoveTo(end); return true; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs index b2df7ae14..d24953185 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs @@ -45,19 +45,22 @@ internal sealed class LookupType2Format1SubTable : LookupSubTable { private readonly CoverageTable coverageTable; private readonly PairSetTable[] pairSets; + private readonly ValueFormat valueFormat2; /// /// Initializes a new instance of the class. /// /// The coverage table. /// The array of pair set tables. + /// The value format of the second glyph's record. /// The lookup qualifiers. /// The mark filtering set index. - public LookupType2Format1SubTable(CoverageTable coverageTable, PairSetTable[] pairSets, LookupFlags lookupFlags, ushort markFilteringSet) + public LookupType2Format1SubTable(CoverageTable coverageTable, PairSetTable[] pairSets, ValueFormat valueFormat2, LookupFlags lookupFlags, ushort markFilteringSet) : base(lookupFlags, markFilteringSet) { this.coverageTable = coverageTable; this.pairSets = pairSets; + this.valueFormat2 = valueFormat2; } /// @@ -110,7 +113,7 @@ public static LookupType2Format1SubTable Load(BigEndianBinaryReader reader, long CoverageTable coverageTable = CoverageTable.Load(reader, offset + coverageOffset); - return new LookupType2Format1SubTable(coverageTable, pairSets, lookupFlags, markFilteringSet); + return new LookupType2Format1SubTable(coverageTable, pairSets, valueFormat2, lookupFlags, markFilteringSet); } /// @@ -165,6 +168,10 @@ public override bool TryUpdatePosition( ValueRecord record2 = pairValueRecord.ValueRecord2; AdvancedTypographicUtils.ApplyPosition(fontMetrics, buffer, secondIndex, record2, feature); + // With no second value record the second glyph is the next + // glyph a lookup is performed for; with one, the pair is + // closed and the walk resumes after it. + buffer.MoveTo(this.valueFormat2 == 0 ? secondIndex : secondIndex + 1); return true; } } @@ -262,6 +269,7 @@ internal sealed class LookupType2Format2SubTable : LookupSubTable private readonly Class1Record[] class1Records; private readonly ClassDefinitionTable classDefinitionTable1; private readonly ClassDefinitionTable classDefinitionTable2; + private readonly ValueFormat valueFormat2; /// /// Initializes a new instance of the class. @@ -270,6 +278,7 @@ internal sealed class LookupType2Format2SubTable : LookupSubTable /// The array of Class1 records. /// The class definition table for the first glyph. /// The class definition table for the second glyph. + /// The value format of the second glyph's record. /// The lookup qualifiers. /// The mark filtering set index. public LookupType2Format2SubTable( @@ -277,6 +286,7 @@ public LookupType2Format2SubTable( Class1Record[] class1Records, ClassDefinitionTable classDefinitionTable1, ClassDefinitionTable classDefinitionTable2, + ValueFormat valueFormat2, LookupFlags lookupFlags, ushort markFilteringSet) : base(lookupFlags, markFilteringSet) @@ -285,6 +295,7 @@ public LookupType2Format2SubTable( this.class1Records = class1Records; this.classDefinitionTable1 = classDefinitionTable1; this.classDefinitionTable2 = classDefinitionTable2; + this.valueFormat2 = valueFormat2; } /// @@ -347,7 +358,7 @@ public static LookupType2Format2SubTable Load(BigEndianBinaryReader reader, long ClassDefinitionTable classDefTable1 = ClassDefinitionTable.Load(reader, offset + classDef1Offset); ClassDefinitionTable classDefTable2 = ClassDefinitionTable.Load(reader, offset + classDef2Offset); - return new LookupType2Format2SubTable(coverageTable, class1Records, classDefTable1, classDefTable2, lookupFlags, markFilteringSet); + return new LookupType2Format2SubTable(coverageTable, class1Records, classDefTable1, classDefTable2, valueFormat2, lookupFlags, markFilteringSet); } /// @@ -405,6 +416,10 @@ public override bool TryUpdatePosition( ValueRecord record2 = class2Record.ValueRecord2; AdvancedTypographicUtils.ApplyPosition(fontMetrics, buffer, secondIndex, record2, feature); + // With no second value record the second glyph is the next glyph + // a lookup is performed for; with one, the pair is closed and the + // walk resumes after it. + buffer.MoveTo(this.valueFormat2 == 0 ? secondIndex : secondIndex + 1); return true; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs index 6560bbb0c..f5f361b90 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs @@ -116,7 +116,7 @@ public override bool TryUpdatePosition( continue; } - if (!AdvancedTypographicUtils.MatchSequence(iterator, 1, ruleTable.InputSequence, buffer.LookupMask, false, matchPositions[1..], out _)) + if (!AdvancedTypographicUtils.MatchSequence(iterator, 1, ruleTable.InputSequence, buffer.LookupMask, false, matchPositions[1..], out int matchEnd)) { continue; } @@ -130,7 +130,8 @@ public override bool TryUpdatePosition( buffer, matchPositions, ruleTable.InputSequence.Length + 1, - count); + count, + matchEnd); } return false; @@ -232,7 +233,7 @@ public override bool TryUpdatePosition( continue; } - if (!AdvancedTypographicUtils.MatchClassSequence(iterator, 1, ruleTable.InputSequence, this.classDefinitionTable, buffer.LookupMask, false, matchPositions[1..], out _)) + if (!AdvancedTypographicUtils.MatchClassSequence(iterator, 1, ruleTable.InputSequence, this.classDefinitionTable, buffer.LookupMask, false, matchPositions[1..], out int matchEnd)) { continue; } @@ -246,7 +247,8 @@ public override bool TryUpdatePosition( buffer, matchPositions, ruleTable.InputSequence.Length + 1, - count); + count, + matchEnd); } return false; @@ -329,7 +331,7 @@ public override bool TryUpdatePosition( // address. SkippingGlyphIterator iterator = new(fontMetrics, buffer, index, this.LookupFlags, this.MarkFilteringSet); Span matchPositions = buffer.GetContextMatchPositions()[..AdvancedTypographicUtils.MaxContextLength]; - if (!AdvancedTypographicUtils.MatchCoverageSequence(iterator, this.coverageTables, index, index + count, buffer.LookupMask, false, matchPositions, out _)) + if (!AdvancedTypographicUtils.MatchCoverageSequence(iterator, this.coverageTables, index, index + count, buffer.LookupMask, false, matchPositions, out int matchEnd)) { return false; } @@ -342,7 +344,8 @@ public override bool TryUpdatePosition( buffer, matchPositions, this.coverageTables.Length, - count); + count, + matchEnd); } } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs index e003d9640..5df44132d 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs @@ -126,37 +126,21 @@ public override bool TryUpdatePosition( for (int lookupIndex = 0; lookupIndex < rules.Length; lookupIndex++) { ChainedSequenceRuleTable rule = rules[lookupIndex]; - if (!AdvancedTypographicUtils.ApplyChainedSequenceRule(iterator, rule, buffer.LookupMask, matchPositions[1..], out _)) + if (!AdvancedTypographicUtils.ApplyChainedSequenceRule(iterator, rule, buffer.LookupMask, matchPositions[1..], out int matchEnd)) { continue; } - if (buffer.NestingLimitReached) - { - return false; - } - - bool hasChanged = false; - int matchCount = rule.InputSequence.Length + 1; - buffer.PushNestedApplication(); - for (int j = 0; j < rule.SequenceLookupRecords.Length; j++) - { - SequenceLookupRecord sequenceLookupRecord = rule.SequenceLookupRecords[j]; - LookupTable lookup = table.LookupList.LookupTables[sequenceLookupRecord.LookupListIndex]; - int sequenceIndex = sequenceLookupRecord.SequenceIndex; - if (sequenceIndex >= matchCount) - { - continue; - } - - if (lookup.TryUpdatePosition(fontMetrics, table, buffer, feature, matchPositions[sequenceIndex], 1)) - { - hasChanged = true; - } - } - - buffer.PopNestedApplication(); - return hasChanged; + return AdvancedTypographicUtils.ApplyLookupList( + fontMetrics, + table, + feature, + rule.SequenceLookupRecords, + buffer, + matchPositions, + rule.InputSequence.Length + 1, + count, + matchEnd); } return false; @@ -283,38 +267,22 @@ public override bool TryUpdatePosition( { ChainedClassSequenceRuleTable rule = rules[lookupIndex]; - if (!AdvancedTypographicUtils.ApplyChainedClassSequenceRule(iterator, rule, this.inputClassDefinitionTable, this.backtrackClassDefinitionTable, this.lookaheadClassDefinitionTable, buffer.LookupMask, matchPositions[1..], out _)) + if (!AdvancedTypographicUtils.ApplyChainedClassSequenceRule(iterator, rule, this.inputClassDefinitionTable, this.backtrackClassDefinitionTable, this.lookaheadClassDefinitionTable, buffer.LookupMask, matchPositions[1..], out int matchEnd)) { continue; } // It's a match. Perform position update and return true if anything changed. - if (buffer.NestingLimitReached) - { - return false; - } - - bool hasChanged = false; - int matchCount = rule.InputSequence.Length + 1; - buffer.PushNestedApplication(); - for (int j = 0; j < rule.SequenceLookupRecords.Length; j++) - { - SequenceLookupRecord sequenceLookupRecord = rule.SequenceLookupRecords[j]; - LookupTable lookup = table.LookupList.LookupTables[sequenceLookupRecord.LookupListIndex]; - int sequenceIndex = sequenceLookupRecord.SequenceIndex; - if (sequenceIndex >= matchCount) - { - continue; - } - - if (lookup.TryUpdatePosition(fontMetrics, table, buffer, feature, matchPositions[sequenceIndex], 1)) - { - hasChanged = true; - } - } - - buffer.PopNestedApplication(); - return hasChanged; + return AdvancedTypographicUtils.ApplyLookupList( + fontMetrics, + table, + feature, + rule.SequenceLookupRecords, + buffer, + matchPositions, + rule.InputSequence.Length + 1, + count, + matchEnd); } return false; @@ -414,41 +382,22 @@ public override bool TryUpdatePosition( // first glyph, so the match fills every position nested lookups // address. Span matchPositions = buffer.GetContextMatchPositions()[..AdvancedTypographicUtils.MaxContextLength]; - if (!AdvancedTypographicUtils.CheckAllCoverages(fontMetrics, this.LookupFlags, this.MarkFilteringSet, buffer, index, count, this.inputCoverageTables, this.backtrackCoverageTables, this.lookaheadCoverageTables, buffer.LookupMask, matchPositions, out _)) - { - return false; - } - - if (buffer.NestingLimitReached) + if (!AdvancedTypographicUtils.CheckAllCoverages(fontMetrics, this.LookupFlags, this.MarkFilteringSet, buffer, index, count, this.inputCoverageTables, this.backtrackCoverageTables, this.lookaheadCoverageTables, buffer.LookupMask, matchPositions, out int matchEnd)) { return false; } // It's a match. Perform position update and return true if anything changed. - bool hasChanged = false; - int matchCount = this.inputCoverageTables.Length; - buffer.PushNestedApplication(); - - foreach (SequenceLookupRecord lookupRecord in this.seqLookupRecords) - { - int sequenceIndex = lookupRecord.SequenceIndex; - if (sequenceIndex >= matchCount) - { - continue; - } - - ushort lookupIndex = lookupRecord.LookupListIndex; - int position = matchPositions[sequenceIndex]; - - LookupTable lookup = table.LookupList.LookupTables[lookupIndex]; - if (lookup.TryUpdatePosition(fontMetrics, table, buffer, feature, position, count - (position - index))) - { - hasChanged = true; - } - } - - buffer.PopNestedApplication(); - return hasChanged; + return AdvancedTypographicUtils.ApplyLookupList( + fontMetrics, + table, + feature, + this.seqLookupRecords, + buffer, + matchPositions, + this.inputCoverageTables.Length, + count, + matchEnd); } } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs index 3459a58f1..af988eab5 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs @@ -346,7 +346,12 @@ private bool PositionSegment( buffer.SetLookupMatchState(featureMask, autoZwnj, autoZwj, random, perSyllable); iterator.Reset(index, featureLookupTable.LookupFlags, featureLookupTable.MarkFilteringSet); - while (iterator.Index < segmentEnd) + // The cursor walks the segment and each lookup leaves it where the + // records it consumed end: after a matched context's input, at or + // past a pair's second glyph. A lookup that leaves it in place + // consumed nothing, so the walk steps one record. + buffer.MoveTo(index); + while (buffer.ReadIndex < segmentEnd) { if (currentOperations++ >= maxOperationsCount) { @@ -356,18 +361,26 @@ private bool PositionSegment( // The digest cheaply rejects glyphs no subtable of this // lookup can affect; a maybe falls through to the exact - // coverage test inside. - ref GlyphShapingData glyphData = ref buffer[iterator.Index]; - if ((glyphData.FeatureMask & featureMask) == 0 || !featureLookupTable.Digest.MightContain(glyphData.GlyphId)) + // coverage test inside. A record the lookup's flags ignore + // is never applied at. + int position = buffer.ReadIndex; + ref GlyphShapingData glyphData = ref buffer[position]; + if ((glyphData.FeatureMask & featureMask) == 0 + || !featureLookupTable.Digest.MightContain(glyphData.GlyphId) + || iterator.IsIgnored(position)) { - iterator.Next(); + buffer.CopyGlyph(); continue; } - bool success = featureLookupTable.TryUpdatePosition(fontMetrics, this, buffer, feature, iterator.Index, segmentEnd - iterator.Index); + bool success = featureLookupTable.TryUpdatePosition(fontMetrics, this, buffer, feature, position, segmentEnd - position); kerned |= success && (feature == KernTag || feature == VKernTag); updated |= success; - iterator.Next(); + + if (buffer.ReadIndex == position) + { + buffer.CopyGlyph(); + } } } diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs b/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs index 50083b1e0..26b3d0bec 100644 --- a/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs +++ b/src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs @@ -29,7 +29,15 @@ internal sealed class Cff1Table : Table, ICffTable /// public CffGlyphData GetGlyph(int index) - => this.glyphs[index]; + { + if ((uint)index >= (uint)this.glyphs.Length) + { + // A glyph id past the table's glyphs has no charstring. + return new CffGlyphData((ushort)index, [], [], 0, [], 1); + } + + return this.glyphs[index]; + } /// /// Loads the CFF1 table from the specified font reader. diff --git a/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs b/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs index 6c810c998..f20ac72e7 100644 --- a/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs +++ b/src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs @@ -37,7 +37,15 @@ public Cff2Table(CffFont cffFont, ItemVariationStore itemVariationStore) /// public CffGlyphData GetGlyph(int index) - => this.glyphs[index]; + { + if ((uint)index >= (uint)this.glyphs.Length) + { + // A glyph id past the table's glyphs has no charstring. + return new CffGlyphData((ushort)index, [], [], 0, [], 2, this.ItemVariationStore); + } + + return this.glyphs[index]; + } /// /// Loads the CFF2 table from the specified font reader. diff --git a/src/SixLabors.Fonts/Tables/Cff/CffBoundsFinder.cs b/src/SixLabors.Fonts/Tables/Cff/CffBoundsFinder.cs index 9f6ef9d63..522514ac7 100644 --- a/src/SixLabors.Fonts/Tables/Cff/CffBoundsFinder.cs +++ b/src/SixLabors.Fonts/Tables/Cff/CffBoundsFinder.cs @@ -236,13 +236,21 @@ private void UpdateMinMax(float x0, float y0) } /// - /// Gets the computed bounding box from all tracked path coordinates. + /// Gets the computed bounding box from all tracked path coordinates, or an empty + /// box when the charstring drew nothing. /// /// The representing the glyph bounding box. public Bounds GetBounds() - => new( + { + if (this.firstEval) + { + return Bounds.Empty; + } + + return new( (short)Math.Floor(this.minX), (short)Math.Floor(this.minY), (short)Math.Ceiling(this.maxX), (short)Math.Ceiling(this.maxY)); + } } diff --git a/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs b/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs index 0e2a7e454..19d6ddf7d 100644 --- a/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs +++ b/src/SixLabors.Fonts/Tables/Cff/ICffTable.cs @@ -28,7 +28,8 @@ public ItemVariationStore? ItemVariationStore } /// - /// Gets the glyph data at the given index. + /// Gets the glyph data at the given index. An index past the table's glyphs + /// gets a glyph with no charstring. /// /// The glyph index. /// The . diff --git a/src/SixLabors.Fonts/Tables/General/CMap/Format10SubTable.cs b/src/SixLabors.Fonts/Tables/General/CMap/Format10SubTable.cs new file mode 100644 index 000000000..6c258c193 --- /dev/null +++ b/src/SixLabors.Fonts/Tables/General/CMap/Format10SubTable.cs @@ -0,0 +1,111 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Unicode; +using SixLabors.Fonts.WellKnownIds; + +namespace SixLabors.Fonts.Tables.General.CMap; + +/// +/// Format 10 is a trimmed array subtable that maps a contiguous range of 32-bit character codes to glyph indices. +/// +/// +internal sealed class Format10SubTable : CMapSubTable +{ + /// + /// Initializes a new instance of the class. + /// + /// The language code for this subtable. + /// The platform identifier. + /// The platform-specific encoding identifier. + /// The first character code of the range. + /// The array of glyph indices for the character codes in the range. + public Format10SubTable(uint language, PlatformIDs platform, ushort encoding, uint startCharCode, ushort[] glyphIds) + : base(platform, encoding, 10) + { + this.Language = language; + this.StartCharCode = startCharCode; + this.GlyphIds = glyphIds; + } + + /// + /// Gets the language code for this subtable. + /// + public uint Language { get; } + + /// + /// Gets the first character code of the range. + /// + public uint StartCharCode { get; } + + /// + /// Gets the array of glyph indices for the character codes in the range. + /// + public ushort[] GlyphIds { get; } + + /// + public override bool TryGetGlyphId(CodePoint codePoint, out ushort glyphId) + { + long index = codePoint.Value - this.StartCharCode; + if (index < 0 || index >= this.GlyphIds.Length) + { + glyphId = 0; + return false; + } + + glyphId = this.GlyphIds[index]; + return glyphId != 0; + } + + /// + public override bool TryGetCodePoint(ushort glyphId, out CodePoint codePoint) + { + for (int i = 0; i < this.GlyphIds.Length; i++) + { + if (this.GlyphIds[i] == glyphId) + { + codePoint = new CodePoint((int)(this.StartCharCode + i)); + return true; + } + } + + codePoint = default; + return false; + } + + /// + public override IEnumerable GetAvailableCodePoints() + => Enumerable.Range((int)this.StartCharCode, this.GlyphIds.Length); + + /// + /// Loads one or more instances from the specified encoding records and reader. + /// + /// The encoding records that share this subtable. + /// The binary reader positioned after the format field. + /// An enumerable of instances, one per encoding record. + public static IEnumerable Load(IEnumerable encodings, BigEndianBinaryReader reader) + { + // 'cmap' Subtable Format 10: + // Type | Name | Description + // -------|------------------------|----------------------------------------------------------- + // uint16 | format | Subtable format; set to 10. + // uint16 | reserved | Reserved; set to 0. + // uint32 | length | Byte length of this subtable (including the header). + // uint32 | language | Please see "Note on the language field in 'cmap' subtables" in this document. + // uint32 | startCharCode | First character code covered. + // uint32 | numChars | Number of character codes covered. + // uint16 | glyphIdArray[numChars] | Array of glyph indices for the character codes covered. + // format has already been read by this point skip it + ushort reserved = reader.ReadUInt16(); + uint length = reader.ReadUInt32(); + uint language = reader.ReadUInt32(); + uint startCharCode = reader.ReadUInt32(); + uint numChars = reader.ReadUInt32(); + ushort[] glyphIds = reader.ReadUInt16Array((int)numChars); + + foreach (EncodingRecord encoding in encodings) + { + yield return new Format10SubTable(language, encoding.PlatformID, encoding.EncodingID, startCharCode, glyphIds); + } + } +} diff --git a/src/SixLabors.Fonts/Tables/General/CMap/Format14SubTable.cs b/src/SixLabors.Fonts/Tables/General/CMap/Format14SubTable.cs index bd5fae06c..1440dcf9a 100644 --- a/src/SixLabors.Fonts/Tables/General/CMap/Format14SubTable.cs +++ b/src/SixLabors.Fonts/Tables/General/CMap/Format14SubTable.cs @@ -183,10 +183,11 @@ public ushort CharacterPairToGlyphId(CodePoint codePoint, ushort defaultGlyphInd return ret; } - // If the sequence is a default UVS, return the default glyph + // If the sequence is a default UVS, return the default glyph. A range covers its + // start value plus the additional count, so the end value is inclusive. for (int i = 0; i < sel.DefaultStartCodes.Count; ++i) { - if (codePoint.Value >= sel.DefaultStartCodes[i] && codePoint.Value < sel.DefaultEndCodes[i]) + if (codePoint.Value >= sel.DefaultStartCodes[i] && codePoint.Value <= sel.DefaultEndCodes[i]) { return defaultGlyphIndex; } diff --git a/src/SixLabors.Fonts/Tables/General/CMap/Format4SubTable.cs b/src/SixLabors.Fonts/Tables/General/CMap/Format4SubTable.cs index d2195ac3a..a4892efc4 100644 --- a/src/SixLabors.Fonts/Tables/General/CMap/Format4SubTable.cs +++ b/src/SixLabors.Fonts/Tables/General/CMap/Format4SubTable.cs @@ -54,23 +54,36 @@ public override bool TryGetGlyphId(CodePoint codePoint, out ushort glyphId) if (seg.End >= charAsInt && seg.Start <= charAsInt) { + int gid; if (seg.Offset == 0) { - glyphId = (ushort)((charAsInt + seg.Delta) & ushort.MaxValue); - return true; + gid = charAsInt + seg.Delta; } + else + { + long offset = (seg.Offset / 2) + (charAsInt - seg.Start); + long idx = offset - this.Segments.Length + seg.Index; + + if (idx < 0 || idx >= this.GlyphIds.Length) + { + glyphId = 0; + return false; + } - long offset = (seg.Offset / 2) + (charAsInt - seg.Start); - long idx = offset - this.Segments.Length + seg.Index; + // A zero in the array is the missing glyph; the delta applies + // only to the other values. + gid = this.GlyphIds[idx]; + if (gid == 0) + { + glyphId = 0; + return false; + } - if (idx < 0 || idx >= this.GlyphIds.Length) - { - glyphId = 0; - return false; + gid += seg.Delta; } - glyphId = this.GlyphIds[idx]; - return true; + glyphId = (ushort)(gid & ushort.MaxValue); + return glyphId != 0; } } @@ -103,7 +116,7 @@ public override bool TryGetCodePoint(ushort glyphId, out CodePoint codePoint) // Reverse the offset-based calculation: // Forward logic: // offset = (seg.Offset / 2) + (charAsInt - seg.Start) - // glyphId = GlyphIds[offset - Segments.Length + seg.Index] + // glyphId = (GlyphIds[offset - Segments.Length + seg.Index] + seg.Delta) & 0xFFFF, zero staying zero // To reverse, iterate over possible codepoints in the segment and find the matching glyphId. for (long j = 0; j <= (seg.End - seg.Start); j++) @@ -117,7 +130,8 @@ public override bool TryGetCodePoint(ushort glyphId, out CodePoint codePoint) return false; } - if (this.GlyphIds[idx] == glyphId) + ushort value = this.GlyphIds[idx]; + if (value != 0 && ((value + seg.Delta) & ushort.MaxValue) == glyphId) { codePoint = new CodePoint((int)(seg.Start + j)); return true; diff --git a/src/SixLabors.Fonts/Tables/General/CMap/Format6SubTable.cs b/src/SixLabors.Fonts/Tables/General/CMap/Format6SubTable.cs new file mode 100644 index 000000000..fbe884173 --- /dev/null +++ b/src/SixLabors.Fonts/Tables/General/CMap/Format6SubTable.cs @@ -0,0 +1,109 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Unicode; +using SixLabors.Fonts.WellKnownIds; + +namespace SixLabors.Fonts.Tables.General.CMap; + +/// +/// Format 6 is a trimmed table mapping subtable that maps a contiguous range of 16-bit character codes to glyph indices. +/// +/// +internal sealed class Format6SubTable : CMapSubTable +{ + /// + /// Initializes a new instance of the class. + /// + /// The language code for Macintosh platform subtables. + /// The platform identifier. + /// The platform-specific encoding identifier. + /// The first character code of the range. + /// The array of glyph indices for the character codes in the range. + public Format6SubTable(ushort language, PlatformIDs platform, ushort encoding, ushort firstCode, ushort[] glyphIds) + : base(platform, encoding, 6) + { + this.Language = language; + this.FirstCode = firstCode; + this.GlyphIds = glyphIds; + } + + /// + /// Gets the language code for Macintosh platform subtables. + /// + public ushort Language { get; } + + /// + /// Gets the first character code of the range. + /// + public ushort FirstCode { get; } + + /// + /// Gets the array of glyph indices for the character codes in the range. + /// + public ushort[] GlyphIds { get; } + + /// + public override bool TryGetGlyphId(CodePoint codePoint, out ushort glyphId) + { + int index = codePoint.Value - this.FirstCode; + if (index < 0 || index >= this.GlyphIds.Length) + { + glyphId = 0; + return false; + } + + glyphId = this.GlyphIds[index]; + return glyphId != 0; + } + + /// + public override bool TryGetCodePoint(ushort glyphId, out CodePoint codePoint) + { + for (int i = 0; i < this.GlyphIds.Length; i++) + { + if (this.GlyphIds[i] == glyphId) + { + codePoint = new CodePoint(this.FirstCode + i); + return true; + } + } + + codePoint = default; + return false; + } + + /// + public override IEnumerable GetAvailableCodePoints() + => Enumerable.Range(this.FirstCode, this.GlyphIds.Length); + + /// + /// Loads one or more instances from the specified encoding records and reader. + /// + /// The encoding records that share this subtable. + /// The binary reader positioned after the format field. + /// An enumerable of instances, one per encoding record. + public static IEnumerable Load(IEnumerable encodings, BigEndianBinaryReader reader) + { + // 'cmap' Subtable Format 6: + // Type | Name | Description + // -------|--------------------------|------------------------------------------------------------------------ + // uint16 | format | Format number is set to 6. + // uint16 | length | This is the length in bytes of the subtable. + // uint16 | language | Please see "Note on the language field in 'cmap' subtables" in this document. + // uint16 | firstCode | First character code of subrange. + // uint16 | entryCount | Number of character codes in subrange. + // uint16 | glyphIdArray[entryCount] | Array of glyph index values for character codes in the range. + // format has already been read by this point skip it + ushort length = reader.ReadUInt16(); + ushort language = reader.ReadUInt16(); + ushort firstCode = reader.ReadUInt16(); + ushort entryCount = reader.ReadUInt16(); + ushort[] glyphIds = reader.ReadUInt16Array(entryCount); + + foreach (EncodingRecord encoding in encodings) + { + yield return new Format6SubTable(language, encoding.PlatformID, encoding.EncodingID, firstCode, glyphIds); + } + } +} diff --git a/src/SixLabors.Fonts/Tables/General/CMapTable.cs b/src/SixLabors.Fonts/Tables/General/CMapTable.cs index 5646ac177..62e8c24de 100644 --- a/src/SixLabors.Fonts/Tables/General/CMapTable.cs +++ b/src/SixLabors.Fonts/Tables/General/CMapTable.cs @@ -365,6 +365,12 @@ public static CMapTable Load(BigEndianBinaryReader reader) case 4: tables.AddRange(Format4SubTable.Load(encoding, reader)); break; + case 6: + tables.AddRange(Format6SubTable.Load(encoding, reader)); + break; + case 10: + tables.AddRange(Format10SubTable.Load(encoding, reader)); + break; case 12: tables.AddRange(Format12SubTable.Load(encoding, reader)); break; diff --git a/src/SixLabors.Fonts/Tables/General/HorizontalMetricsTable.cs b/src/SixLabors.Fonts/Tables/General/HorizontalMetricsTable.cs index d663bffb8..ad807ef0f 100644 --- a/src/SixLabors.Fonts/Tables/General/HorizontalMetricsTable.cs +++ b/src/SixLabors.Fonts/Tables/General/HorizontalMetricsTable.cs @@ -38,12 +38,18 @@ public HorizontalMetricsTable(ushort[] advancedWidths, short[] leftSideBearings) /// /// Gets the advance width for the specified glyph. If the glyph index exceeds the - /// number of metric records, the last record's advance width is returned. + /// number of metric records, the last record's advance width is returned. If it + /// exceeds the font's glyph count, the glyph has no metrics and zero is returned. /// /// The glyph index. /// The advance width in font design units. public ushort GetAdvancedWidth(int glyphIndex) { + if (glyphIndex >= this.leftSideBearings.Length) + { + return 0; + } + if (glyphIndex >= this.advancedWidths.Length) { // Records are indexed by glyph ID. As an optimization, the number of records can @@ -56,7 +62,8 @@ public ushort GetAdvancedWidth(int glyphIndex) } /// - /// Gets the left side bearing for the specified glyph. + /// Gets the left side bearing for the specified glyph. If the glyph index exceeds + /// the font's glyph count, the glyph has no metrics and zero is returned. /// /// The glyph index. /// The left side bearing in font design units. @@ -64,7 +71,7 @@ internal short GetLeftSideBearing(int glyphIndex) { if (glyphIndex >= this.leftSideBearings.Length) { - return this.leftSideBearings[^1]; + return 0; } return this.leftSideBearings[glyphIndex]; diff --git a/src/SixLabors.Fonts/Tables/General/VerticalMetricsTable.cs b/src/SixLabors.Fonts/Tables/General/VerticalMetricsTable.cs index a00d1aabc..0a46a9a65 100644 --- a/src/SixLabors.Fonts/Tables/General/VerticalMetricsTable.cs +++ b/src/SixLabors.Fonts/Tables/General/VerticalMetricsTable.cs @@ -38,22 +38,32 @@ public VerticalMetricsTable(ushort[] advancedHeights, short[] topSideBearings) /// /// Gets the advance height for the specified glyph. If the glyph index exceeds the - /// number of metric records, the first record's advance height is returned. + /// number of metric records, the last record's advance height is returned. If it + /// exceeds the font's glyph count, the glyph has no metrics and zero is returned. /// /// The glyph index. /// The advance height in font design units. public ushort GetAdvancedHeight(int glyphIndex) { + if (glyphIndex >= this.topSideBearings.Length) + { + return 0; + } + if (glyphIndex >= this.advancedHeights.Length) { - return this.advancedHeights[0]; + // Records are indexed by glyph ID. As an optimization, the number of records can + // be less than the number of glyphs, in which case the advance height value of the + // last record applies to all remaining glyph IDs. + return this.advancedHeights[^1]; } return this.advancedHeights[glyphIndex]; } /// - /// Gets the top side bearing for the specified glyph. + /// Gets the top side bearing for the specified glyph. If the glyph index exceeds + /// the font's glyph count, the glyph has no metrics and zero is returned. /// /// The glyph index. /// The top side bearing in font design units. @@ -61,7 +71,7 @@ internal short GetTopSideBearing(int glyphIndex) { if (glyphIndex >= this.topSideBearings.Length) { - return this.topSideBearings[0]; + return 0; } return this.topSideBearings[glyphIndex]; diff --git a/tests/Browser/EmojiPresentation.html b/tests/Browser/EmojiPresentation.html index 8e2e047bd..3f1a901fe 100644 --- a/tests/Browser/EmojiPresentation.html +++ b/tests/Browser/EmojiPresentation.html @@ -33,6 +33,13 @@ font-weight: 400; } + @font-face { + font-family: "Presentation Segoe UI Emoji 1.70"; + src: url("../Fonts/seguiemj-win11-1.70.ttf") format("truetype"); + font-style: normal; + font-weight: 400; + } + * { box-sizing: border-box; } @@ -101,6 +108,23 @@ .arial-segoe-dejavu { font-family: "Presentation Arial", "Presentation Segoe UI Emoji", "Presentation DejaVu Sans"; } + + .arial-segoe170-dejavu { + font-family: "Presentation Arial", "Presentation Segoe UI Emoji 1.70", "Presentation DejaVu Sans"; + } + + .segoe170 { + font-family: "Presentation Segoe UI Emoji 1.70"; + } + + .hand { + font-size: 192pt; + /* The library moves a first line's baseline down by the ink that rises above the + font's ascender so the bitmap holds it; the browser lets that ink overflow. The + thumb tip of the toned thumbs-up reaches 1740 units against Segoe UI Emoji's + ascender of 1491, so the browser copy moves down by that overshoot: 24px at 192pt. */ + padding-top: 24px; + } @@ -155,5 +179,34 @@

Arial, then Segoe UI Emoji, then DejaVu Sans, 30pt

+ +
+

Arial, then Segoe UI Emoji 1.70, then DejaVu Sans, 30pt

+
+
+
Browser
+

☀︎ ☀️ ☀ ☺︎ ☺️ ☺ +🤷🏽‍♀️ 🤷🏽 👨‍👩‍👧‍👦 ❤️‍🔥 +1️⃣ 🇯🇵 👍🏽 😀

+
+
+
SixLabors.Fonts actual output
+ SixLabors.Fonts Arial then Segoe UI Emoji 1.70 then DejaVu Sans output +
+
+
+
+

Segoe UI Emoji 1.70 toned thumbs-up, 192pt

+
+
+
Browser
+

👍🏽

+
+
+
SixLabors.Fonts actual output
+ SixLabors.Fonts Segoe UI Emoji 1.70 toned thumbs-up output +
+
+
diff --git a/tests/Fonts/seguiemj-win11-1.70.ttf b/tests/Fonts/seguiemj-win11-1.70.ttf new file mode 100644 index 000000000..bab365e47 --- /dev/null +++ b/tests/Fonts/seguiemj-win11-1.70.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:451df9bb5546055af625ff86aa72695df43e8e170b4c6c1316fe44e9c7a22f7c +size 12934920 diff --git a/tests/SixLabors.Fonts.Tests/EmojiPresentationFallbackTests.cs b/tests/SixLabors.Fonts.Tests/EmojiPresentationFallbackTests.cs index c2cbc2074..58e517642 100644 --- a/tests/SixLabors.Fonts.Tests/EmojiPresentationFallbackTests.cs +++ b/tests/SixLabors.Fonts.Tests/EmojiPresentationFallbackTests.cs @@ -32,16 +32,29 @@ public class EmojiPresentationFallbackTests private const int TextPresentationSelector = 0xFE0E; + private const int EmojiPresentationSelector = 0xFE0F; + /// - /// Gets the color emoji fonts in the test set: COLR v1, COLR v0 with a cmap format 14, and COLR v0. + /// Gets the color emoji fonts in the test set: COLR v1, COLR v0 with a cmap format 14, COLR v1 that + /// also carries COLR v0 layer records with a cmap format 14, and COLR v0. /// public static TheoryData ColorEmojiFonts { get; } = new() { TestFonts.NotoColorEmojiRegular, TestFonts.SegoeuiEmojiFile, + TestFonts.SegoeuiEmoji170File, TestFonts.TwemojiMozillaFile }; + /// + /// Gets both Segoe UI Emoji builds in the test set: 1.33 with COLR v0 and 1.70 with COLR v1. + /// + public static TheoryData SegoeUIEmojiFonts { get; } = new() + { + TestFonts.SegoeuiEmojiFile, + TestFonts.SegoeuiEmoji170File + }; + /// /// Gets the sequences a text font maps only in part, paired with every color emoji font. DejaVu Sans /// maps the digit, the heart, the joiner and the selectors but none of the emoji, so each @@ -71,6 +84,11 @@ public class EmojiPresentationFallbackTests "Arial-SegoeUIEmoji-DejaVuSans", [TestFonts.Arial, TestFonts.SegoeuiEmojiFile, TestFonts.Anchor2FontFile], [2, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1] + }, + { + "Arial-SegoeUIEmoji170-DejaVuSans", + [TestFonts.Arial, TestFonts.SegoeuiEmoji170File, TestFonts.Anchor2FontFile], + [2, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1] } }; @@ -158,6 +176,52 @@ public void TextFontFirst_SelectorsChooseTheFont_ForEveryColorFont(string emojiF Assert.Equal(GlyphType.Painted, suns[1].GlyphType); } + [Theory] + [MemberData(nameof(ColorEmojiFonts))] + public void VariationSequences_ResolveLikeHarfBuzz_ForEveryMappedCodePoint(string emojiFile) + { + // Every base character the font maps, paired with each presentation selector, resolves + // through the cmap format 14 default and non-default tables the way HarfBuzz resolves it. + using Blob blob = Blob.FromFile(emojiFile); + using HBFace face = new(blob, 0); + using HBFont hbFont = new(face); + + FontMetrics metrics = TestFonts.GetFont(emojiFile, 12).FontMetrics; + ReadOnlySpan codePoints = metrics.GetAvailableCodePoints().Span; + int[] selectors = [TextPresentationSelector, EmojiPresentationSelector]; + foreach (CodePoint codePoint in codePoints) + { + foreach (int selector in selectors) + { + bool declared = hbFont.TryGetVariationGlyph((uint)codePoint.Value, (uint)selector, out uint expected); + Assert.True(metrics.TryGetGlyphId(codePoint, new CodePoint(selector), out ushort actual, out bool consumed)); + Assert.True(declared == consumed, $"U+{codePoint.Value:X4} U+{selector:X4}: HarfBuzz declared {declared}, Fonts consumed {consumed}."); + if (declared) + { + Assert.Equal(expected, actual); + } + } + } + } + + [Theory] + [MemberData(nameof(SegoeUIEmojiFonts))] + public void SegoeUIEmoji_DefaultVariationSequences_ResolveToTheBaseGlyph(string emojiFile) + { + // Both builds list the female sign and the heart with U+FE0F in their default UVS + // ranges, so the pair resolves to the base glyph and the selector is consumed. + FontMetrics metrics = TestFonts.GetFont(emojiFile, 12).FontMetrics; + CodePoint selector = new(EmojiPresentationSelector); + foreach (int value in new[] { 0x2640, 0x2764 }) + { + CodePoint codePoint = new(value); + Assert.True(metrics.TryGetGlyphId(codePoint, out ushort baseGlyph)); + Assert.True(metrics.TryGetGlyphId(codePoint, selector, out ushort pairGlyph, out bool consumed)); + Assert.True(consumed, $"U+{value:X4} U+FE0F was not consumed."); + Assert.Equal(baseGlyph, pairGlyph); + } + } + [Theory] [MemberData(nameof(ColorEmojiFonts))] public void EmojiFontFirst_SelectorsChooseTheFont_ForEveryColorFont(string emojiFile) @@ -173,13 +237,14 @@ public void EmojiFontFirst_SelectorsChooseTheFont_ForEveryColorFont(string emoji AssertFont(emoji, suns[2]); } - [Fact] - public void ArialThenSegoeUIEmoji_TextSelectorWithoutAPlainSunTakesTheBaseGlyph() + [Theory] + [MemberData(nameof(SegoeUIEmojiFonts))] + public void ArialThenSegoeUIEmoji_TextSelectorWithoutAPlainSunTakesTheBaseGlyph(string emojiFile) { // Arial has no sun and Segoe UI Emoji has a color table, so no font answers ☀︎ and // the pass that ignores the selector takes the base glyph from Segoe UI Emoji. FontFamily text = TestFonts.GetFontFamily(TestFonts.Arial); - FontFamily emoji = TestFonts.GetFontFamily(TestFonts.SegoeuiEmojiFile); + FontFamily emoji = TestFonts.GetFontFamily(emojiFile); GlyphRendererParameters[] suns = RenderSuns(text, emoji); @@ -232,12 +297,13 @@ public void ColorSupportNone_EmojiSelectorTakesTheFirstFontsBaseGlyph() Assert.All(suns, sun => Assert.Equal(GlyphType.Standard, sun.GlyphType)); } - [Fact] - public void ArialThenSegoeUIEmoji_SmilingFaceSelectorsChooseTheFont() + [Theory] + [MemberData(nameof(SegoeUIEmojiFonts))] + public void ArialThenSegoeUIEmoji_SmilingFaceSelectorsChooseTheFont(string emojiFile) { // Arial maps the smiling face, and Segoe UI Emoji declares the emoji sequence in its cmap. FontFamily text = TestFonts.GetFontFamily(TestFonts.Arial); - FontFamily emoji = TestFonts.GetFontFamily(TestFonts.SegoeuiEmojiFile); + FontFamily emoji = TestFonts.GetFontFamily(emojiFile); TextOptions options = new(text.CreateFont(48)) { FallbackFontFamilies = [emoji] @@ -354,20 +420,45 @@ public void EmojiPresentation_BrowserComparison(string chainName, string[] fontF TextLayoutTestUtilities.TestLayout(ComparisonText, options, properties: [name]); } + [Fact] + public void EmojiPresentation_BrowserComparison_Hand() + { + // The toned thumbs-up of Segoe UI Emoji 1.70 shades its palm and fingers with radial + // gradients that the font squashes and skews with PaintTransform. At 192 points the + // shading is large enough to compare against the browser pixel for pixel. + FontCollection collection = new(); + Font font = TestFonts.GetFontFamily(collection, TestFonts.SegoeuiEmoji170File).CreateFont(192); + TextOptions options = new(font) + { + Dpi = 96F, + LineSpacing = 1.4F + }; + + GlyphRenderer renderer = new(); + TextRenderer.RenderTo(renderer, "👍🏽", options); + GlyphRendererParameters[] visible = renderer.GlyphKeys.Where(k => !CodePoint.IsVariationSelector(k.CodePoint)).ToArray(); + Assert.NotEmpty(visible); + Assert.All(visible, glyph => Assert.Equal(GlyphType.Painted, glyph.GlyphType)); + + // A string property is decorated in the file name. The formattable string keeps the font name plain. + FormattableString name = $"SegoeUIEmoji170"; + TextLayoutTestUtilities.TestLayout("👍🏽", options, properties: [name]); + } + private static TheoryData BuildSequenceCases() { // Keycap, flag, joiner with a selector inside, modifier, and the family joiner sequence. // Segoe UI Emoji draws no country flags; a regional indicator pair renders as two - // outline letters there, so the flag is paired with the other two fonts only. + // outline letters there, so the flag is paired with the other fonts only. const string flag = "🇯🇵"; string[] sequences = ["1️⃣", flag, "❤️‍🔥", "👍🏽", "👨‍👩‍👧‍👦"]; - string[] fonts = [TestFonts.NotoColorEmojiRegular, TestFonts.SegoeuiEmojiFile, TestFonts.TwemojiMozillaFile]; + string[] fonts = [TestFonts.NotoColorEmojiRegular, TestFonts.SegoeuiEmojiFile, TestFonts.SegoeuiEmoji170File, TestFonts.TwemojiMozillaFile]; TheoryData cases = []; foreach (string sequence in sequences) { foreach (string font in fonts) { - if (sequence == flag && font == TestFonts.SegoeuiEmojiFile) + if (sequence == flag && (font == TestFonts.SegoeuiEmojiFile || font == TestFonts.SegoeuiEmoji170File)) { continue; } diff --git a/tests/SixLabors.Fonts.Tests/GlyphTests.cs b/tests/SixLabors.Fonts.Tests/GlyphTests.cs index 9ee231170..58f015a15 100644 --- a/tests/SixLabors.Fonts.Tests/GlyphTests.cs +++ b/tests/SixLabors.Fonts.Tests/GlyphTests.cs @@ -312,5 +312,99 @@ public void RendererIsThreadsafe(string fontName) } #endif + [Fact] + public void RenderFamilySequence_SegoeUIEmoji170_ColrV0AndColrV1ShareGlyphs() + { + // Segoe UI Emoji 1.70 carries COLR v1 paint graphs and COLR v0 layer records in one + // table and builds the family from four positioned glyphs. HarfBuzz shapes the + // sequence to these glyph ids; the differential test checks their positions. + Font font = TestFonts.GetFont(TestFonts.SegoeuiEmoji170File, 72); + const string family = "👨‍👩‍👧‍👦"; + ushort[] expectedGlyphIds = [1283, 1464, 1271, 1259]; + + PaintCaptureRenderer outlines = RenderFamily(font, family, ColorFontSupport.None); + PaintCaptureRenderer colrV0 = RenderFamily(font, family, ColorFontSupport.ColrV0); + PaintCaptureRenderer colrV1 = RenderFamily(font, family, ColorFontSupport.ColrV1); + + foreach (PaintCaptureRenderer renderer in new[] { outlines, colrV0, colrV1 }) + { + Assert.Equal(expectedGlyphIds, renderer.VisibleKeys.Select(k => k.GlyphId)); + } + + Assert.All(outlines.VisibleKeys, k => Assert.Equal(GlyphType.Standard, k.GlyphType)); + Assert.Equal(0, outlines.SolidLayers + outlines.GradientLayers); + + Assert.All(colrV0.VisibleKeys, k => Assert.Equal(GlyphType.Painted, k.GlyphType)); + Assert.True(colrV0.SolidLayers > 0); + Assert.Equal(0, colrV0.GradientLayers); + + Assert.All(colrV1.VisibleKeys, k => Assert.Equal(GlyphType.Painted, k.GlyphType)); + Assert.True(colrV1.GradientLayers > 0); + } + + [Fact] + public void RenderThumbsUp_SegoeUIEmoji170_RadialGradientsKeepTheirWholeTransform() + { + // The shading gradients of the toned thumbs-up sit under PaintTransform and PaintScale + // records that squash, skew and reflect them. The circles stay in the paint's own space + // and the whole transform reaches the renderer, so none of that is lost. + Font font = TestFonts.GetFont(TestFonts.SegoeuiEmoji170File, 72); + PaintCaptureRenderer renderer = RenderFamily(font, "👍🏽", ColorFontSupport.ColrV1); + + RadialGradientPaint[] radials = renderer.Paints.OfType().ToArray(); + Assert.NotEmpty(radials); + Assert.All(radials, r => Assert.True(Matrix3x2.Invert(r.Transform, out _))); + + // A similarity maps the axes to perpendicular vectors of equal length; the squashed + // shading gradients do not. + Assert.Contains(radials, r => MathF.Abs((r.Transform.M11 * r.Transform.M21) + (r.Transform.M12 * r.Transform.M22)) > 0.01F + || MathF.Abs(new Vector2(r.Transform.M11, r.Transform.M12).Length() - new Vector2(r.Transform.M21, r.Transform.M22).Length()) > 0.01F); + + // PaintScale with a negative x scale reflects the gradient. + Assert.Contains(radials, r => r.Transform.GetDeterminant() < 0F); + } + + private static PaintCaptureRenderer RenderFamily(Font font, string text, ColorFontSupport colorFontSupport) + { + PaintCaptureRenderer renderer = new(); + TextRenderer.RenderTo(renderer, text, new TextOptions(font) + { + ColorFontSupport = colorFontSupport + }); + + return renderer; + } + private static CodePoint AsCodePoint(string text) => CodePoint.DecodeFromUtf16At(text.AsSpan(), 0); + + /// + /// Counts solid and gradient paint layers and exposes the glyph keys of the glyphs that + /// draw something, leaving out the zero width joiners. + /// + private sealed class PaintCaptureRenderer : GlyphRenderer + { + public int SolidLayers { get; private set; } + + public int GradientLayers { get; private set; } + + public List Paints { get; } = []; + + public GlyphRendererParameters[] VisibleKeys + => this.GlyphKeys.Where(k => !CodePoint.IsZeroWidthJoiner(k.CodePoint)).ToArray(); + + public override void BeginLayer(Paint paint, FillRule fillRule) + { + this.Paints.Add(paint); + if (paint is SolidPaint) + { + this.SolidLayers++; + } + else + { + this.GradientLayers++; + } + + base.BeginLayer(paint, fillRule); + } + } } diff --git a/tests/SixLabors.Fonts.Tests/HarfBuzzCorpusTests.cs b/tests/SixLabors.Fonts.Tests/HarfBuzzCorpusTests.cs index 366be8dd6..118cb3c2b 100644 --- a/tests/SixLabors.Fonts.Tests/HarfBuzzCorpusTests.cs +++ b/tests/SixLabors.Fonts.Tests/HarfBuzzCorpusTests.cs @@ -21,7 +21,9 @@ namespace SixLabors.Fonts.Tests; /// /// /// The corpus is a pinned submodule fetched by the generator project, so the inputs -/// and the reference version move only when the pin does. +/// and the reference version move only when the pin does. It holds the reference +/// implementation's own cases and the OpenType lookup test suite it carries, and +/// both are shaped. /// /// /// Every glyph is compared whole: which glyph, where it sits, how far it advances, @@ -74,13 +76,11 @@ public class HarfBuzzCorpusTests private static readonly string[] UnsupportedOptions = [ "--font-size", - "--ned", "--remove-default-ignorables", "--preserve-default-ignorables", "--cluster-level", "--bot", "--eot", - "--single-par", "--shaper", "--language", "--font-ptem", @@ -152,7 +152,7 @@ public static TheoryData CorpusCases() continue; } - string fontPath = Path.GetFullPath(Path.Combine(CorpusRoot, "tests", parts[0])); + string fontPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(file)!, parts[0])); if (!File.Exists(fontPath)) { continue; @@ -277,16 +277,27 @@ public void ShapesAsReferenceDoes(string fontPath, string options, string codePo [Fact] public void CorpusIsPresent() { - Assert.True(Directory.Exists(CorpusRoot), $"The corpus is missing from '{CorpusRoot}'."); + foreach (string root in CorpusRoots) + { + Assert.True(Directory.Exists(root), $"The corpus is missing from '{root}'."); + Assert.NotEmpty(Directory.GetFiles(Path.Combine(root, "fonts"))); + } + Assert.NotEmpty(EnumerateCorpusFiles()); - Assert.NotEmpty(Directory.GetFiles(Path.Combine(CorpusRoot, "fonts"))); } /// - /// Gets the root of the corpus within the pinned submodule. + /// Gets the roots of the corpus within the pinned submodule: the reference + /// implementation's own cases, and the OpenType lookup test suite it carries. /// - private static string CorpusRoot - => Path.Combine(TestEnvironment.SolutionDirectoryFullPath, "tests", "harfbuzz", "test", "shape", "data", "in-house"); + private static string[] CorpusRoots + { + get + { + string data = Path.Combine(TestEnvironment.SolutionDirectoryFullPath, "tests", "harfbuzz", "test", "shape", "data"); + return [Path.Combine(data, "in-house"), Path.Combine(data, "aots")]; + } + } /// /// Enumerates the corpus files carrying cases this library is meant to match. @@ -294,20 +305,23 @@ private static string CorpusRoot /// The corpus file paths. private static List EnumerateCorpusFiles() { - string directory = Path.Combine(CorpusRoot, "tests"); - if (!Directory.Exists(directory)) - { - return []; - } - List files = []; - foreach (string file in Directory.EnumerateFiles(directory, "*.tests")) + foreach (string root in CorpusRoots) { - // A file testing Apple Advanced Typography exclusively is left out whole: - // none of its cases are in scope for an OpenType engine. - if (!Path.GetFileName(file).StartsWith("aat", StringComparison.OrdinalIgnoreCase)) + string directory = Path.Combine(root, "tests"); + if (!Directory.Exists(directory)) { - files.Add(file); + continue; + } + + foreach (string file in Directory.EnumerateFiles(directory, "*.tests")) + { + // A file testing Apple Advanced Typography exclusively is left out whole: + // none of its cases are in scope for an OpenType engine. + if (!Path.GetFileName(file).StartsWith("aat", StringComparison.OrdinalIgnoreCase)) + { + files.Add(file); + } } } diff --git a/tests/SixLabors.Fonts.Tests/HarfBuzzDifferentialTests.cs b/tests/SixLabors.Fonts.Tests/HarfBuzzDifferentialTests.cs index 6c56f4c2b..5f5760777 100644 --- a/tests/SixLabors.Fonts.Tests/HarfBuzzDifferentialTests.cs +++ b/tests/SixLabors.Fonts.Tests/HarfBuzzDifferentialTests.cs @@ -50,6 +50,16 @@ public static TheoryData ShapingCases() { TestFonts.SegoeuiEmojiFile, "👨‍👩‍👧‍👦", false }, { TestFonts.SegoeuiEmojiFile, "🤷🏽‍♀️ 🤷🏽", false }, { TestFonts.SegoeuiEmojiFile, "☀︎ ☀️", false }, + + // Segoe UI Emoji 1.70 builds families from positioned glyphs rather than one + // ligature, so every family and the skin tone modifiers run against it too. + { TestFonts.SegoeuiEmoji170File, "👨‍👩‍👧‍👦", false }, + { TestFonts.SegoeuiEmoji170File, "👪 👨‍👩‍👧 👨‍👩‍👧‍👦", false }, + { TestFonts.SegoeuiEmoji170File, "👩‍👩‍👧‍👦 👨‍👨‍👧‍👦 👨‍👩‍👦‍👦 👩‍👧‍👧", false }, + { TestFonts.SegoeuiEmoji170File, "🤷🏽‍♀️ 🤷🏽", false }, + { TestFonts.SegoeuiEmoji170File, "☀︎ ☀️", false }, + { TestFonts.SegoeuiEmoji170File, "👌🏻 👌🏼 👌🏽 👌🏾 👌🏿", false }, + { TestFonts.SegoeuiEmoji170File, "❤️‍🔥 1️⃣ 👍🏽 😀", false }, { TestFonts.NotoColorEmojiRegular, "👨‍👩‍👧‍👦", false }, { TestFonts.NotoColorEmojiRegular, "🤷🏽‍♀️ 🤷🏽", false }, { TestFonts.NotoColorEmojiRegular, "☀︎ ☀️", false }, diff --git a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj index 0e39a31f2..cb3d9e83b 100644 --- a/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj +++ b/tests/SixLabors.Fonts.Tests/SixLabors.Fonts.Tests.csproj @@ -28,7 +28,7 @@ Comment out this constant declaration to disable all tests based upon image generation. This allows us to make breaking changes to the Fonts API without breaking the tests. --> - $(DefineConstants);SUPPORTS_DRAWING + true @@ -47,7 +47,7 @@ - + diff --git a/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format10SubTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format10SubTableTests.cs new file mode 100644 index 000000000..1efae8630 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format10SubTableTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Tables.General.CMap; +using SixLabors.Fonts.Unicode; +using SixLabors.Fonts.WellKnownIds; + +namespace SixLabors.Fonts.Tests.Tables.General.CMap; + +public class Format10SubTableTests +{ + [Fact] + public void LoadFormat10() + { + BigEndianBinaryWriter writer = new(); + + writer.WriteCMapSubTable( + new Format10SubTable( + 0, + PlatformIDs.Windows, + 10, + 0x109423, + [26, 27, 32])); + + BigEndianBinaryReader reader = writer.GetReader(); + ushort format = reader.ReadUInt16(); // read format before we pass along as that's what the cmap table does + Assert.Equal(10, format); + + Format10SubTable table = Format10SubTable.Load( + new[] { new EncodingRecord(PlatformIDs.Windows, 10, 0) }, + reader).Single(); + + Assert.Equal(0U, table.Language); + Assert.Equal(PlatformIDs.Windows, table.Platform); + Assert.Equal(10, table.Encoding); + Assert.Equal(0x109423U, table.StartCharCode); + Assert.Equal(new ushort[] { 26, 27, 32 }, table.GlyphIds); + } + + [Theory] + [InlineData(0x109423, 26, true)] + [InlineData(0x109424, 27, true)] + [InlineData(0x109425, 32, true)] + [InlineData(0x109422, 0, false)] // before the range + [InlineData(0x109426, 0, false)] // after the range + [InlineData(0x0021, 0, false)] // far below the range + public void GetCharacter(int src, int expected, bool expectedFound) + { + Format10SubTable format = new(0, PlatformIDs.Windows, 10, 0x109423, [26, 27, 32]); + + bool found = format.TryGetGlyphId(new CodePoint(src), out ushort id); + + Assert.Equal(expectedFound, found); + Assert.Equal(expected, id); + } + + [Fact] + public void GetCharacter_ZeroGlyphIsMissing() + { + Format10SubTable format = new(0, PlatformIDs.Windows, 10, 0x109423, [26, 0, 32]); + + bool found = format.TryGetGlyphId(new CodePoint(0x109424), out ushort id); + + Assert.False(found); + Assert.Equal(0, id); + } + + [Fact] + public void GetCodePoint() + { + Format10SubTable format = new(0, PlatformIDs.Windows, 10, 0x109423, [26, 27, 32]); + + Assert.True(format.TryGetCodePoint(32, out CodePoint codePoint)); + Assert.Equal(0x109425, codePoint.Value); + Assert.False(format.TryGetCodePoint(99, out _)); + } +} diff --git a/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format4SubTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format4SubTableTests.cs index fca14f0f0..9a28e7f61 100644 --- a/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format4SubTableTests.cs +++ b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format4SubTableTests.cs @@ -84,4 +84,66 @@ public void GetCharacter(int src, int expected, bool expectedFound) Assert.Equal(expected, id); Assert.Equal(expectedFound, found); } + + [Theory] + [InlineData(10, 12, true)] // 7 + 5 + [InlineData(11, 0, false)] // a zero in the array is the missing glyph, the delta does not apply + [InlineData(12, 14, true)] // 9 + 5 + public void GetCharacter_OffsetSegmentAddsDelta(int src, int expected, bool expectedFound) + { + // Two segments, so glyphIds[(offset / 2) + (src - start) - 2 + index] with + // offset 4 addresses glyphIds[src - start]. + Format4SubTable.Segment[] segments = + [ + new Format4SubTable.Segment(0, 12, 10, 5, 4), + new Format4SubTable.Segment(1, 0xFFFF, 0xFFFF, 1, 0), + ]; + + Format4SubTable table = new(0, PlatformIDs.Windows, 0, segments, [7, 0, 9]); + + bool found = table.TryGetGlyphId(new CodePoint(src), out ushort id); + + Assert.Equal(expectedFound, found); + Assert.Equal(expected, id); + + if (expectedFound) + { + Assert.True(table.TryGetCodePoint(id, out CodePoint codePoint)); + Assert.Equal(src, codePoint.Value); + } + } + + [Fact] + public void GetCharacter_DeltaToZeroIsMissing() + { + Format4SubTable.Segment[] segments = + [ + new Format4SubTable.Segment(0, 20, 20, -20, 0), + new Format4SubTable.Segment(1, 0xFFFF, 0xFFFF, 1, 0), + ]; + + Format4SubTable table = new(0, PlatformIDs.Windows, 0, segments, []); + + bool found = table.TryGetGlyphId(new CodePoint(20), out ushort id); + + Assert.False(found); + Assert.Equal(0, id); + } + + [Fact] + public void GetCharacter_DeltaWrapsToSixteenBits() + { + // The sentinel segment with a delta of -1 maps U+FFFF to 0xFFFE. + Format4SubTable.Segment[] segments = + [ + new Format4SubTable.Segment(0, 0xFFFF, 0xFFFF, -1, 0), + ]; + + Format4SubTable table = new(0, PlatformIDs.Windows, 0, segments, []); + + bool found = table.TryGetGlyphId(new CodePoint(0xFFFF), out ushort id); + + Assert.True(found); + Assert.Equal(0xFFFE, id); + } } diff --git a/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format6SubTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format6SubTableTests.cs new file mode 100644 index 000000000..f987a2aa5 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format6SubTableTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Tables.General.CMap; +using SixLabors.Fonts.Unicode; +using SixLabors.Fonts.WellKnownIds; + +namespace SixLabors.Fonts.Tests.Tables.General.CMap; + +public class Format6SubTableTests +{ + [Fact] + public void LoadFormat6() + { + BigEndianBinaryWriter writer = new(); + + writer.WriteCMapSubTable( + new Format6SubTable( + 0, + PlatformIDs.Windows, + 2, + 34, + [17, 56, 12])); + + BigEndianBinaryReader reader = writer.GetReader(); + ushort format = reader.ReadUInt16(); // read format before we pass along as that's what the cmap table does + Assert.Equal(6, format); + + Format6SubTable table = Format6SubTable.Load( + new[] { new EncodingRecord(PlatformIDs.Windows, 2, 0) }, + reader).Single(); + + Assert.Equal(0, table.Language); + Assert.Equal(PlatformIDs.Windows, table.Platform); + Assert.Equal(2, table.Encoding); + Assert.Equal(34, table.FirstCode); + Assert.Equal(new ushort[] { 17, 56, 12 }, table.GlyphIds); + } + + [Theory] + [InlineData(34, 17, true)] + [InlineData(35, 56, true)] + [InlineData(36, 12, true)] + [InlineData(33, 0, false)] // before the range + [InlineData(37, 0, false)] // after the range + public void GetCharacter(int src, int expected, bool expectedFound) + { + Format6SubTable format = new(0, PlatformIDs.Windows, 2, 34, [17, 56, 12]); + + bool found = format.TryGetGlyphId(new CodePoint(src), out ushort id); + + Assert.Equal(expectedFound, found); + Assert.Equal(expected, id); + } + + [Fact] + public void GetCharacter_ZeroGlyphIsMissing() + { + Format6SubTable format = new(0, PlatformIDs.Windows, 2, 34, [17, 0, 12]); + + bool found = format.TryGetGlyphId(new CodePoint(35), out ushort id); + + Assert.False(found); + Assert.Equal(0, id); + } + + [Fact] + public void GetCodePoint() + { + Format6SubTable format = new(0, PlatformIDs.Windows, 2, 34, [17, 56, 12]); + + Assert.True(format.TryGetCodePoint(56, out CodePoint codePoint)); + Assert.Equal(35, codePoint.Value); + Assert.False(format.TryGetCodePoint(99, out _)); + } +} diff --git a/tests/SixLabors.Fonts.Tests/Tables/General/HorizontalMetricsTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/General/HorizontalMetricsTableTests.cs new file mode 100644 index 000000000..d3621c296 --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/General/HorizontalMetricsTableTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Tables.General; + +namespace SixLabors.Fonts.Tests.Tables.General; + +public class HorizontalMetricsTableTests +{ + // Three glyphs, two metric records: the last record's advance applies to the third glyph. + private static readonly HorizontalMetricsTable Table = new([500, 600], [10, 20, 30]); + + [Theory] + [InlineData(0, 500)] + [InlineData(1, 600)] + [InlineData(2, 600)] + public void AdvanceWidth_WithinTheGlyphCount(int glyphIndex, int expected) + => Assert.Equal(expected, Table.GetAdvancedWidth(glyphIndex)); + + [Theory] + [InlineData(3)] + [InlineData(65535)] + public void AdvanceWidth_BeyondTheGlyphCountIsZero(int glyphIndex) + => Assert.Equal(0, Table.GetAdvancedWidth(glyphIndex)); + + [Theory] + [InlineData(0, 10)] + [InlineData(2, 30)] + public void LeftSideBearing_WithinTheGlyphCount(int glyphIndex, int expected) + => Assert.Equal(expected, Table.GetLeftSideBearing(glyphIndex)); + + [Fact] + public void LeftSideBearing_BeyondTheGlyphCountIsZero() + => Assert.Equal(0, Table.GetLeftSideBearing(3)); +} diff --git a/tests/SixLabors.Fonts.Tests/Tables/General/VerticalMetricsTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/General/VerticalMetricsTableTests.cs new file mode 100644 index 000000000..7894e42ae --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/General/VerticalMetricsTableTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Tables.General; + +namespace SixLabors.Fonts.Tests.Tables.General; + +public class VerticalMetricsTableTests +{ + // Three glyphs, two metric records: the last record's advance applies to the third glyph. + private static readonly VerticalMetricsTable Table = new([700, 800], [10, 20, 30]); + + [Theory] + [InlineData(0, 700)] + [InlineData(1, 800)] + [InlineData(2, 800)] + public void AdvanceHeight_WithinTheGlyphCount(int glyphIndex, int expected) + => Assert.Equal(expected, Table.GetAdvancedHeight(glyphIndex)); + + [Theory] + [InlineData(3)] + [InlineData(65535)] + public void AdvanceHeight_BeyondTheGlyphCountIsZero(int glyphIndex) + => Assert.Equal(0, Table.GetAdvancedHeight(glyphIndex)); + + [Theory] + [InlineData(0, 10)] + [InlineData(2, 30)] + public void TopSideBearing_WithinTheGlyphCount(int glyphIndex, int expected) + => Assert.Equal(expected, Table.GetTopSideBearing(glyphIndex)); + + [Fact] + public void TopSideBearing_BeyondTheGlyphCountIsZero() + => Assert.Equal(0, Table.GetTopSideBearing(3)); +} diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index c016768ca..d6a11ad77 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -13,6 +13,8 @@ public static class TestFonts public static string SegoeuiEmojiFile => GetFullPath("seguiemj-win11.ttf"); + public static string SegoeuiEmoji170File => GetFullPath("seguiemj-win11-1.70.ttf"); + public static string CarterOneFile => GetFullPath("Carter_One/CarterOne.ttf"); // Subset of Noto Sans SC pinned to the TextBaselineTests browser comparison strings, diff --git a/tests/SixLabors.Fonts.Tests/WriterExtensions.cs b/tests/SixLabors.Fonts.Tests/WriterExtensions.cs index 6398d0891..3d948c3c5 100644 --- a/tests/SixLabors.Fonts.Tests/WriterExtensions.cs +++ b/tests/SixLabors.Fonts.Tests/WriterExtensions.cs @@ -231,6 +231,66 @@ public static void WriteCMapSubTable(this BigEndianBinaryWriter writer, CMapSubT { writer.WriteCMapSubTable(subtable as Format0SubTable); writer.WriteCMapSubTable(subtable as Format4SubTable); + writer.WriteCMapSubTable(subtable as Format6SubTable); + writer.WriteCMapSubTable(subtable as Format10SubTable); + } + + public static void WriteCMapSubTable(this BigEndianBinaryWriter writer, Format6SubTable subtable) + { + if (subtable == null) + { + return; + } + + // Format 6 SubTable + // Type | Name | Description + // -------|--------------------------|------------------------------------------------------------------------ + // uint16 | format | Format number is set to 6. + // uint16 | length | This is the length in bytes of the subtable. + // uint16 | language | Please see “Note on the language field in 'cmap' subtables“ in this document. + // uint16 | firstCode | First character code of subrange. + // uint16 | entryCount | Number of character codes in subrange. + // uint16 | glyphIdArray[entryCount] | Array of glyph index values for character codes in the range. + writer.WriteUInt16(6); + writer.WriteUInt16((ushort)subtable.DataLength()); + writer.WriteUInt16(subtable.Language); + writer.WriteUInt16(subtable.FirstCode); + writer.WriteUInt16((ushort)subtable.GlyphIds.Length); + + foreach (ushort glyphId in subtable.GlyphIds) + { + writer.WriteUInt16(glyphId); + } + } + + public static void WriteCMapSubTable(this BigEndianBinaryWriter writer, Format10SubTable subtable) + { + if (subtable == null) + { + return; + } + + // Format 10 SubTable + // Type | Name | Description + // -------|------------------------|----------------------------------------------------------- + // uint16 | format | Subtable format; set to 10. + // uint16 | reserved | Reserved; set to 0. + // uint32 | length | Byte length of this subtable (including the header). + // uint32 | language | Please see “Note on the language field in 'cmap' subtables“ in this document. + // uint32 | startCharCode | First character code covered. + // uint32 | numChars | Number of character codes covered. + // uint16 | glyphIdArray[numChars] | Array of glyph indices for the character codes covered. + writer.WriteUInt16(10); + writer.WriteUInt16(0); + writer.WriteUInt32((uint)subtable.DataLength()); + writer.WriteUInt32(subtable.Language); + writer.WriteUInt32(subtable.StartCharCode); + writer.WriteUInt32((uint)subtable.GlyphIds.Length); + + foreach (ushort glyphId in subtable.GlyphIds) + { + writer.WriteUInt16(glyphId); + } } public static void WriteCMapSubTable(this BigEndianBinaryWriter writer, Format0SubTable subtable) @@ -378,6 +438,16 @@ private static int DataLength(this CMapSubTable subtable) return 16 + (segs.Length * 8) + (glyphs.Length * 2); } + if (subtable is Format6SubTable format6Table) + { + return 10 + (format6Table.GlyphIds.Length * 2); + } + + if (subtable is Format10SubTable format10Table) + { + return 20 + (format10Table.GlyphIds.Length * 2); + } + return 0; }