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);