diff --git a/src/SixLabors.Fonts/GlyphPositioningCollection.cs b/src/SixLabors.Fonts/GlyphPositioningCollection.cs index fe395599..8378ac02 100644 --- a/src/SixLabors.Fonts/GlyphPositioningCollection.cs +++ b/src/SixLabors.Fonts/GlyphPositioningCollection.cs @@ -40,12 +40,20 @@ public GlyphShapingData this[int index] /// public void AddShapingFeature(int index, TagEntry feature) - => this.glyphs[index].Data.Features.Add(feature); + { + GlyphShapingData data = this.glyphs[index].Data; + data.Features.Add(feature); + if (feature.Enabled) + { + data.EnabledFeatureTags.Add(feature.Tag); + } + } /// public void EnableShapingFeature(int index, Tag feature) { - List features = this.glyphs[index].Data.Features; + GlyphShapingData data = this.glyphs[index].Data; + List features = data.Features; for (int i = 0; i < features.Count; i++) { TagEntry tagEntry = features[i]; @@ -53,6 +61,7 @@ public void EnableShapingFeature(int index, Tag feature) { tagEntry.Enabled = true; features[i] = tagEntry; + data.EnabledFeatureTags.Add(feature); break; } } @@ -61,7 +70,8 @@ public void EnableShapingFeature(int index, Tag feature) /// public void DisableShapingFeature(int index, Tag feature) { - List features = this.glyphs[index].Data.Features; + GlyphShapingData data = this.glyphs[index].Data; + List features = data.Features; for (int i = 0; i < features.Count; i++) { TagEntry tagEntry = features[i]; @@ -69,6 +79,7 @@ public void DisableShapingFeature(int index, Tag feature) { tagEntry.Enabled = false; features[i] = tagEntry; + data.EnabledFeatureTags.Remove(feature); break; } } @@ -78,6 +89,10 @@ public void DisableShapingFeature(int index, Tag feature) /// Gets the glyph metrics at the given codepoint offset. /// /// The zero-based index within the input codepoint collection. + /// + /// The index within the glyph list to start searching from. Updated to the position of the match + /// so that subsequent calls with increasing offsets avoid rescanning from the beginning. + /// /// The font size in PT units of the font containing this glyph. /// Whether the glyph is the result of a substitution. /// Whether the glyph is the result of a vertical substitution. @@ -90,6 +105,7 @@ public void DisableShapingFeature(int index, Tag feature) /// The metrics. public bool TryGetGlyphMetricsAtOffset( int offset, + ref int startIndex, out float pointSize, out bool isSubstituted, out bool isVerticalSubstitution, @@ -106,10 +122,15 @@ public bool TryGetGlyphMetricsAtOffset( Tag vrt2 = FeatureTags.VerticalAlternatesAndRotation; Tag vrtr = FeatureTags.VerticalAlternatesForRotation; - for (int i = 0; i < this.glyphs.Count; i++) + for (int i = startIndex; i < this.glyphs.Count; i++) { if (this.glyphs[i].Offset == offset) { + if (match.Count == 0) + { + startIndex = i; + } + GlyphPositioningData glyph = this.glyphs[i]; isSubstituted = glyph.Data.IsSubstituted; isDecomposed = glyph.Data.IsDecomposed; diff --git a/src/SixLabors.Fonts/GlyphShapingData.cs b/src/SixLabors.Fonts/GlyphShapingData.cs index c28e9b03..e144a2f6 100644 --- a/src/SixLabors.Fonts/GlyphShapingData.cs +++ b/src/SixLabors.Fonts/GlyphShapingData.cs @@ -14,6 +14,8 @@ namespace SixLabors.Fonts; [DebuggerDisplay("{DebuggerDisplay,nq}")] internal class GlyphShapingData { + private ushort glyphId; + /// /// Initializes a new instance of the class. /// @@ -62,6 +64,10 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) if (!clearFeatures) { this.Features.AddRange(data.Features); + foreach (Tag tag in data.EnabledFeatureTags) + { + this.EnabledFeatureTags.Add(tag); + } } foreach (Tag feature in data.AppliedFeatures) @@ -70,12 +76,36 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) } this.Bounds = data.Bounds; + this.CachedShapingClass = data.CachedShapingClass; + this.ShapingClassCacheKey = data.ShapingClassCacheKey; + } + + /// + /// Gets or sets the glyph id. Setting this value invalidates the cached shaping class. + /// + public ushort GlyphId + { + get => this.glyphId; + set + { + if (this.glyphId != value) + { + this.glyphId = value; + this.ShapingClassCacheKey = -1; + } + } } /// - /// Gets or sets the glyph id. + /// Gets or sets the cached glyph shaping class, avoiding repeated GDEF lookups. + /// + internal GlyphShapingClass CachedShapingClass { get; set; } + + /// + /// Gets or sets the cache key for . + /// A value of -1 indicates the cache is invalid. Valid entries store the glyph id. /// - public ushort GlyphId { get; set; } + internal int ShapingClassCacheKey { get; set; } = -1; /// /// Gets or sets the leading codepoint. @@ -127,6 +157,12 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) /// public List Features { get; set; } = []; + /// + /// Gets the set of feature tags that are currently enabled, maintained + /// in sync with for O(1) lookup. + /// + internal HashSet EnabledFeatureTags { get; } = []; + /// /// Gets or sets the collection of applied features. /// diff --git a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs index fa2f6892..68ed4d59 100644 --- a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs +++ b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs @@ -61,12 +61,20 @@ internal GlyphShapingData GetGlyphShapingData(int index, out int offset) /// public void AddShapingFeature(int index, TagEntry feature) - => this.glyphs[index].Data.Features.Add(feature); + { + GlyphShapingData data = this.glyphs[index].Data; + data.Features.Add(feature); + if (feature.Enabled) + { + data.EnabledFeatureTags.Add(feature.Tag); + } + } /// public void EnableShapingFeature(int index, Tag feature) { - List features = this.glyphs[index].Data.Features; + GlyphShapingData data = this.glyphs[index].Data; + List features = data.Features; for (int i = 0; i < features.Count; i++) { TagEntry tagEntry = features[i]; @@ -74,6 +82,7 @@ public void EnableShapingFeature(int index, Tag feature) { tagEntry.Enabled = true; features[i] = tagEntry; + data.EnabledFeatureTags.Add(feature); break; } } @@ -82,7 +91,8 @@ public void EnableShapingFeature(int index, Tag feature) /// public void DisableShapingFeature(int index, Tag feature) { - List features = this.glyphs[index].Data.Features; + GlyphShapingData data = this.glyphs[index].Data; + List features = data.Features; for (int i = 0; i < features.Count; i++) { TagEntry tagEntry = features[i]; @@ -90,6 +100,7 @@ public void DisableShapingFeature(int index, Tag feature) { tagEntry.Enabled = false; features[i] = tagEntry; + data.EnabledFeatureTags.Remove(feature); break; } } @@ -189,27 +200,29 @@ public void ReverseRange(int startIndex, int endIndex) /// /// Performs a stable sort of the glyphs by the comparison delegate starting at the specified index. + /// Only the references are reordered; offsets remain in place. /// /// The start index. /// The end index. /// The comparison delegate. public void Sort(int startIndex, int endIndex, Comparison comparer) { + // Stable insertion sort using adjacent swaps of Data references. + // The sorted ranges are typically small (syllable clusters of 2-10 glyphs), + // so insertion sort is optimal and avoids allocations. Adjacent swaps + // replace the previous MoveGlyph approach which shifted all intermediate elements. + List glyphs = this.glyphs; for (int i = startIndex + 1; i < endIndex; i++) { int j = i; - while (j > startIndex && comparer(this[j - 1], this[i]) > 0) + while (j > startIndex && comparer(glyphs[j - 1].Data, glyphs[j].Data) > 0) { + // Swap Data references between adjacent slots. + GlyphShapingData temp = glyphs[j - 1].Data; + glyphs[j - 1].Data = glyphs[j].Data; + glyphs[j].Data = temp; j--; } - - if (i == j) - { - continue; - } - - // Move item i to occupy place for item j, shift what's in between. - this.MoveGlyph(i, j); } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs index 903999c3..39f63fce 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs @@ -361,6 +361,13 @@ public static bool IsMarkGlyph(FontMetrics fontMetrics, ushort glyphId, GlyphSha public static GlyphShapingClass GetGlyphShapingClass(FontMetrics fontMetrics, ushort glyphId, GlyphShapingData shapingData) { + // Cache the shaping class on the GlyphShapingData to avoid repeated GDEF lookups. + // The cache key stores the glyph id; -1 means "not cached". + if (shapingData.ShapingClassCacheKey == glyphId) + { + return shapingData.CachedShapingClass; + } + bool isMark; bool isBase; bool isLigature; @@ -383,7 +390,10 @@ public static GlyphShapingClass GetGlyphShapingClass(FontMetrics fontMetrics, us isLigature = shapingData.CodePointCount > 1; } - return new GlyphShapingClass(isMark, isBase, isLigature, markAttachmentType); + GlyphShapingClass result = new(isMark, isBase, isLigature, markAttachmentType); + shapingData.CachedShapingClass = result; + shapingData.ShapingClassCacheKey = glyphId; + return result; } public static bool IsInMarkFilteringSet(FontMetrics fontMetrics, ushort markFilteringSet, ushort glyphId) diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/ClassDefinitionTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/ClassDefinitionTable.cs index c6529e1b..9ccebbae 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/ClassDefinitionTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/ClassDefinitionTable.cs @@ -143,10 +143,24 @@ public static ClassDefinitionFormat2Table Load(BigEndianBinaryReader reader) /// public override int ClassIndexOf(ushort glyphId) { - for (int i = 0; i < this.records.Length; i++) + // Records are ordered by StartGlyphId, so use binary search to find the + // candidate range whose StartGlyphId is <= glyphId. + ClassRangeRecord[] records = this.records; + int lo = 0; + int hi = records.Length - 1; + while (lo <= hi) { - ClassRangeRecord rec = this.records[i]; - if (rec.StartGlyphId <= glyphId && glyphId <= rec.EndGlyphId) + int mid = (int)(((uint)lo + (uint)hi) >> 1); + ClassRangeRecord rec = records[mid]; + if (glyphId < rec.StartGlyphId) + { + hi = mid - 1; + } + else if (glyphId > rec.EndGlyphId) + { + lo = mid + 1; + } + else { return rec.Class; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/CoverageTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/CoverageTable.cs index 7beb5832..e71bee80 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/CoverageTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/CoverageTable.cs @@ -84,10 +84,24 @@ private CoverageFormat2Table(CoverageRangeRecord[] records) public override int CoverageIndexOf(ushort glyphId) { - for (int i = 0; i < this.records.Length; i++) + // Records are ordered by StartGlyphId, so use binary search to find the + // candidate range whose StartGlyphId is <= glyphId. + CoverageRangeRecord[] records = this.records; + int lo = 0; + int hi = records.Length - 1; + while (lo <= hi) { - CoverageRangeRecord rec = this.records[i]; - if (rec.StartGlyphId <= glyphId && glyphId <= rec.EndGlyphId) + int mid = (int)(((uint)lo + (uint)hi) >> 1); + CoverageRangeRecord rec = records[mid]; + if (glyphId < rec.StartGlyphId) + { + hi = mid - 1; + } + else if (glyphId > rec.EndGlyphId) + { + lo = mid + 1; + } + else { return rec.Index + glyphId - rec.StartGlyphId; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs index 5c56d21a..bed0c697 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs @@ -160,11 +160,25 @@ public static PairSetTable Load(BigEndianBinaryReader reader, long offset, Value public bool TryGetPairValueRecord(ushort glyphId, [NotNullWhen(true)] out PairValueRecord pairValueRecord) { - foreach (PairValueRecord pair in this.pairValueRecords) + // Records are ordered by SecondGlyph, so use binary search. + PairValueRecord[] records = this.pairValueRecords; + int lo = 0; + int hi = records.Length - 1; + while (lo <= hi) { - if (pair.SecondGlyph == glyphId) + int mid = (int)(((uint)lo + (uint)hi) >> 1); + ushort midGlyph = records[mid].SecondGlyph; + if (glyphId < midGlyph) { - pairValueRecord = pair; + hi = mid - 1; + } + else if (glyphId > midGlyph) + { + lo = mid + 1; + } + else + { + pairValueRecord = records[mid]; return true; } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs index 852add06..b3040bdc 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs @@ -194,8 +194,7 @@ current is not ScriptClass.Common and not ScriptClass.Unknown and not ScriptClas goto EndLookups; } - List glyphFeatures = collection[iterator.Index].Features; - if (!HasFeature(glyphFeatures, in feature)) + if (!collection[iterator.Index].EnabledFeatureTags.Contains(feature)) { iterator.Next(); continue; @@ -367,20 +366,6 @@ private ScriptClass GetScriptClass(ScriptClass current) return ScriptClass.Default; } - private static bool HasFeature(List glyphFeatures, in Tag feature) - { - for (int i = 0; i < glyphFeatures.Count; i++) - { - TagEntry entry = glyphFeatures[i]; - if (entry.Tag == feature && entry.Enabled) - { - return true; - } - } - - return false; - } - private static void FixCursiveAttachment(GlyphPositioningCollection collection, int index, int count) { LayoutMode layoutMode = collection.TextOptions.LayoutMode; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs index 69596206..87499b86 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs @@ -224,8 +224,7 @@ internal void ApplyFeature( return; } - List glyphFeatures = collection[iterator.Index].Features; - if (!HasFeature(glyphFeatures, in feature)) + if (!collection[iterator.Index].EnabledFeatureTags.Contains(feature)) { iterator.Next(); continue; @@ -379,18 +378,4 @@ private ScriptClass GetScriptClass(ScriptClass current) // Script for `current` not present in the font: use default shaper. return ScriptClass.Default; } - - private static bool HasFeature(List glyphFeatures, in Tag feature) - { - for (int i = 0; i < glyphFeatures.Count; i++) - { - TagEntry entry = glyphFeatures[i]; - if (entry.Tag == feature && entry.Enabled) - { - return true; - } - } - - return false; - } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HebrewShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HebrewShaper.cs new file mode 100644 index 00000000..bf9dd27d --- /dev/null +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HebrewShaper.cs @@ -0,0 +1,279 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Unicode; + +namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers; + +/// +/// Hebrew shaper. Handles mark reordering (PATAH/QAMATS before SHEVA/HIRIQ before METEG) +/// and presentation form composition for legacy fonts without GPOS mark positioning. +/// Based on HarfBuzz: +/// +internal class HebrewShaper : DefaultShaper +{ + // Hebrew presentation forms with dagesh for U+05D0..U+05EA. + // 0x0000 means no dagesh form exists for that letter. + private static readonly ushort[] DageshForms = + [ + 0xFB30, // ALEF + 0xFB31, // BET + 0xFB32, // GIMEL + 0xFB33, // DALET + 0xFB34, // HE + 0xFB35, // VAV + 0xFB36, // ZAYIN + 0x0000, // HET + 0xFB38, // TET + 0xFB39, // YOD + 0xFB3A, // FINAL KAF + 0xFB3B, // KAF + 0xFB3C, // LAMED + 0x0000, // FINAL MEM + 0xFB3E, // MEM + 0x0000, // FINAL NUN + 0xFB40, // NUN + 0xFB41, // SAMEKH + 0x0000, // AYIN + 0xFB43, // FINAL PE + 0xFB44, // PE + 0x0000, // FINAL TSADI + 0xFB46, // TSADI + 0xFB47, // QOF + 0xFB48, // RESH + 0xFB49, // SHIN + 0xFB4A, // TAV + ]; + + private readonly FontMetrics fontMetrics; + private readonly bool hasGsub; + + public HebrewShaper(ScriptClass script, TextOptions textOptions, FontMetrics fontMetrics, bool hasGsub) + : base(script, MarkZeroingMode.PostGpos, textOptions) + { + this.fontMetrics = fontMetrics; + this.hasGsub = hasGsub; + } + + /// + protected override void AssignFeatures(IGlyphShapingCollection collection, int index, int count) + { + base.AssignFeatures(collection, index, count); + + if (collection is not GlyphSubstitutionCollection substitutionCollection) + { + return; + } + + // Step 1: Reorder Hebrew marks. + // Swap SHEVA/HIRIQ with following METEG when preceded by PATAH/QAMATS. + // https://bugzilla.mozilla.org/show_bug.cgi?id=728866 + ReorderMarks(substitutionCollection, index, count); + + // Step 2: Compose Hebrew presentation forms for legacy fonts. + // Only applied when the font lacks GSUB features (proxy for lacking GPOS mark). + if (!this.hasGsub) + { + ComposeHebrewForms(substitutionCollection, this.fontMetrics, index, count); + } + } + + /// + /// Reorders Hebrew combining marks to ensure correct rendering. + /// + /// Looks for the pattern [PATAH/QAMATS, SHEVA/HIRIQ, METEG/BELOW] and swaps + /// the last two marks. This ensures correct visual stacking of vowel points + /// and the meteg stress mark. + /// + /// + private static void ReorderMarks(GlyphSubstitutionCollection collection, int index, int count) + { + int end = index + count; + for (int i = index + 2; i < end; i++) + { + int c0 = collection[i - 2].CodePoint.Value; + int c1 = collection[i - 1].CodePoint.Value; + int c2 = collection[i].CodePoint.Value; + + // c0: PATAH (U+05B7) or QAMATS (U+05B8) + // c1: SHEVA (U+05B0) or HIRIQ (U+05B4) + // c2: METEG (U+05BD) or a below-class mark + if (IsPatahOrQamats(c0) && IsShevaOrHiriq(c1) && IsMetegOrBelow(c2)) + { + // Swap positions i-1 and i. + GlyphShapingData data1 = collection[i - 1]; + GlyphShapingData data2 = collection[i]; + + // Swap codepoints and glyph IDs. + (collection[i - 1].CodePoint, collection[i].CodePoint) = (data2.CodePoint, data1.CodePoint); + (collection[i - 1].GlyphId, collection[i].GlyphId) = (data2.GlyphId, data1.GlyphId); + break; + } + } + } + + /// + /// Composes Hebrew base + mark sequences into precomposed presentation forms. + /// This is a fallback for legacy fonts that lack GPOS mark-to-base positioning. + /// + private static void ComposeHebrewForms(GlyphSubstitutionCollection collection, FontMetrics fontMetrics, int index, int count) + { + int end = index + count; + for (int i = index + 1; i < end; i++) + { + int a = collection[i - 1].CodePoint.Value; + int b = collection[i].CodePoint.Value; + + int composed = TryCompose(a, b); + if (composed != 0 && fontMetrics.TryGetGlyphId(new CodePoint(composed), out ushort composedGlyphId)) + { + // Replace the two glyphs with the composed form. + collection.Replace(i - 1, 2, composedGlyphId, FeatureTags.GlyphCompositionDecomposition); + end--; + i--; + } + } + } + + /// + /// Attempts to compose two Hebrew codepoints into a precomposed presentation form. + /// Returns the composed codepoint, or 0 if no composition exists. + /// + private static int TryCompose(int a, int b) + { + switch (b) + { + case 0x05B4: // HIRIQ + + if (a == 0x05D9) + { + // YOD + return 0xFB1D; + } + + break; + + case 0x05B7: // PATAH + if (a == 0x05F2) + { + // YIDDISH YOD YOD + PATAH + return 0xFB1F; + } + + if (a == 0x05D0) + { + // ALEF + PATAH + return 0xFB2E; + } + + break; + + case 0x05B8: // QAMATS + if (a == 0x05D0) + { + // ALEF + QAMATS + return 0xFB2F; + } + + break; + + case 0x05B9: // HOLAM + if (a == 0x05D5) + { + // VAV + HOLAM + return 0xFB4B; + } + + break; + + case 0x05BC: // DAGESH + if (a is >= 0x05D0 and <= 0x05EA) + { + int form = DageshForms[a - 0x05D0]; + return form != 0 ? form : 0; + } + + if (a == 0xFB2A) + { + // SHIN WITH SHIN DOT + DAGESH + return 0xFB2C; + } + + if (a == 0xFB2B) + { + // SHIN WITH SIN DOT + DAGESH + return 0xFB2D; + } + + break; + + case 0x05BF: // RAFE + if (a == 0x05D1) + { + // BET + RAFE + return 0xFB4C; + } + + if (a == 0x05DB) + { + // KAF + RAFE + return 0xFB4D; + } + + if (a == 0x05E4) + { + // PE + RAFE + return 0xFB4E; + } + + break; + + case 0x05C1: // SHIN DOT + if (a == 0x05E9) + { + // SHIN + SHIN DOT + return 0xFB2A; + } + + if (a == 0xFB49) + { + // SHIN WITH DAGESH + SHIN DOT + return 0xFB2C; + } + + break; + + case 0x05C2: // SIN DOT + if (a == 0x05E9) + { + // SHIN + SIN DOT + return 0xFB2B; + } + + if (a == 0xFB49) + { + // SHIN WITH DAGESH + SIN DOT + return 0xFB2D; + } + + break; + } + + return 0; + } + + /// PATAH (U+05B7) or QAMATS (U+05B8). + private static bool IsPatahOrQamats(int codepoint) + => codepoint is 0x05B7 or 0x05B8; + + /// SHEVA (U+05B0) or HIRIQ (U+05B4). + private static bool IsShevaOrHiriq(int codepoint) + => codepoint is 0x05B0 or 0x05B4; + + /// + /// METEG (U+05BD) or a combining mark with Unicode Canonical Combining Class = Below (220). + /// Currently checks METEG only; generic CCC=220 detection would require combining class data. + /// + private static bool IsMetegOrBelow(int codepoint) + => codepoint == 0x05BD; +} diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs index d46173eb..f244fd14 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs @@ -34,6 +34,13 @@ or ScriptClass.Mandaic or ScriptClass.Manichaean or ScriptClass.PsalterPahlavi => new ArabicShaper(script, textOptions), + // Hebrew + ScriptClass.Hebrew => new HebrewShaper(script, textOptions, fontMetrics, unicodeScriptTag != default), + + // Thai / Lao + ScriptClass.Thai + or ScriptClass.Lao => new ThaiShaper(script, textOptions, fontMetrics, unicodeScriptTag != default), + // Hangul ScriptClass.Hangul => new HangulShaper(script, textOptions, fontMetrics), diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ThaiShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ThaiShaper.cs new file mode 100644 index 00000000..35da87a0 --- /dev/null +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ThaiShaper.cs @@ -0,0 +1,463 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts.Unicode; + +namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers; + +/// +/// Thai and Lao shaper. Handles SARA AM decomposition, NIKHAHIT/NIGGAHITA reordering, +/// and PUA-based fallback mark positioning for legacy fonts. +/// Based on HarfBuzz: +/// +#pragma warning disable SA1201 // Nested types are grouped with the static data they define. +internal class ThaiShaper : DefaultShaper +{ + // Above-base state machine. + // Start states by consonant type: NC=0, AC=1, RC=0, DC=0, NotConsonant=3 + private static readonly int[] AboveStartState = [0, 1, 0, 0, 3]; + + // State transitions [state, markType] → (action, nextState) + // AV BV T + // T0: {NOP,T3} {NOP,T0} {SD, T3} + // T1: {SL, T2} {NOP,T1} {SDL,T2} + // T2: {NOP,T3} {NOP,T2} {SL, T3} + // T3: {NOP,T3} {NOP,T3} {NOP,T3} + private static readonly StateTransition[,] AboveStateMachine = + { + { new(PuaAction.NOP, 3), new(PuaAction.NOP, 0), new(PuaAction.SD, 3) }, + { new(PuaAction.SL, 2), new(PuaAction.NOP, 1), new(PuaAction.SDL, 2) }, + { new(PuaAction.NOP, 3), new(PuaAction.NOP, 2), new(PuaAction.SL, 3) }, + { new(PuaAction.NOP, 3), new(PuaAction.NOP, 3), new(PuaAction.NOP, 3) }, + }; + + // Below-base state machine. + // Start states by consonant type: NC=0, AC=0, RC=1, DC=2, NotConsonant=2 + private static readonly int[] BelowStartState = [0, 0, 1, 2, 2]; + + // State transitions [state, markType] → (action, nextState) + // AV BV T + // B0: {NOP,B0} {NOP,B2} {NOP,B0} + // B1: {NOP,B1} {RD, B2} {NOP,B1} + // B2: {NOP,B2} {SD, B2} {NOP,B2} + private static readonly StateTransition[,] BelowStateMachine = + { + { new(PuaAction.NOP, 0), new(PuaAction.NOP, 2), new(PuaAction.NOP, 0) }, + { new(PuaAction.NOP, 1), new(PuaAction.RD, 2), new(PuaAction.NOP, 1) }, + { new(PuaAction.NOP, 2), new(PuaAction.SD, 2), new(PuaAction.NOP, 2) }, + }; + + // Shift-Down PUA mappings. + private static readonly PuaMapping[] SdMappings = + [ + new(0x0E48, 0xF70A, 0xF88B), // MAI EK + new(0x0E49, 0xF70B, 0xF88E), // MAI THO + new(0x0E4A, 0xF70C, 0xF891), // MAI TRI + new(0x0E4B, 0xF70D, 0xF894), // MAI CHATTAWA + new(0x0E4C, 0xF70E, 0xF897), // THANTHAKHAT + new(0x0E38, 0xF718, 0xF89B), // SARA U + new(0x0E39, 0xF719, 0xF89C), // SARA UU + new(0x0E3A, 0xF71A, 0xF89D), // PHINTHU + ]; + + // Shift-Down-Left PUA mappings. + private static readonly PuaMapping[] SdlMappings = + [ + new(0x0E48, 0xF705, 0xF88C), // MAI EK + new(0x0E49, 0xF706, 0xF88F), // MAI THO + new(0x0E4A, 0xF707, 0xF892), // MAI TRI + new(0x0E4B, 0xF708, 0xF895), // MAI CHATTAWA + new(0x0E4C, 0xF709, 0xF898), // THANTHAKHAT + ]; + + // Shift-Left PUA mappings. + private static readonly PuaMapping[] SlMappings = + [ + new(0x0E48, 0xF713, 0xF88A), // MAI EK + new(0x0E49, 0xF714, 0xF88D), // MAI THO + new(0x0E4A, 0xF715, 0xF890), // MAI TRI + new(0x0E4B, 0xF716, 0xF893), // MAI CHATTAWA + new(0x0E4C, 0xF717, 0xF896), // THANTHAKHAT + new(0x0E31, 0xF710, 0xF884), // MAI HAN-AKAT + new(0x0E34, 0xF701, 0xF885), // SARA I + new(0x0E35, 0xF702, 0xF886), // SARA II + new(0x0E36, 0xF703, 0xF887), // SARA UE + new(0x0E37, 0xF704, 0xF888), // SARA UEE + new(0x0E47, 0xF712, 0xF889), // MAITAIKHU + new(0x0E4D, 0xF711, 0xF899), // NIKHAHIT + ]; + + // Remove-Descender PUA mappings. + private static readonly PuaMapping[] RdMappings = + [ + new(0x0E0D, 0xF70F, 0xF89A), // YO YING + new(0x0E10, 0xF700, 0xF89E), // THO THAN + ]; + + private readonly FontMetrics fontMetrics; + private readonly bool hasGsub; + + /// + /// Thai consonant types for the PUA shaping state machines. + /// + private enum ConsonantType + { + /// Normal consonant. + NC, + + /// Ascending consonant (Thai: 0x0E1B, 0x0E1D, 0x0E1F). + AC, + + /// Consonant with removable descender (Thai: 0x0E0D, 0x0E10). + RC, + + /// Consonant with strict descender (Thai: 0x0E0E, 0x0E0F). + DC, + + /// Not a consonant. + NotConsonant + } + + /// + /// Thai mark types for the PUA shaping state machines. + /// + private enum MarkType + { + /// Above-vowel mark. + AV, + + /// Below-vowel mark. + BV, + + /// Tone mark. + T, + + /// Not a mark. + NotMark + } + + /// + /// Actions emitted by the PUA shaping state machines. + /// + private enum PuaAction + { + /// No operation. + NOP, + + /// Shift combining-mark down. + SD, + + /// Shift combining-mark left. + SL, + + /// Shift combining-mark down-left. + SDL, + + /// Remove descender from base consonant. + RD + } + + public ThaiShaper(ScriptClass script, TextOptions textOptions, FontMetrics fontMetrics, bool hasGsub) + : base(script, MarkZeroingMode.PostGpos, textOptions) + { + this.fontMetrics = fontMetrics; + this.hasGsub = hasGsub; + } + + /// + protected override void AssignFeatures(IGlyphShapingCollection collection, int index, int count) + { + base.AssignFeatures(collection, index, count); + + if (collection is not GlyphSubstitutionCollection substitutionCollection) + { + return; + } + + // Step 1: Always decompose SARA AM -> NIKHAHIT + SARA AA and reorder. + // This is needed even when the font has Thai/Lao GSUB tables. + count = PreprocessSaraAm(substitutionCollection, this.fontMetrics, index, count); + + // Step 2: PUA-based fallback mark positioning. + // Only applied for Thai (not Lao) when the font lacks Thai GSUB features. + if (this.ScriptClass == ScriptClass.Thai && !this.hasGsub) + { + DoThaiPuaShaping(substitutionCollection, this.fontMetrics, index, count); + } + } + + /// + /// Decomposes SARA AM (Thai U+0E33 / Lao U+0EB3) into NIKHAHIT + SARA AA, + /// then reorders NIKHAHIT backward over any above-base marks. + /// + /// This is needed even when the font has Thai/Lao GSUB tables. + /// + /// + /// + /// The updated count after decomposition. + private static int PreprocessSaraAm(GlyphSubstitutionCollection collection, FontMetrics fontMetrics, int index, int count) + { + // Characters of significance: + // + // Thai Lao + // SARA AM: U+0E33 U+0EB3 + // SARA AA: U+0E32 U+0EB2 + // Nikhahit: U+0E4D U+0ECD + // + // When SARA AM is found, decompose into NIKHAHIT + SARA AA, + // then move NIKHAHIT backward past any above-base marks. + // + // Example: <0E14, 0E4B, 0E33> -> <0E14, 0E4D, 0E4B, 0E32> + int end = index + count; + for (int i = index; i < end; i++) + { + GlyphShapingData data = collection[i]; + int codepoint = data.CodePoint.Value; + + if (!IsSaraAm(codepoint)) + { + continue; + } + + int nikhahitCodepoint = NikhahitFromSaraAm(codepoint); + int saraAACodepoint = SaraAAFromSaraAm(codepoint); + + if (!fontMetrics.TryGetGlyphId(new CodePoint(nikhahitCodepoint), out ushort nikhahitId) || + !fontMetrics.TryGetGlyphId(new CodePoint(saraAACodepoint), out ushort saraAAId)) + { + continue; + } + + // Decompose SARA AM into [NIKHAHIT, SARA AA]. + // Replace puts NIKHAHIT at index i, SARA AA at index i+1. + collection.Replace(i, [nikhahitId, saraAAId], FeatureTags.GlyphCompositionDecomposition); + collection[i].CodePoint = new CodePoint(nikhahitCodepoint); + collection[i + 1].CodePoint = new CodePoint(saraAACodepoint); + end++; + + // Move NIKHAHIT backward over any above-base marks. + int target = i; + while (target > index && IsAboveBaseMark(collection[target - 1].CodePoint.Value)) + { + target--; + } + + if (target < i) + { + collection.MoveGlyph(i, target); + } + + // Skip past SARA AA. + i++; + } + + return end - index; + } + + /// + /// Applies PUA-based fallback mark positioning using state machines. + /// Only used for Thai fonts that lack GSUB features. + /// + private static void DoThaiPuaShaping(GlyphSubstitutionCollection collection, FontMetrics fontMetrics, int index, int count) + { + int aboveState = AboveStartState[(int)ConsonantType.NotConsonant]; + int belowState = BelowStartState[(int)ConsonantType.NotConsonant]; + int baseIndex = index; + + int end = index + count; + for (int i = index; i < end; i++) + { + int codepoint = collection[i].CodePoint.Value; + MarkType mt = GetMarkType(codepoint); + + if (mt == MarkType.NotMark) + { + ConsonantType ct = GetConsonantType(codepoint); + aboveState = AboveStartState[(int)ct]; + belowState = BelowStartState[(int)ct]; + baseIndex = i; + continue; + } + + StateTransition aboveEdge = AboveStateMachine[aboveState, (int)mt]; + StateTransition belowEdge = BelowStateMachine[belowState, (int)mt]; + aboveState = aboveEdge.NextState; + belowState = belowEdge.NextState; + + // At least one of the above/below actions is NOP. + PuaAction action = aboveEdge.Action != PuaAction.NOP ? aboveEdge.Action : belowEdge.Action; + + if (action == PuaAction.RD) + { + int baseCp = collection[baseIndex].CodePoint.Value; + int puaCp = ThaiPuaShape(baseCp, action, fontMetrics); + if (puaCp != baseCp && fontMetrics.TryGetGlyphId(new CodePoint(puaCp), out ushort puaId)) + { + collection[baseIndex].CodePoint = new CodePoint(puaCp); + collection[baseIndex].GlyphId = puaId; + } + } + else if (action != PuaAction.NOP) + { + int puaCp = ThaiPuaShape(codepoint, action, fontMetrics); + if (puaCp != codepoint && fontMetrics.TryGetGlyphId(new CodePoint(puaCp), out ushort puaId)) + { + collection[i].CodePoint = new CodePoint(puaCp); + collection[i].GlyphId = puaId; + } + } + } + } + + /// + /// Maps a Thai codepoint to its PUA variant based on the action. + /// Tries Windows PUA first, then Mac PUA. + /// + private static int ThaiPuaShape(int codepoint, PuaAction action, FontMetrics fontMetrics) + { + ReadOnlySpan mappings = action switch + { + PuaAction.SD => SdMappings, + PuaAction.SDL => SdlMappings, + PuaAction.SL => SlMappings, + PuaAction.RD => RdMappings, + _ => default + }; + + for (int i = 0; i < mappings.Length; i++) + { + if (mappings[i].Original == codepoint) + { + // Try Windows PUA first. + if (fontMetrics.TryGetGlyphId(new CodePoint(mappings[i].WinPua), out _)) + { + return mappings[i].WinPua; + } + + // Try Mac PUA. + if (fontMetrics.TryGetGlyphId(new CodePoint(mappings[i].MacPua), out _)) + { + return mappings[i].MacPua; + } + + break; + } + } + + return codepoint; + } + + /// + /// Classifies a Thai consonant by its vertical extent. + /// Only works for Thai codepoints (U+0E01..U+0E2E). + /// + private static ConsonantType GetConsonantType(int codepoint) + { + // Ascending consonants (tall right stroke). + if (codepoint is 0x0E1B or 0x0E1D or 0x0E1F) + { + return ConsonantType.AC; + } + + // Consonants with removable descender. + if (codepoint is 0x0E0D or 0x0E10) + { + return ConsonantType.RC; + } + + // Consonants with strict descender. + if (codepoint is 0x0E0E or 0x0E0F) + { + return ConsonantType.DC; + } + + // Normal consonant range. + if (codepoint is >= 0x0E01 and <= 0x0E2E) + { + return ConsonantType.NC; + } + + return ConsonantType.NotConsonant; + } + + /// + /// Classifies a Thai mark by its position relative to the base consonant. + /// Only works for Thai codepoints. + /// + private static MarkType GetMarkType(int codepoint) + { + // Above-vowel marks. + if (codepoint is 0x0E31 + or (>= 0x0E34 and <= 0x0E37) or 0x0E47 + or (>= 0x0E4D and <= 0x0E4E)) + { + return MarkType.AV; + } + + // Below-vowel marks. + if (codepoint is >= 0x0E38 and <= 0x0E3A) + { + return MarkType.BV; + } + + // Tone marks. + if (codepoint is >= 0x0E48 and <= 0x0E4C) + { + return MarkType.T; + } + + return MarkType.NotMark; + } + + /// + /// SARA AM: Thai U+0E33, Lao U+0EB3. The Lao variant is Thai + 0x80. + /// + private static bool IsSaraAm(int codepoint) + => (codepoint & ~0x0080) == 0x0E33; + + /// + /// Derives NIKHAHIT/NIGGAHITA from SARA AM. Thai: U+0E4D, Lao: U+0ECD. + /// + private static int NikhahitFromSaraAm(int codepoint) + => codepoint - 0x0E33 + 0x0E4D; + + /// + /// Derives SARA AA from SARA AM. Thai: U+0E32, Lao: U+0EB2. + /// + private static int SaraAAFromSaraAm(int codepoint) + => codepoint - 1; + + /// + /// Returns if the codepoint is an above-base mark that NIKHAHIT + /// should reorder past during SARA AM decomposition. + /// Uses the (codepoint & ~0x80) trick to handle both Thai and Lao uniformly. + /// + private static bool IsAboveBaseMark(int codepoint) + { + int u = codepoint & ~0x0080; + return u is (>= 0x0E34 and <= 0x0E37) or (>= 0x0E47 and <= 0x0E4E) or 0x0E31 + or 0x0E3B; + } + + /// + /// State + action pair for state machine transitions. + /// + private readonly struct StateTransition(PuaAction action, int nextState) + { + public PuaAction Action { get; } = action; + + public int NextState { get; } = nextState; + } + + /// + /// PUA mapping entry: original codepoint, Windows PUA, Mac PUA. + /// + private readonly struct PuaMapping(ushort original, ushort winPua, ushort macPua) + { + public ushort Original { get; } = original; + + public ushort WinPua { get; } = winPua; + + public ushort MacPua { get; } = macPua; + } +} diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/UnicodeScriptTagMap.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/UnicodeScriptTagMap.cs index 8a0e2fbd..175e833b 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/UnicodeScriptTagMap.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/UnicodeScriptTagMap.cs @@ -99,7 +99,7 @@ private static UnicodeScriptTagMap CreateMap() { ScriptClass.Kannada, new[] { Tag.Parse("knd2"), Tag.Parse("knda") } }, { ScriptClass.Kaithi, new[] { Tag.Parse("kthi") } }, { ScriptClass.TaiTham, new[] { Tag.Parse("lana") } }, - { ScriptClass.Lao, new[] { Tag.Parse("laoo") } }, + { ScriptClass.Lao, new[] { Tag.Parse("lao ") } }, { ScriptClass.Latin, new[] { Tag.Parse("latn") } }, { ScriptClass.Lepcha, new[] { Tag.Parse("lepc") } }, { ScriptClass.Limbu, new[] { Tag.Parse("limb") } }, diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index 8550cc8f..4f8e05ca 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -1113,6 +1113,7 @@ private static TextBox BreakLines( int graphemeIndex; int codePointIndex = 0; + int glyphSearchIndex = 0; List textLines = []; TextLine textLine = new(); int stringIndex = 0; @@ -1133,6 +1134,7 @@ private static TextBox BreakLines( { if (!positionings.TryGetGlyphMetricsAtOffset( codePointIndex, + ref glyphSearchIndex, out float pointSize, out bool isSubstituted, out bool isVerticalSubstitution, diff --git a/tests/Images/ReferenceOutput/Test_Issue_488_Hebrew-.png b/tests/Images/ReferenceOutput/Test_Issue_488_Hebrew-.png new file mode 100644 index 00000000..6a4f344d --- /dev/null +++ b/tests/Images/ReferenceOutput/Test_Issue_488_Hebrew-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c2c1b10092633740dab8ea7aca801bd0de19b36bf823ae533c6026a43ba64b8 +size 2879 diff --git a/tests/Images/ReferenceOutput/Test_Issue_488_Lao-.png b/tests/Images/ReferenceOutput/Test_Issue_488_Lao-.png new file mode 100644 index 00000000..eeb8ec83 --- /dev/null +++ b/tests/Images/ReferenceOutput/Test_Issue_488_Lao-.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ca380d5024c2a345aff450ad8b3a129b5be80421fd2698b3b0d303ec80231ce +size 7093 diff --git a/tests/SixLabors.Fonts.Benchmarks/SixLabors.Fonts.Benchmarks/MeasureTextBenchmark.cs b/tests/SixLabors.Fonts.Benchmarks/SixLabors.Fonts.Benchmarks/MeasureTextBenchmark.cs index 04495cb4..674cac2b 100644 --- a/tests/SixLabors.Fonts.Benchmarks/SixLabors.Fonts.Benchmarks/MeasureTextBenchmark.cs +++ b/tests/SixLabors.Fonts.Benchmarks/SixLabors.Fonts.Benchmarks/MeasureTextBenchmark.cs @@ -14,7 +14,7 @@ namespace SixLabors.Fonts.Benchmarks; /// /// We should see if we can include the Skia HarfBuzz extensions to see how we compare. /// -[ShortRunJob] +[MediumRunJob] public class MeasureTextBenchmark : IDisposable { private readonly TextOptions textOptions; diff --git a/tests/SixLabors.Fonts.Tests/Fonts/NotoSansHebrew-Regular.ttf b/tests/SixLabors.Fonts.Tests/Fonts/NotoSansHebrew-Regular.ttf new file mode 100644 index 00000000..b44a0db0 Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/NotoSansHebrew-Regular.ttf differ diff --git a/tests/SixLabors.Fonts.Tests/Fonts/NotoSerifLao-Regular.ttf b/tests/SixLabors.Fonts.Tests/Fonts/NotoSerifLao-Regular.ttf new file mode 100644 index 00000000..8b1cd012 Binary files /dev/null and b/tests/SixLabors.Fonts.Tests/Fonts/NotoSerifLao-Regular.ttf differ diff --git a/tests/SixLabors.Fonts.Tests/Issues/Issues_488.cs b/tests/SixLabors.Fonts.Tests/Issues/Issues_488.cs index 38a54f71..3ba02b08 100644 --- a/tests/SixLabors.Fonts.Tests/Issues/Issues_488.cs +++ b/tests/SixLabors.Fonts.Tests/Issues/Issues_488.cs @@ -68,4 +68,38 @@ public void Test_Issue_488_Myanmar() TextLayoutTestUtilities.TestLayout(text, options); } + + [Fact] + public void Test_Issue_488_Lao() + { + const string text = "ໝາຈິ້ງຈອກສີນ້ຳຕານທີ່ວ່ອງໄວກະໂດດຂ້າມໝາຂີ້ຄ້ານ ສະຫຼັບ: ກ່ ງ່ ຍ່"; + + FontCollection fontCollection = new(); + string name = fontCollection.Add(TestFonts.LaoSerifRegular).Name; + + FontFamily mainFontFamily = fontCollection.Get(name); + Font mainFont = mainFontFamily.CreateFont(30, FontStyle.Regular); + + TextOptions options = new(mainFont); + + TextLayoutTestUtilities.TestLayout(text, options); + } + + [Fact] + public void Test_Issue_488_Hebrew() + { + // Exercises: dagesh (בּ כּ), shin/sin dots (שׁ שׂ), stacked marks (בָּ), + // hataf vowels (אֱ), holam (לֹ), hiriq (הִ), meteg with vowels (בָֽ). + const string text = "בְּרֵאשִׁית בָּרָא אֱלֹהִים שָׁלוֹם שָׂרָה כָּבוֹד בָֽרְכוּ"; + + FontCollection fontCollection = new(); + string name = fontCollection.Add(TestFonts.NotoSansHebrewRegular).Name; + + FontFamily mainFontFamily = fontCollection.Get(name); + Font mainFont = mainFontFamily.CreateFont(30, FontStyle.Regular); + + TextOptions options = new(mainFont); + + TextLayoutTestUtilities.TestLayout(text, options); + } } diff --git a/tests/SixLabors.Fonts.Tests/TestFonts.cs b/tests/SixLabors.Fonts.Tests/TestFonts.cs index af9b592c..4a3bbe7b 100644 --- a/tests/SixLabors.Fonts.Tests/TestFonts.cs +++ b/tests/SixLabors.Fonts.Tests/TestFonts.cs @@ -1,13 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. -using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Reflection; -using Xunit; namespace SixLabors.Fonts.Tests; @@ -377,10 +372,14 @@ public static class TestFonts public static string MyanmarSansRegular => GetFullPath("NotoSansMyanmar-Regular.ttf"); + public static string LaoSerifRegular => GetFullPath("NotoSerifLao-Regular.ttf"); + public static string NotoSansOghamRegular => GetFullPath("NotoSansOgham-Regular.ttf"); public static string NotoSansRunicRegular => GetFullPath("NotoSansRunic-Regular.ttf"); + public static string NotoSansHebrewRegular => GetFullPath("NotoSansHebrew-Regular.ttf"); + public static string MgOpenCanonicRegular => GetFullPath("mgopencanonicaregular.ttf"); public static Stream TwemojiMozillaData() => OpenStream(TwemojiMozillaFile);