ImageSharp/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs
2026-08-03 22:31:27 +02:00

528 lines
22 KiB
C#

// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using SixLabors.Fonts.Unicode;
using System;
namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers {
/// <summary>
/// This is a shaper for the Hangul script, used by the Korean language.
/// The shaping state machine was ported from fontkit.
/// <see href="https://github.com/foliojs/fontkit/blob/master/src/opentype/shapers/HangulShaper.js"/>
/// </summary>
internal sealed class HangulShaper : DefaultShaper
{
/// <summary>The 'ljmo' (leading Jamo forms) feature tag.</summary>
private static readonly Tag LjmoTag = Tag.Parse("ljmo");
/// <summary>The 'vjmo' (vowel Jamo forms) feature tag.</summary>
private static readonly Tag VjmoTag = Tag.Parse("vjmo");
/// <summary>The 'tjmo' (trailing Jamo forms) feature tag.</summary>
private static readonly Tag TjmoTag = Tag.Parse("tjmo");
/// <summary>The base code point for precomposed Hangul syllables (U+AC00).</summary>
private const int HangulBase = 0xac00;
/// <summary>The base code point for leading consonant Jamo (U+1100).</summary>
private const int LBase = 0x1100; // lead
/// <summary>The base code point for vowel Jamo (U+1161).</summary>
private const int VBase = 0x1161; // vowel
/// <summary>The base code point for trailing consonant Jamo (U+11A7).</summary>
private const int TBase = 0x11a7; // trail
/// <summary>The number of leading consonant Jamo.</summary>
private const int LCount = 19;
/// <summary>The number of vowel Jamo.</summary>
private const int VCount = 21;
/// <summary>The number of trailing consonant Jamo (including no-trail).</summary>
private const int TCount = 28;
/// <summary>The last leading consonant Jamo code point.</summary>
private const int LEnd = LBase + LCount - 1;
/// <summary>The last vowel Jamo code point.</summary>
private const int VEnd = VBase + VCount - 1;
/// <summary>The last trailing consonant Jamo code point.</summary>
private const int TEnd = TBase + TCount - 1;
/// <summary>The dotted circle code point (U+25CC) used as a placeholder base.</summary>
private const int DottedCircle = 0x25cc;
/// <summary>Other character category.</summary>
private const byte X = 0;
/// <summary>Leading consonant category.</summary>
private const byte L = 1;
/// <summary>Medial vowel category.</summary>
private const byte V = 2;
/// <summary>Trailing consonant category.</summary>
private const byte T = 3;
/// <summary>Composed lead-vowel syllable category.</summary>
private const byte LV = 4;
/// <summary>Composed lead-vowel-trail syllable category.</summary>
private const byte LVT = 5;
/// <summary>Tone mark category.</summary>
private const byte M = 6;
/// <summary>No action.</summary>
private const byte None = 0;
/// <summary>Decompose composed syllable action.</summary>
private const byte Decompose = 1;
/// <summary>Compose Jamo sequence action.</summary>
private const byte Compose = 2;
/// <summary>Reorder tone mark action.</summary>
private const byte ToneMark = 4;
/// <summary>Invalid sequence (insert dotted circle) action.</summary>
private const byte Invalid = 5;
/// <summary>
/// State machine table for Hangul syllable composition/decomposition.
/// Each entry is [action, nextState]. Rows are states, columns are character categories.
/// </summary>
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: <L>
{ 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: <L,V> or <LV>
{ 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: <L,V,T> or <LVT>
{ 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 } },
};
/// <summary>The font metrics used for glyph lookups during composition/decomposition.</summary>
private readonly FontMetrics fontMetrics;
/// <summary>
/// Initializes a new instance of the <see cref="HangulShaper"/> class.
/// </summary>
/// <param name="script">The script classification.</param>
/// <param name="textOptions">The text options.</param>
/// <param name="fontMetrics">The font metrics for glyph lookups.</param>
public HangulShaper(ScriptClass script, TextOptions textOptions, FontMetrics fontMetrics)
: base(script, MarkZeroingMode.None, textOptions)
=> this.fontMetrics = fontMetrics;
/// <inheritdoc/>
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);
}
/// <inheritdoc/>
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<ushort> 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;
}
}
}
}
/// <summary>
/// Gets the Hangul syllable type category for a code point.
/// </summary>
/// <param name="codePoint">The code point to classify.</param>
/// <returns>The syllable type constant (L, V, T, LV, LVT, M, or X).</returns>
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,
};
}
/// <summary>
/// Gets the number of Jamo components in a syllable for tone mark reordering.
/// </summary>
/// <param name="codePoint">The code point to measure.</param>
/// <returns>The syllable length in Jamo components.</returns>
private static int GetSyllableLength(CodePoint codePoint)
=> GetSyllableType(codePoint) switch
{
LV or LVT => 1,
V => 2,
T => 3,
_ => 0,
};
/// <summary>
/// Decomposes a precomposed Hangul syllable into its constituent Jamo glyphs.
/// </summary>
/// <param name="collection">The glyph substitution collection.</param>
/// <param name="data">The shaping data for the composed syllable.</param>
/// <param name="index">The index of the glyph to decompose.</param>
/// <param name="compositinoBuffer">A buffer for temporary glyph ID storage.</param>
/// <returns>The updated index after decomposition.</returns>
private int DecomposeGlyph(GlyphSubstitutionCollection collection, GlyphShapingData data, int index, Span<ushort> 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<ushort> 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<ushort> 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;
}
/// <summary>
/// Attempts to compose decomposed Jamo into a precomposed Hangul syllable.
/// </summary>
/// <param name="collection">The glyph substitution collection.</param>
/// <param name="index">The current index in the collection.</param>
/// <param name="type">The syllable type of the current glyph.</param>
/// <param name="compositionBuffer">A buffer for glyph IDs during composition.</param>
/// <returns>The updated index after composition.</returns>
private int ComposeGlyph(GlyphSubstitutionCollection collection, int index, int type, Span<ushort> 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,T>
lv = prevCodePoint;
tjmo = index;
}
else
{
if (type == V)
{
// <L,V>
ljmo = index - 1;
vjmo = index;
}
else
{
// <L,V,T>
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 <L,V>, 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;
}
/// <summary>
/// Reorders a tone mark to the beginning of the preceding syllable.
/// </summary>
/// <param name="collection">The glyph substitution collection.</param>
/// <param name="data">The shaping data of the tone mark glyph.</param>
/// <param name="index">The index of the tone mark in the collection.</param>
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);
}
/// <summary>
/// Inserts a dotted circle glyph as a placeholder for an invalid tone mark that has no syllable to attach to.
/// </summary>
/// <param name="collection">The glyph substitution collection.</param>
/// <param name="data">The shaping data of the invalid tone mark glyph.</param>
/// <param name="index">The index of the tone mark in the collection.</param>
/// <param name="compositionBuffer">A buffer for glyph IDs during insertion.</param>
/// <returns>The updated index after insertion.</returns>
private int InsertDottedCircle(GlyphSubstitutionCollection collection, GlyphShapingData data, int index, Span<ushort> 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<ushort> 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;
}
/// <summary>
/// Determines whether the code point is a combining leading consonant Jamo.
/// </summary>
/// <param name="code">The code point to test.</param>
/// <returns><see langword="true"/> if the code point is in the leading Jamo range.</returns>
private static bool IsCombiningL(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, LBase, LEnd);
/// <summary>
/// Determines whether the code point is a combining vowel Jamo.
/// </summary>
/// <param name="code">The code point to test.</param>
/// <returns><see langword="true"/> if the code point is in the vowel Jamo range.</returns>
private static bool IsCombiningV(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, VBase, VEnd);
/// <summary>
/// Determines whether the code point is a combining trailing consonant Jamo.
/// </summary>
/// <param name="code">The code point to test.</param>
/// <returns><see langword="true"/> if the code point is in the trailing Jamo range.</returns>
private static bool IsCombiningT(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, TBase + 1, TEnd);
}
}