// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using SixLabors.Fonts.Unicode; using System; namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers { /// /// This is a shaper for the Hangul script, used by the Korean language. /// The shaping state machine was ported from fontkit. /// /// internal sealed class HangulShaper : DefaultShaper { /// The 'ljmo' (leading Jamo forms) feature tag. private static readonly Tag LjmoTag = Tag.Parse("ljmo"); /// The 'vjmo' (vowel Jamo forms) feature tag. private static readonly Tag VjmoTag = Tag.Parse("vjmo"); /// The 'tjmo' (trailing Jamo forms) feature tag. private static readonly Tag TjmoTag = Tag.Parse("tjmo"); /// The base code point for precomposed Hangul syllables (U+AC00). private const int HangulBase = 0xac00; /// The base code point for leading consonant Jamo (U+1100). private const int LBase = 0x1100; // lead /// The base code point for vowel Jamo (U+1161). private const int VBase = 0x1161; // vowel /// The base code point for trailing consonant Jamo (U+11A7). private const int TBase = 0x11a7; // trail /// The number of leading consonant Jamo. private const int LCount = 19; /// The number of vowel Jamo. private const int VCount = 21; /// The number of trailing consonant Jamo (including no-trail). private const int TCount = 28; /// The last leading consonant Jamo code point. private const int LEnd = LBase + LCount - 1; /// The last vowel Jamo code point. private const int VEnd = VBase + VCount - 1; /// The last trailing consonant Jamo code point. private const int TEnd = TBase + TCount - 1; /// The dotted circle code point (U+25CC) used as a placeholder base. private const int DottedCircle = 0x25cc; /// Other character category. private const byte X = 0; /// Leading consonant category. private const byte L = 1; /// Medial vowel category. private const byte V = 2; /// Trailing consonant category. private const byte T = 3; /// Composed lead-vowel syllable category. private const byte LV = 4; /// Composed lead-vowel-trail syllable category. private const byte LVT = 5; /// Tone mark category. private const byte M = 6; /// No action. private const byte None = 0; /// Decompose composed syllable action. private const byte Decompose = 1; /// Compose Jamo sequence action. private const byte Compose = 2; /// Reorder tone mark action. private const byte ToneMark = 4; /// Invalid sequence (insert dotted circle) action. private const byte Invalid = 5; /// /// State machine table for Hangul syllable composition/decomposition. /// Each entry is [action, nextState]. Rows are states, columns are character categories. /// private static readonly byte[,][] StateTable = { // # X L V T LV LVT M // State 0: start state { new byte[] { None, 0 }, new byte[] { None, 1 }, new byte[] { None, 0 }, new byte[] { None, 0 }, new byte[] { Decompose, 2 }, new byte[] { Decompose, 3 }, new byte[] { Invalid, 0 } }, // State 1: { new byte[] { None, 0 }, new byte[] { None, 1 }, new byte[] { Compose, 2 }, new byte[] { None, 0 }, new byte[] { Decompose, 2 }, new byte[] { Decompose, 3 }, new byte[] { Invalid, 0 } }, // State 2: or { new byte[] { None, 0 }, new byte[] { None, 1 }, new byte[] { None, 0 }, new byte[] { Compose, 3 }, new byte[] { Decompose, 2 }, new byte[] { Decompose, 3 }, new byte[] { ToneMark, 0 } }, // State 3: or { new byte[] { None, 0 }, new byte[] { None, 1 }, new byte[] { None, 0 }, new byte[] { None, 0 }, new byte[] { Decompose, 2 }, new byte[] { Decompose, 3 }, new byte[] { ToneMark, 0 } }, }; /// The font metrics used for glyph lookups during composition/decomposition. private readonly FontMetrics fontMetrics; /// /// Initializes a new instance of the class. /// /// The script classification. /// The text options. /// The font metrics for glyph lookups. public HangulShaper(ScriptClass script, TextOptions textOptions, FontMetrics fontMetrics) : base(script, MarkZeroingMode.None, textOptions) => this.fontMetrics = fontMetrics; /// protected override void PlanFeatures(IGlyphShapingCollection collection, int index, int count) { this.AddFeature(collection, index, count, LjmoTag, false); this.AddFeature(collection, index, count, VjmoTag, false); this.AddFeature(collection, index, count, TjmoTag, false); } /// protected override void AssignFeatures(IGlyphShapingCollection collection, int index, int count) { for (int i = index; i < count; i++) { // Uniscribe does not apply 'calt' for Hangul, and certain fonts // (Noto Sans CJK, Source Sans Han, etc) apply all of jamo lookups // in calt, which is not desirable. collection.DisableShapingFeature(i, CaltTag); } // Apply the state machine to map glyphs to features. if (collection is GlyphSubstitutionCollection substitutionCollection) { // Allocate a small buffer for composition operations. Span compositionBuffer = stackalloc ushort[3]; // GSub int state = 0; for (int i = 0; i < count; i++) { if (i + index >= substitutionCollection.Count) { break; } GlyphShapingData data = substitutionCollection[i + index]; CodePoint codePoint = data.CodePoint; int type = GetSyllableType(codePoint); byte[] actionsWithState = StateTable[state, type]; byte action = actionsWithState[0]; state = actionsWithState[1]; // TODO: Do not stackalloc in the loop. switch (action) { case Decompose: // Decompose the composed syllable if it is not supported by the font. if (data.GlyphId == 0) { i = this.DecomposeGlyph(substitutionCollection, data, i, compositionBuffer); } break; case Compose: // Found a decomposed syllable. Try to compose if supported by the font. i = this.ComposeGlyph(substitutionCollection, i, type, compositionBuffer); break; case ToneMark: // Got a valid syllable, followed by a tone mark. Move the tone mark to the beginning of the syllable. this.ReOrderToneMark(substitutionCollection, data, i); break; case Invalid: // Tone mark has no valid syllable to attach to, so insert a dotted circle. i = this.InsertDottedCircle(substitutionCollection, data, i, compositionBuffer); break; } } } else { // GPos // Simply loop and enable based on type. // Glyph substitution has handled [de]composition. for (int i = 0; i < count; i++) { if (i + index >= collection.Count) { break; } GlyphShapingData data = collection[i + index]; CodePoint codePoint = data.CodePoint; switch (GetSyllableType(codePoint)) { case L: collection.EnableShapingFeature(i, LjmoTag); break; case V: collection.EnableShapingFeature(i, VjmoTag); break; case T: collection.EnableShapingFeature(i, TjmoTag); break; case LV: collection.EnableShapingFeature(i, LjmoTag); collection.EnableShapingFeature(i, VjmoTag); break; case LVT: collection.EnableShapingFeature(i, LjmoTag); collection.EnableShapingFeature(i, VjmoTag); collection.EnableShapingFeature(i, TjmoTag); break; } } } } /// /// Gets the Hangul syllable type category for a code point. /// /// The code point to classify. /// The syllable type constant (L, V, T, LV, LVT, M, or X). private static int GetSyllableType(CodePoint codePoint) { GraphemeClusterClass type = CodePoint.GetGraphemeClusterClass(codePoint); int value = codePoint.Value; return type switch { GraphemeClusterClass.HangulLead => L, GraphemeClusterClass.HangulVowel => V, GraphemeClusterClass.HangulTail => T, GraphemeClusterClass.HangulLeadVowel => LV, GraphemeClusterClass.HangulLeadVowelTail => LVT, // HANGUL SINGLE DOT TONE MARK // HANGUL DOUBLE DOT TONE MARK _ => value is >= 0x302E and <= 0x302F ? M : X, }; } /// /// Gets the number of Jamo components in a syllable for tone mark reordering. /// /// The code point to measure. /// The syllable length in Jamo components. private static int GetSyllableLength(CodePoint codePoint) => GetSyllableType(codePoint) switch { LV or LVT => 1, V => 2, T => 3, _ => 0, }; /// /// Decomposes a precomposed Hangul syllable into its constituent Jamo glyphs. /// /// The glyph substitution collection. /// The shaping data for the composed syllable. /// The index of the glyph to decompose. /// A buffer for temporary glyph ID storage. /// The updated index after decomposition. private int DecomposeGlyph(GlyphSubstitutionCollection collection, GlyphShapingData data, int index, Span compositinoBuffer) { // Decompose the syllable into a sequence of glyphs. int s = data.CodePoint.Value - HangulBase; int t = TBase + (s % TCount); s = (s / TCount) | 0; int l = (LBase + (s / VCount)) | 0; int v = VBase + (s % VCount); FontMetrics metrics = this.fontMetrics; // Don't decompose if all of the components are not available if (!metrics.TryGetGlyphId(new(l), out ushort ljmo) || !metrics.TryGetGlyphId(new(v), out ushort vjmo) || (!metrics.TryGetGlyphId(new(t), out ushort tjmo) && t != TBase)) { return index; } // Replace the current glyph with decomposed L, V, and T glyphs, // and apply the proper OpenType features to each component. if (t <= TBase) { Span ii = compositinoBuffer[..2]; ii[1] = vjmo; ii[0] = ljmo; collection.Replace(index, ii, KnownFeatureTags.GlyphCompositionDecomposition); collection.EnableShapingFeature(index, LjmoTag); collection.EnableShapingFeature(index + 1, VjmoTag); return index + 1; } Span iii = compositinoBuffer[..3]; iii[2] = tjmo; iii[1] = vjmo; iii[0] = ljmo; collection.Replace(index, iii, KnownFeatureTags.GlyphCompositionDecomposition); collection.EnableShapingFeature(index, LjmoTag); collection.EnableShapingFeature(index + 1, VjmoTag); collection.EnableShapingFeature(index + 2, TjmoTag); return index + 2; } /// /// Attempts to compose decomposed Jamo into a precomposed Hangul syllable. /// /// The glyph substitution collection. /// The current index in the collection. /// The syllable type of the current glyph. /// A buffer for glyph IDs during composition. /// The updated index after composition. private int ComposeGlyph(GlyphSubstitutionCollection collection, int index, int type, Span compositionBuffer) { if (index == 0) { return index; } GlyphShapingData prev = collection[index - 1]; CodePoint prevCodePoint = prev.CodePoint; int prevType = GetSyllableType(prevCodePoint); // Figure out what type of syllable we're dealing with CodePoint lv = default; int ljmo = -1, vjmo = -1, tjmo = -1; if (prevType == LV && type == T) { // lv = prevCodePoint; tjmo = index; } else { if (type == V) { // ljmo = index - 1; vjmo = index; } else { // ljmo = index - 2; vjmo = index - 1; tjmo = index; } CodePoint l = collection[ljmo].CodePoint; CodePoint v = collection[vjmo].CodePoint; // Make sure L and V are combining characters if (IsCombiningL(l) && IsCombiningV(v)) { lv = new CodePoint(HangulBase + ((((l.Value - LBase) * VCount) + (v.Value - VBase)) * TCount)); } } CodePoint t = tjmo >= 0 ? collection[tjmo].CodePoint : new CodePoint(TBase); if ((lv != default) && (t.Value == TBase || IsCombiningT(t))) { CodePoint s = new(lv.Value + (t.Value - TBase)); // Replace with a composed glyph if supported by the font, // otherwise apply the proper OpenType features to each component. if (this.fontMetrics.TryGetGlyphId(s, out ushort id)) { int del = prevType == V ? 3 : 2; int idx = index - del + 1; collection.Replace(idx, del - 1, id, KnownFeatureTags.GlyphCompositionDecomposition); collection[idx].CodePoint = s; return idx; } } // Didn't compose (either a non-combining component or unsupported by font). if (ljmo >= 0) { collection.EnableShapingFeature(ljmo, LjmoTag); } if (vjmo >= 0) { collection.EnableShapingFeature(vjmo, VjmoTag); } if (tjmo >= 0) { collection.EnableShapingFeature(tjmo, TjmoTag); } if (prevType == LV) { // Sequence was originally , which got combined earlier. // Either the T was non-combining, or the LVT glyph wasn't supported. // Decompose the glyph again and apply OT features. this.DecomposeGlyph(collection, collection[index - 1], index - 1, compositionBuffer); return index + 1; } return index; } /// /// Reorders a tone mark to the beginning of the preceding syllable. /// /// The glyph substitution collection. /// The shaping data of the tone mark glyph. /// The index of the tone mark in the collection. private void ReOrderToneMark(GlyphSubstitutionCollection collection, GlyphShapingData data, int index) { if (index == 0) { return; } // Move tone mark to the beginning of the previous syllable, unless it is zero width // We don't have access to the glyphs metrics as an array when substituting so we have to loop. FontMetrics fontMetrics = this.fontMetrics; TextAttributes textAttributes = data.TextRun.TextAttributes; TextDecorations textDecorations = data.TextRun.TextDecorations; LayoutMode layoutMode = collection.TextOptions.LayoutMode; ColorFontSupport colorFontSupport = collection.TextOptions.ColorFontSupport; if (fontMetrics.TryGetGlyphMetrics(data.CodePoint, textAttributes, textDecorations, layoutMode, colorFontSupport, out FontGlyphMetrics? metrics) && metrics.AdvanceWidth == 0) { return; } GlyphShapingData prev = collection[index - 1]; int len = GetSyllableLength(prev.CodePoint); collection.MoveGlyph(index, index - len); } /// /// Inserts a dotted circle glyph as a placeholder for an invalid tone mark that has no syllable to attach to. /// /// The glyph substitution collection. /// The shaping data of the invalid tone mark glyph. /// The index of the tone mark in the collection. /// A buffer for glyph IDs during insertion. /// The updated index after insertion. private int InsertDottedCircle(GlyphSubstitutionCollection collection, GlyphShapingData data, int index, Span compositionBuffer) { bool after = false; FontMetrics fontMetrics = this.fontMetrics; if (fontMetrics.TryGetGlyphId(new(DottedCircle), out ushort id)) { TextAttributes textAttributes = data.TextRun.TextAttributes; TextDecorations textDecorations = data.TextRun.TextDecorations; LayoutMode layoutMode = collection.TextOptions.LayoutMode; ColorFontSupport colorFontSupport = collection.TextOptions.ColorFontSupport; if (fontMetrics.TryGetGlyphMetrics(data.CodePoint, textAttributes, textDecorations, layoutMode, colorFontSupport, out FontGlyphMetrics? metrics) && metrics.AdvanceWidth != 0) { after = true; } // If the tone mark is zero width, insert the dotted circle before, otherwise after Span glyphs = compositionBuffer[..2]; if (after) { glyphs[1] = id; glyphs[0] = data.GlyphId; } else { glyphs[1] = data.GlyphId; glyphs[0] = id; } collection.Replace(index, glyphs, KnownFeatureTags.GlyphCompositionDecomposition); return index + 1; } return index; } /// /// Determines whether the code point is a combining leading consonant Jamo. /// /// The code point to test. /// if the code point is in the leading Jamo range. private static bool IsCombiningL(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, LBase, LEnd); /// /// Determines whether the code point is a combining vowel Jamo. /// /// The code point to test. /// if the code point is in the vowel Jamo range. private static bool IsCombiningV(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, VBase, VEnd); /// /// Determines whether the code point is a combining trailing consonant Jamo. /// /// The code point to test. /// if the code point is in the trailing Jamo range. private static bool IsCombiningT(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, TBase + 1, TEnd); } }