From 372797f365fa78d7beba113e8a7893cf1956e692 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 10:05:24 +1000 Subject: [PATCH 1/4] Walk GPOS lookups on the buffer cursor like GSUB Positioning stepped one glyph after every lookup, so a matched contextual lookup was re-attempted on the glyphs it had consumed. With Segoe UI Emoji 1.70 the family sequence's chained context applied its nested lookups twice and the members overlapped. GPOS now walks the same buffer cursor GSUB uses: a lookup leaves the cursor after the records it consumed and the loop steps one record only when the cursor did not move. The GPOS ApplyLookupList ends with MoveTo(end), the three chained context formats call it instead of inlining nested application, and pair positioning moves the cursor to the second glyph, or past it when valueFormat2 is set. MoveTo takes the no-pass fast path outside a substitution pass. Adds the installed Segoe UI Emoji 1.70 and its HarfBuzz differential cases. --- src/SixLabors.Fonts/ShapingBuffer.cs | 10 +- .../AdvancedTypographicUtils.cs | 8 +- .../GPos/LookupType2SubTable.cs | 21 +++- .../GPos/LookupType7SubTable.cs | 15 ++- .../GPos/LookupType8SubTable.cs | 117 +++++------------- .../Tables/AdvancedTypographic/GPosTable.cs | 27 ++-- tests/Fonts/seguiemj-win11-1.70.ttf | 3 + .../HarfBuzzDifferentialTests.cs | 10 ++ tests/SixLabors.Fonts.Tests/TestFonts.cs | 2 + 9 files changed, 111 insertions(+), 102 deletions(-) create mode 100644 tests/Fonts/seguiemj-win11-1.70.ttf 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/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/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/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, From 1e203180760ef42df93a7bc3d96b6f19d6e7a843 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 10:05:24 +1000 Subject: [PATCH 2/4] Treat cmap format 14 default UVS ranges as inclusive A default UVS range covers startUnicodeValue through startUnicodeValue + additionalCount, so the last code point of each range was never matched and U+FE0F fell through to the text presentation for it. Adds the presentation tests against HarfBuzz, the Segoe UI Emoji 1.70 font chain row and the 192pt toned thumbs-up row to the browser page. --- .../Tables/General/CMap/Format14SubTable.cs | 5 +- tests/Browser/EmojiPresentation.html | 53 +++++++++ .../EmojiPresentationFallbackTests.cs | 111 ++++++++++++++++-- 3 files changed, 157 insertions(+), 12 deletions(-) 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/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/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; } From 2efef6c4033ce3699ddbe700db51daa2ff665ed1 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 10:05:25 +1000 Subject: [PATCH 3/4] Keep COLRv1 gradient geometry in paint space Radial and sweep paints were flattened to device points, which reduced a PaintTransform to a similarity. The squashed shadow ellipses of the toned thumbs-up became large circles whose pad region painted the finger opaque dark. Paint.Transform is public and radial and sweep paints keep their geometry in the paint's y-down space with the whole transform, as FreeType and HarfBuzz hand it to the renderer. Linear paints stay flattened. The image comparison tests are disabled until Drawing 3.1.3 consumes the transform. --- src/SixLabors.Fonts/Rendering/Paint.cs | 7 +- .../Rendering/PaintedGlyphMetrics.cs | 143 ++++++------------ tests/SixLabors.Fonts.Tests/GlyphTests.cs | 94 ++++++++++++ .../SixLabors.Fonts.Tests.csproj | 4 +- 4 files changed, 150 insertions(+), 98 deletions(-) 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/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/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 @@ - + From 1e44cf0e0c03c185c27e809d4272b1836dbb7e3a Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 10:05:25 +1000 Subject: [PATCH 4/4] Add cmap formats 6 and 10 and shape the aots corpus The corpus tests now also shape HarfBuzz's aots suite, which found: - cmap formats 6 and 10 were not read. - Format 4 dropped idDelta in the idRangeOffset branch and reported glyph 0 as found. - A glyph id past the font's glyph count threw in the CFF tables. They now return an empty charstring, the bounds finder returns an empty box when nothing was drawn, and hmtx and vmtx return zero past the glyph count. vmtx also applies the last record, not the first, past numberOfVMetrics. --- src/SixLabors.Fonts/Tables/Cff/Cff1Table.cs | 10 +- src/SixLabors.Fonts/Tables/Cff/Cff2Table.cs | 10 +- .../Tables/Cff/CffBoundsFinder.cs | 12 +- src/SixLabors.Fonts/Tables/Cff/ICffTable.cs | 3 +- .../Tables/General/CMap/Format10SubTable.cs | 111 ++++++++++++++++++ .../Tables/General/CMap/Format4SubTable.cs | 38 ++++-- .../Tables/General/CMap/Format6SubTable.cs | 109 +++++++++++++++++ .../Tables/General/CMapTable.cs | 6 + .../Tables/General/HorizontalMetricsTable.cs | 13 +- .../Tables/General/VerticalMetricsTable.cs | 18 ++- .../HarfBuzzCorpusTests.cs | 54 +++++---- .../General/CMap/Format10SubTableTests.cs | 77 ++++++++++++ .../General/CMap/Format4SubTableTests.cs | 62 ++++++++++ .../General/CMap/Format6SubTableTests.cs | 76 ++++++++++++ .../General/HorizontalMetricsTableTests.cs | 35 ++++++ .../General/VerticalMetricsTableTests.cs | 35 ++++++ .../SixLabors.Fonts.Tests/WriterExtensions.cs | 70 +++++++++++ 17 files changed, 695 insertions(+), 44 deletions(-) create mode 100644 src/SixLabors.Fonts/Tables/General/CMap/Format10SubTable.cs create mode 100644 src/SixLabors.Fonts/Tables/General/CMap/Format6SubTable.cs create mode 100644 tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format10SubTableTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/Tables/General/CMap/Format6SubTableTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/Tables/General/HorizontalMetricsTableTests.cs create mode 100644 tests/SixLabors.Fonts.Tests/Tables/General/VerticalMetricsTableTests.cs 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/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/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/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/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; }