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