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