diff --git a/src/SixLabors.Fonts/GlyphMetrics.cs b/src/SixLabors.Fonts/GlyphMetrics.cs index 2f23484b..ada1e482 100644 --- a/src/SixLabors.Fonts/GlyphMetrics.cs +++ b/src/SixLabors.Fonts/GlyphMetrics.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.CompilerServices; using SixLabors.Fonts.Tables.General; using SixLabors.Fonts.Tables.General.Glyphs; using SixLabors.Fonts.Unicode; @@ -237,6 +238,12 @@ internal FontRectangle GetBoundingBox(Vector2 origin, float scaledPointSize) /// Too many control points internal void RenderTo(IGlyphRenderer surface, float pointSize, Vector2 location, TextOptions options) { + // https://www.unicode.org/faq/unsup_char.html + if (ShouldSkipGlyphRendering(this.CodePoint)) + { + return; + } + float dpi = options.Dpi; location *= dpi; float scaledPPEM = dpi * pointSize; @@ -255,7 +262,7 @@ internal void RenderTo(IGlyphRenderer surface, float pointSize, Vector2 location if (surface.BeginGlyph(box, parameters)) { - if (!CodePoint.IsWhiteSpace(this.CodePoint)) + if (!ShouldRenderWhiteSpaceOnly(this.CodePoint)) { if (this.GlyphColor.HasValue && surface is IColorGlyphRenderer colorSurface) { @@ -452,6 +459,40 @@ void SetDecoration(TextDecorations decorationType, float thickness, float positi surface.EndGlyph(); } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ShouldSkipGlyphRendering(CodePoint codePoint) + { + uint value = (uint)codePoint.Value; + return UnicodeUtility.IsDefaultIgnorableCodePoint(value) && !ShouldRenderWhiteSpaceOnly(codePoint); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool ShouldRenderWhiteSpaceOnly(CodePoint codePoint) + { + if (CodePoint.IsWhiteSpace(codePoint)) + { + return true; + } + + // Note: While U+115F, U+1160, U+3164 and U+FFA0 are Default_Ignorable, + // we do NOT want to hide them, as the way Uniscribe has implemented them + // is with regular spacing glyphs, and that's the way fonts are made to work. + // As such, we make exceptions for those four. + // Also ignoring U+1BCA0..1BCA3. https://github.com/harfbuzz/harfbuzz/issues/503 + uint value = (uint)codePoint.Value; + if (value is 0x115F or 0x1160 or 0x3164 or 0xFFA0) + { + return true; + } + + if (UnicodeUtility.IsInRangeInclusive(value, 0x1BCA0, 0x1BCA3)) + { + return true; + } + + return false; + } + private static void AlignToGrid(ref Vector2 point) { var floorPoint = new Vector2(MathF.Floor(point.X), MathF.Floor(point.Y)); diff --git a/src/SixLabors.Fonts/GlyphPositioningCollection.cs b/src/SixLabors.Fonts/GlyphPositioningCollection.cs index 11a68bdc..edccab85 100644 --- a/src/SixLabors.Fonts/GlyphPositioningCollection.cs +++ b/src/SixLabors.Fonts/GlyphPositioningCollection.cs @@ -1,9 +1,9 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using SixLabors.Fonts.Tables.AdvancedTypographic; using SixLabors.Fonts.Unicode; @@ -15,20 +15,9 @@ namespace SixLabors.Fonts internal sealed class GlyphPositioningCollection : IGlyphShapingCollection { /// - /// Contains a map between the index of a map within the collection, it's codepoint - /// and glyph ids. + /// Contains a map the index of a map within the collection, non-sequential codepoint offsets, and their glyph ids, point size, and mtrics. /// - private readonly List glyphs = new(); - - /// - /// Contains a map between the index of a map within the collection and its offset. - /// - private readonly List offsets = new(); - - /// - /// Contains a map between non-sequential codepoint offsets and their glyphs. - /// - private readonly Dictionary map = new(); + private readonly List glyphs = new(); /// /// Initializes a new instance of the class. @@ -41,7 +30,7 @@ public GlyphPositioningCollection(TextOptions textOptions) } /// - public int Count => this.offsets.Count; + public int Count => this.glyphs.Count; /// public bool IsVerticalLayoutMode { get; } @@ -50,23 +39,47 @@ public GlyphPositioningCollection(TextOptions textOptions) public TextOptions TextOptions { get; } /// - public ReadOnlySpan this[int index] => this.glyphs[index].GlyphIds; + public ushort this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => this.glyphs[index].Data.GlyphId; + } /// - public GlyphShapingData GetGlyphShapingData(int index) => this.glyphs[index]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GlyphShapingData GetGlyphShapingData(int index) => this.glyphs[index].Data; /// public void AddShapingFeature(int index, TagEntry feature) - => this.glyphs[index].Features.Add(feature); + => this.glyphs[index].Data.Features.Add(feature); /// public void EnableShapingFeature(int index, Tag feature) { - foreach (TagEntry tagEntry in this.glyphs[index].Features) + List features = this.glyphs[index].Data.Features; + for (int i = 0; i < features.Count; i++) { + TagEntry tagEntry = features[i]; if (tagEntry.Tag == feature) { tagEntry.Enabled = true; + features[i] = tagEntry; + break; + } + } + } + + /// + public void DisableShapingFeature(int index, Tag feature) + { + List features = this.glyphs[index].Data.Features; + for (int i = 0; i < features.Count; i++) + { + TagEntry tagEntry = features[i]; + if (tagEntry.Tag == feature) + { + tagEntry.Enabled = false; + features[i] = tagEntry; break; } } @@ -77,24 +90,36 @@ public void EnableShapingFeature(int index, Tag feature) /// /// The zero-based index within the input codepoint collection. /// The font size in PT units of the font containing this glyph. + /// Whether the glyph is the result of a decomposition substitution. /// /// When this method returns, contains the glyph metrics associated with the specified offset, /// if the value is found; otherwise, the default value for the type of the metrics parameter. /// This parameter is passed uninitialized. /// /// The metrics. - public bool TryGetGlyphMetricsAtOffset(int offset, out float pointSize, [NotNullWhen(true)] out GlyphMetrics[]? metrics) + public bool TryGetGlyphMetricsAtOffset(int offset, out float pointSize, out bool isDecomposed, [NotNullWhen(true)] out IReadOnlyList? metrics) { - if (this.map.TryGetValue(offset, out PointSizeMetricsPair? entry)) + List match = new(); + pointSize = 0; + isDecomposed = false; + for (int i = 0; i < this.glyphs.Count; i++) { - pointSize = entry.PointSize; - metrics = entry.Metrics; - return true; + if (this.glyphs[i].Offset == offset) + { + GlyphPositioningData glyph = this.glyphs[i]; + isDecomposed = glyph.Data.IsDecomposed; + pointSize = glyph.PointSize; + match.AddRange(glyph.Metrics); + } + else if (match.Count > 0) + { + // Offsets, though non-sequential, are sorted, so we can stop searching. + break; + } } - pointSize = 0; - metrics = null; - return false; + metrics = match; + return match.Count > 0; } /// @@ -109,65 +134,76 @@ public bool TryUpdate(Font font, GlyphSubstitutionCollection collection) FontMetrics fontMetrics = font.FontMetrics; ColorFontSupport colorFontSupport = this.TextOptions.ColorFontSupport; bool hasFallBacks = false; - List orphans = new(); - for (int i = 0; i < this.offsets.Count; i++) + for (int i = 0; i < this.glyphs.Count; i++) { - int offset = this.offsets[i]; - if (!collection.TryGetGlyphShapingDataAtOffset(offset, out GlyphShapingData? data)) - { - // If a font had glyphs but a follow up font also has them and can substitute. e.g ligatures - // then we end up with orphaned fallbacks. We need to remove them. - orphans.Add(i); - continue; - } - - PointSizeMetricsPair pair = this.map[offset]; - if (pair.Metrics[0].GlyphType != GlyphType.Fallback) + GlyphPositioningData current = this.glyphs[i]; + if (current.Metrics[0].GlyphType != GlyphType.Fallback) { // We've already got the correct glyph. continue; } - CodePoint codePoint = data.CodePoint; - ushort[] glyphIds = data.GlyphIds; - var m = new List(glyphIds.Length); - - foreach (ushort id in glyphIds) + int offset = current.Offset; + float pointSize = current.PointSize; + if (collection.TryGetGlyphShapingDataAtOffset(offset, out IReadOnlyList? data)) { - // Perform a semi-deep clone (FontMetrics is not cloned) so we can continue to - // cache the original in the font metrics and only update our collection. - foreach (GlyphMetrics gm in fontMetrics.GetGlyphMetrics(codePoint, id, colorFontSupport)) + ushort shiftXY = 0; + int replacementCount = 0; + for (int j = 0; j < data.Count; j++) { - if (gm.GlyphType == GlyphType.Fallback && !CodePoint.IsControl(codePoint)) + GlyphShapingData shape = data[j]; + ushort id = shape.GlyphId; + CodePoint codePoint = shape.CodePoint; + bool isDecomposed = shape.IsDecomposed; + + // Perform a semi-deep clone (FontMetrics is not cloned) so we can continue to + // cache the original in the font metrics and only update our collection. + var metrics = new List(data.Count); + foreach (GlyphMetrics gm in fontMetrics.GetGlyphMetrics(codePoint, id, colorFontSupport)) { - // If the glyphs are fallbacks we don't want them as - // we've already captured them on the first run. - hasFallBacks = true; - break; + if (gm.GlyphType == GlyphType.Fallback && !CodePoint.IsControl(codePoint)) + { + // If the glyphs are fallbacks we don't want them as + // we've already captured them on the first run. + hasFallBacks = true; + break; + } + + // Clone and offset the glyph for rendering. + // If the glyph is the result of a decomposition substitution we need to offset it. + // We slip the text run in here while we clone so we have it available to the renderer. + var clone = GlyphMetrics.CloneForRendering(gm, shape.TextRun, codePoint); + if (isDecomposed) + { + if (!this.IsVerticalLayoutMode) + { + clone.ApplyOffset((short)shiftXY, 0); + shiftXY += clone.AdvanceWidth; + } + else + { + clone.ApplyOffset(0, (short)shiftXY); + shiftXY += clone.AdvanceHeight; + } + } + + metrics.Add(clone); } - // We slip the text run in here while we clone so we have - // it available to the renderer. - m.Add(GlyphMetrics.CloneForRendering(gm, data.TextRun, codePoint)); + if (metrics.Count > 0) + { + if (j == 0) + { + // There should only be a single fallback glyph at this position from the previous collection. + this.glyphs.RemoveAt(i); + } + + // Track the number of inserted glyphs at the offset so we can correctly increment our position. + this.glyphs.Insert(i += replacementCount, new(offset, new(shape, true) { Bounds = new(0, 0, metrics[0].AdvanceWidth, metrics[0].AdvanceHeight) }, pointSize, metrics.ToArray())); + replacementCount++; + } } } - - if (m.Count > 0) - { - GlyphMetrics[] gm = m.ToArray(); - this.map[offset] = new(pair.PointSize, gm); - this.glyphs[i] = new(data, true) { Bounds = new(0, 0, gm[0].AdvanceWidth, gm[0].AdvanceHeight) }; - this.offsets[i] = offset; - } - } - - // Remove any orphans. - for (int i = orphans.Count - 1; i >= 0; i--) - { - int idx = orphans[i]; - this.map.Remove(this.offsets[idx]); - this.offsets.RemoveAt(idx); - this.glyphs.RemoveAt(idx); } return !hasFallBacks; @@ -185,44 +221,61 @@ public bool TryAdd(Font font, GlyphSubstitutionCollection collection) bool hasFallBacks = false; FontMetrics fontMetrics = font.FontMetrics; ColorFontSupport colorFontSupport = this.TextOptions.ColorFontSupport; + ushort shiftXY = 0; for (int i = 0; i < collection.Count; i++) { GlyphShapingData data = collection.GetGlyphShapingData(i, out int offset); CodePoint codePoint = data.CodePoint; - ushort[] glyphIds = data.GlyphIds; - var m = new List(glyphIds.Length); + ushort id = data.GlyphId; + List metrics = new(); - foreach (ushort id in glyphIds) + bool isDecomposed = data.IsDecomposed; + if (!isDecomposed) { - // Perform a semi-deep clone (FontMetrics is not cloned) so we can continue to - // cache the original in the font metrics and only update our collection. - foreach (GlyphMetrics gm in fontMetrics.GetGlyphMetrics(codePoint, id, colorFontSupport)) + shiftXY = 0; + } + + // Perform a semi-deep clone (FontMetrics is not cloned) so we can continue to + // cache the original in the font metrics and only update our collection. + foreach (GlyphMetrics gm in fontMetrics.GetGlyphMetrics(codePoint, id, colorFontSupport)) + { + if (gm.GlyphType == GlyphType.Fallback && !CodePoint.IsControl(codePoint)) { - if (gm.GlyphType == GlyphType.Fallback && !CodePoint.IsControl(codePoint)) + hasFallBacks = true; + } + + // Clone and offset the glyph for rendering. + // If the glyph is the result of a decomposition substitution we need to offset it. + // We slip the text run in here while we clone so we have it available to the renderer. + var clone = GlyphMetrics.CloneForRendering(gm, data.TextRun, codePoint); + if (isDecomposed) + { + if (!this.IsVerticalLayoutMode) { - hasFallBacks = true; + clone.ApplyOffset((short)shiftXY, 0); + shiftXY += clone.AdvanceWidth; + } + else + { + clone.ApplyOffset(0, (short)shiftXY); + shiftXY += clone.AdvanceHeight; } - - // We slip the text run in here while we clone so we have - // it available to the renderer. - m.Add(GlyphMetrics.CloneForRendering(gm, data.TextRun, codePoint)); } + + metrics.Add(clone); } - if (m.Count > 0) + if (metrics.Count > 0) { - GlyphMetrics[] gm = m.ToArray(); - this.map[offset] = new(font.Size, gm); + GlyphMetrics[] gm = metrics.ToArray(); if (this.IsVerticalLayoutMode) { - this.glyphs.Add(new(data, true) { Bounds = new(0, 0, 0, gm[0].AdvanceHeight) }); + this.glyphs.Add(new(offset, new(data, true) { Bounds = new(0, 0, 0, gm[0].AdvanceHeight) }, font.Size, gm)); } else { - this.glyphs.Add(new(data, true) { Bounds = new(0, 0, gm[0].AdvanceWidth, 0) }); + this.glyphs.Add(new(offset, new(data, true) { Bounds = new(0, 0, gm[0].AdvanceWidth, 0) }, font.Size, gm)); } - - this.offsets.Add(offset); } } @@ -244,8 +297,8 @@ public void UpdatePosition(FontMetrics fontMetrics, ushort index) return; } - ushort glyphId = data.GlyphIds[0]; - foreach (GlyphMetrics m in this.map[this.offsets[index]].Metrics) + ushort glyphId = data.GlyphId; + foreach (GlyphMetrics m in this.glyphs[index].Metrics) { if (m.GlyphId == glyphId && fontMetrics == m.FontMetrics) { @@ -274,7 +327,7 @@ public void UpdatePosition(FontMetrics fontMetrics, ushort index) /// The delta y-advance. public void Advance(FontMetrics fontMetrics, ushort index, ushort glyphId, short dx, short dy) { - foreach (GlyphMetrics m in this.map[this.offsets[index]].Metrics) + foreach (GlyphMetrics m in this.glyphs[index].Metrics) { if (m.GlyphId == glyphId && fontMetrics == m.FontMetrics) { @@ -287,19 +340,25 @@ public void Advance(FontMetrics fontMetrics, ushort index, ushort glyphId, short /// Returns a value indicating whether the element at the given index should be processed. /// /// The font face with metrics. - /// The zero-based index of the elements to offset. + /// The zero-based index of the elements to position. /// if the element should be processed; otherwise, . public bool ShouldProcess(FontMetrics fontMetrics, ushort index) - => this.map[this.offsets[index]].Metrics[0].FontMetrics == fontMetrics; + => this.glyphs[index].Metrics[0].FontMetrics == fontMetrics; - private class PointSizeMetricsPair + private class GlyphPositioningData { - public PointSizeMetricsPair(float pointSize, GlyphMetrics[] metrics) + public GlyphPositioningData(int offset, GlyphShapingData data, float pointSize, GlyphMetrics[] metrics) { + this.Offset = offset; + this.Data = data; this.PointSize = pointSize; this.Metrics = metrics; } + public int Offset { get; set; } + + public GlyphShapingData Data { get; set; } + public float PointSize { get; set; } public GlyphMetrics[] Metrics { get; set; } diff --git a/src/SixLabors.Fonts/GlyphShapingData.cs b/src/SixLabors.Fonts/GlyphShapingData.cs index 3019b476..717ce8ff 100644 --- a/src/SixLabors.Fonts/GlyphShapingData.cs +++ b/src/SixLabors.Fonts/GlyphShapingData.cs @@ -27,24 +27,30 @@ internal class GlyphShapingData /// Whether to clear features. public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) { + this.GlyphId = data.GlyphId; this.CodePoint = data.CodePoint; this.CodePointCount = data.CodePointCount; this.Direction = data.Direction; this.TextRun = data.TextRun; - this.GlyphIds = data.GlyphIds; this.LigatureId = data.LigatureId; this.LigatureComponent = data.LigatureComponent; this.MarkAttachment = data.MarkAttachment; this.CursiveAttachment = data.CursiveAttachment; + this.IsDecomposed = data.IsDecomposed; if (!clearFeatures) { - this.Features = data.Features; + this.Features = new(data.Features); } this.Bounds = data.Bounds; } + /// + /// Gets or sets the glyph id. + /// + public ushort GlyphId { get; set; } + /// /// Gets or sets the leading codepoint. /// @@ -65,11 +71,6 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) /// public TextRun TextRun { get; set; } - /// - /// Gets or sets the collection of glyph ids. - /// - public ushort[] GlyphIds { get; set; } = Array.Empty(); - /// /// Gets or sets the id of any ligature this glyph is a member of. /// @@ -100,8 +101,13 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false) /// public GlyphShapingBounds Bounds { get; set; } = new(0, 0, 0, 0); + /// + /// Gets or sets a value indicating whether this glyph is the result of a decomposition substitution + /// + public bool IsDecomposed { get; set; } + private string DebuggerDisplay => FormattableString - .Invariant($"{this.CodePoint.ToDebuggerDisplay()} : {CodePoint.GetScriptClass(this.CodePoint)} : {this.Direction} : {this.TextRun.TextAttributes} : {this.LigatureId} : {this.LigatureComponent} : [{string.Join(",", this.GlyphIds)}]"); + .Invariant($" {this.GlyphId} : {this.CodePoint.ToDebuggerDisplay()} : {CodePoint.GetScriptClass(this.CodePoint)} : {this.Direction} : {this.TextRun.TextAttributes} : {this.LigatureId} : {this.LigatureComponent} : {this.IsDecomposed}"); } } diff --git a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs index 509f0d7e..b6ed4159 100644 --- a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs +++ b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using SixLabors.Fonts.Tables.AdvancedTypographic; using SixLabors.Fonts.Unicode; @@ -15,14 +16,9 @@ namespace SixLabors.Fonts internal sealed class GlyphSubstitutionCollection : IGlyphShapingCollection { /// - /// Contains a map between the index of a map within the collection and its offset. + /// Contains a map the index of a map within the collection, non-sequential codepoint offsets, and their glyph ids. /// - private readonly List offsets = new(); - - /// - /// Contains a map between non-sequential codepoint offsets and their glyph ids. - /// - private readonly Dictionary glyphs = new(); + private readonly List glyphs = new(); /// /// Initializes a new instance of the class. @@ -38,7 +34,7 @@ public GlyphSubstitutionCollection(TextOptions textOptions) /// Gets the number of glyphs ids contained in the collection. /// This may be more or less than original input codepoint count (due to substitution process). /// - public int Count => this.offsets.Count; + public int Count => this.glyphs.Count; /// public bool IsVerticalLayoutMode { get; } @@ -52,11 +48,15 @@ public GlyphSubstitutionCollection(TextOptions textOptions) public int LigatureId { get; set; } = 1; /// - public ReadOnlySpan this[int index] => this.glyphs[this.offsets[index]].GlyphIds; + public ushort this[int index] + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => this.glyphs[index].Data.GlyphId; + } /// - public GlyphShapingData GetGlyphShapingData(int index) - => this.glyphs[this.offsets[index]]; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public GlyphShapingData GetGlyphShapingData(int index) => this.glyphs[index].Data; /// /// Gets the shaping data at the specified position. @@ -66,23 +66,42 @@ public GlyphShapingData GetGlyphShapingData(int index) /// The . internal GlyphShapingData GetGlyphShapingData(int index, out int offset) { - offset = this.offsets[index]; - return this.glyphs[offset]; + OffsetGlyphDataPair pair = this.glyphs[index]; + offset = pair.Offset; + return pair.Data; } /// public void AddShapingFeature(int index, TagEntry feature) - => this.glyphs[this.offsets[index]].Features.Add(feature); + => this.glyphs[index].Data.Features.Add(feature); /// public void EnableShapingFeature(int index, Tag feature) { - List features = this.glyphs[this.offsets[index]].Features; - foreach (TagEntry tagEntry in features) + List features = this.glyphs[index].Data.Features; + for (int i = 0; i < features.Count; i++) { + TagEntry tagEntry = features[i]; if (tagEntry.Tag == feature) { tagEntry.Enabled = true; + features[i] = tagEntry; + break; + } + } + } + + /// + public void DisableShapingFeature(int index, Tag feature) + { + List features = this.glyphs[index].Data.Features; + for (int i = 0; i < features.Count; i++) + { + TagEntry tagEntry = features[i]; + if (tagEntry.Tag == feature) + { + tagEntry.Enabled = false; + features[i] = tagEntry; break; } } @@ -97,14 +116,41 @@ public void EnableShapingFeature(int index, Tag feature) /// The text run this glyph belongs to. /// The zero-based index within the input codepoint collection. public void AddGlyph(ushort glyphId, CodePoint codePoint, TextDirection direction, TextRun textRun, int offset) - { - this.glyphs.Add(offset, new(textRun) + => this.glyphs.Add(new(offset, new(textRun) { CodePoint = codePoint, Direction = direction, - GlyphIds = new[] { glyphId }, - }); - this.offsets.Add(offset); + GlyphId = glyphId, + })); + + /// + /// Moves the specified glyph to the specified position. + /// + /// The index to move from. + /// The index to move to. + public void MoveGlyph(int fromIndex, int toIndex) + { + GlyphShapingData data = this.GetGlyphShapingData(fromIndex); + if (fromIndex > toIndex) + { + int idx = fromIndex; + while (idx > toIndex) + { + this.glyphs[idx].Data = this.glyphs[idx - 1].Data; + idx--; + } + } + else + { + int idx = toIndex; + while (idx > fromIndex) + { + this.glyphs[idx - 1].Data = this.glyphs[idx].Data; + idx--; + } + } + + this.glyphs[toIndex].Data = data; } /// @@ -112,7 +158,6 @@ public void AddGlyph(ushort glyphId, CodePoint codePoint, TextDirection directio /// public void Clear() { - this.offsets.Clear(); this.glyphs.Clear(); this.LigatureId = 1; } @@ -130,8 +175,25 @@ public void Clear() /// if the contains glyph ids /// for the specified offset; otherwise, . /// - public bool TryGetGlyphShapingDataAtOffset(int offset, [NotNullWhen(true)] out GlyphShapingData? data) - => this.glyphs.TryGetValue(offset, out data); + public bool TryGetGlyphShapingDataAtOffset(int offset, [NotNullWhen(true)] out IReadOnlyList? data) + { + List match = new(); + for (int i = 0; i < this.glyphs.Count; i++) + { + if (this.glyphs[i].Offset == offset) + { + match.Add(this.glyphs[i].Data); + } + else if (match.Count > 0) + { + // Offsets, though non-sequential, are sorted, so we can stop searching. + break; + } + } + + data = match; + return match.Count > 0; + } /// /// Performs a 1:1 replacement of a glyph id at the given position. @@ -139,11 +201,7 @@ public bool TryGetGlyphShapingDataAtOffset(int offset, [NotNullWhen(true)] out G /// The zero-based index of the element to replace. /// The replacement glyph id. public void Replace(int index, ushort glyphId) - { - int offset = this.offsets[index]; - GlyphShapingData current = this.glyphs[offset]; - current.GlyphIds = new[] { glyphId }; - } + => this.glyphs[index].Data.GlyphId = glyphId; /// /// Performs a 1:1 replacement of a glyph id at the given position while removing a series of glyph ids at the given positions within the sequence. @@ -155,29 +213,51 @@ public void Replace(int index, ushort glyphId) public void Replace(int index, ReadOnlySpan removalIndices, ushort glyphId, int ligatureId) { // Remove the glyphs at each index. - // TODO: We will have to offset these indices by the leading index of the collection - // that the current shaper is working against. int codePointCount = 0; for (int i = removalIndices.Length - 1; i >= 0; i--) { int match = removalIndices[i]; - int matchOffset = this.offsets[match]; - codePointCount += this.glyphs[matchOffset].CodePointCount; - this.glyphs.Remove(matchOffset); - this.offsets.RemoveAt(match); + codePointCount += this.glyphs[match].Data.CodePointCount; + this.glyphs.RemoveAt(match); } // Assign our new id at the index. - int offset = this.offsets[index]; - GlyphShapingData current = this.glyphs[offset]; + GlyphShapingData current = this.glyphs[index].Data; current.CodePointCount += codePointCount; - current.GlyphIds = new[] { glyphId }; + current.GlyphId = glyphId; current.LigatureId = ligatureId; current.LigatureComponent = -1; current.MarkAttachment = -1; current.CursiveAttachment = -1; } + /// + /// Performs a 1:1 replacement of a glyph id at the given position while removing a series of glyph ids. + /// + /// The zero-based index of the element to replace. + /// The number of glyphs to remove. + /// The replacement glyph id. + public void Replace(int index, int count, ushort glyphId) + { + // Remove the glyphs at each index. + int codePointCount = 0; + for (int i = count; i > 0; i--) + { + int match = index + i; + codePointCount += this.glyphs[match].Data.CodePointCount; + this.glyphs.RemoveAt(match); + } + + // Assign our new id at the index. + GlyphShapingData current = this.glyphs[index].Data; + current.CodePointCount += codePointCount; + current.GlyphId = glyphId; + current.LigatureId = 0; + current.LigatureComponent = -1; + current.MarkAttachment = -1; + current.CursiveAttachment = -1; + } + /// /// Replaces a single glyph id with a collection of glyph ids. /// @@ -185,13 +265,48 @@ public void Replace(int index, ReadOnlySpan removalIndices, ushort glyphId, /// The collection of replacement glyph ids. public void Replace(int index, ReadOnlySpan glyphIds) { - // TODO: FontKit stores the ids in sequence with increasing ligature component values. - int offset = this.offsets[index]; - GlyphShapingData current = this.glyphs[offset]; - current.GlyphIds = glyphIds.ToArray(); - current.LigatureComponent = 0; - current.MarkAttachment = -1; - current.CursiveAttachment = -1; + if (glyphIds.Length > 0) + { + OffsetGlyphDataPair pair = this.glyphs[index]; + GlyphShapingData current = pair.Data; + current.GlyphId = glyphIds[0]; + current.LigatureComponent = 0; + current.MarkAttachment = -1; + current.CursiveAttachment = -1; + current.IsDecomposed = true; + + // Add additional glyphs from the rest of the sequence. + if (glyphIds.Length > 1) + { + glyphIds = glyphIds.Slice(1); + for (int i = 0; i < glyphIds.Length; i++) + { + GlyphShapingData data = new(current, false); + data.GlyphId = glyphIds[i]; + data.LigatureComponent = i + 1; + this.glyphs.Insert(++index, new(pair.Offset, data)); + } + } + } + else + { + // Spec disallows removal of glyphs in this manner but it's common enough practice to allow it. + // https://github.com/MicrosoftDocs/typography-issues/issues/673 + this.glyphs.RemoveAt(index); + } + } + + private class OffsetGlyphDataPair + { + public OffsetGlyphDataPair(int offset, GlyphShapingData data) + { + this.Offset = offset; + this.Data = data; + } + + public int Offset { get; set; } + + public GlyphShapingData Data { get; set; } } } } diff --git a/src/SixLabors.Fonts/IGlyphShapingCollection.cs b/src/SixLabors.Fonts/IGlyphShapingCollection.cs index 06e00821..96bdd7a1 100644 --- a/src/SixLabors.Fonts/IGlyphShapingCollection.cs +++ b/src/SixLabors.Fonts/IGlyphShapingCollection.cs @@ -1,7 +1,6 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. -using System; using SixLabors.Fonts.Tables.AdvancedTypographic; namespace SixLabors.Fonts @@ -27,11 +26,11 @@ internal interface IGlyphShapingCollection TextOptions TextOptions { get; } /// - /// Gets the glyph ids at the specified index. + /// Gets the glyph id at the specified index. /// /// The zero-based index of the elements to get. - /// The . - ReadOnlySpan this[int index] { get; } + /// The . + ushort this[int index] { get; } /// /// Gets the shaping data at the specified position. @@ -53,5 +52,12 @@ internal interface IGlyphShapingCollection /// The zero-based index of the element. /// The feature to enable. void EnableShapingFeature(int index, Tag feature); + + /// + /// Disables a previously added shaping feature. + /// + /// The zero-based index of the element. + /// The feature to disable. + void DisableShapingFeature(int index, Tag feature); } } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs index ffd2dfe8..f356d990 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/AdvancedTypographicUtils.cs @@ -86,7 +86,7 @@ public static bool MatchInputSequence(SkippingGlyphIterator iterator, Tag featur return false; } - return component == data.GlyphIds[0]; + return component == data.GlyphId; }, matches); @@ -108,7 +108,7 @@ public static bool MatchSequence(SkippingGlyphIterator iterator, int increment, increment, sequence, iterator, - (component, data) => component == data.GlyphIds[0], + (component, data) => component == data.GlyphId, default); public static bool MatchClassSequence( @@ -120,7 +120,7 @@ public static bool MatchClassSequence( increment, sequence, iterator, - (component, data) => component == classDefinitionTable.ClassIndexOf(data.GlyphIds[0]), + (component, data) => component == classDefinitionTable.ClassIndexOf(data.GlyphId), default); public static bool MatchCoverageSequence( @@ -131,7 +131,7 @@ public static bool MatchCoverageSequence( increment, coverageTable, iterator, - (component, data) => component.CoverageIndexOf(data.GlyphIds[0]) >= 0, + (component, data) => component.CoverageIndexOf(data.GlyphId) >= 0, default); public static bool ApplyChainedSequenceRule(SkippingGlyphIterator iterator, ChainedSequenceRuleTable rule) diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs index 32d05f0a..0075b7e1 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType1SubTable.cs @@ -74,7 +74,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -145,7 +145,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs index 0e9f9a55..2664e873 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType2SubTable.cs @@ -101,7 +101,7 @@ public override bool TryUpdatePosition( return false; } - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -111,7 +111,7 @@ public override bool TryUpdatePosition( if (coverage > -1) { PairSetTable pairSet = this.pairSets[coverage]; - ushort glyphId2 = collection[index + 1][0]; + ushort glyphId2 = collection[index + 1]; if (glyphId2 == 0) { return false; @@ -266,7 +266,7 @@ public override bool TryUpdatePosition( return false; } - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -276,7 +276,7 @@ public override bool TryUpdatePosition( if (coverage > -1) { int classDef1 = this.classDefinitionTable1.ClassIndexOf(glyphId); - ushort glyphId2 = collection[index + 1][0]; + ushort glyphId2 = collection[index + 1]; if (glyphId2 == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs index acb0a214..acd4fdf8 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs @@ -88,14 +88,14 @@ public override bool TryUpdatePosition( // Implements Cursive Attachment Positioning Subtable: // https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-3-cursive-attachment-positioning-subtable - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; } ushort nextIndex = (ushort)(index + 1); - ushort nextGlyphId = collection[nextIndex][0]; + ushort nextGlyphId = collection[nextIndex]; if (nextGlyphId == 0) { return false; @@ -180,9 +180,7 @@ public override bool TryUpdatePosition( int yOffset = entryXY.YCoordinate - exitXY.YCoordinate; if (this.LookupFlags.HasFlag(LookupFlags.RightToLeft)) { - int temp = child; - child = parent; - parent = temp; + (parent, child) = (child, parent); xOffset = -xOffset; yOffset = -yOffset; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType4SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType4SubTable.cs index 62e61585..9e1bb997 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType4SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType4SubTable.cs @@ -91,7 +91,7 @@ public override bool TryUpdatePosition( { // Mark-to-Base Attachment Positioning Subtable. // Implements: https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-4-mark-to-base-attachment-positioning-subtable - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -108,7 +108,7 @@ public override bool TryUpdatePosition( while (--baseGlyphIndex >= 0) { GlyphShapingData data = collection.GetGlyphShapingData(baseGlyphIndex); - if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphIds[0], data) && !(data.LigatureComponent > 0)) + if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphId, data) && !(data.LigatureComponent > 0)) { break; } @@ -119,7 +119,7 @@ public override bool TryUpdatePosition( return false; } - ushort baseGlyphId = collection[baseGlyphIndex][0]; + ushort baseGlyphId = collection[baseGlyphIndex]; int baseIndex = this.baseCoverage.CoverageIndexOf(baseGlyphId); if (baseIndex < 0) { diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType5SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType5SubTable.cs index 0c040a2d..1d20ac3c 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType5SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType5SubTable.cs @@ -95,7 +95,7 @@ public override bool TryUpdatePosition( { // Mark-to-Ligature Attachment Positioning. // Implements: https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-5-mark-to-ligature-attachment-positioning-subtable - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -112,7 +112,7 @@ public override bool TryUpdatePosition( while (--baseGlyphIndex >= 0) { GlyphShapingData data = collection.GetGlyphShapingData(baseGlyphIndex); - if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphIds[0], data)) + if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphId, data)) { break; } @@ -123,7 +123,7 @@ public override bool TryUpdatePosition( return false; } - ushort baseGlyphId = collection[baseGlyphIndex][0]; + ushort baseGlyphId = collection[baseGlyphIndex]; int ligatureIndex = this.ligatureCoverage.CoverageIndexOf(baseGlyphId); if (ligatureIndex < 0) { diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType6SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType6SubTable.cs index 9ca304ba..61c2a4a6 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType6SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType6SubTable.cs @@ -93,7 +93,7 @@ public override bool TryUpdatePosition( { // Mark to mark positioning. // Implements: https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-6-mark-to-mark-attachment-positioning-subtable - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -112,7 +112,7 @@ public override bool TryUpdatePosition( } int prevIdx = index - 1; - ushort prevGlyphId = collection[prevIdx][0]; + ushort prevGlyphId = collection[prevIdx]; GlyphShapingData prevGlyph = collection.GetGlyphShapingData(prevIdx); if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, prevGlyphId, prevGlyph)) { diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs index 9b9612fe..c90495b3 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType7SubTable.cs @@ -55,7 +55,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -134,7 +134,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -210,7 +210,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs index 5e85fd34..ad9e57dc 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType8SubTable.cs @@ -59,7 +59,7 @@ public override bool TryUpdatePosition( { // Implements Chained Contexts Substitution, Format 1: // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#61-chained-contexts-substitution-format-1-simple-glyph-contexts - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -160,7 +160,7 @@ public override bool TryUpdatePosition( { // Implements Chained Contexts Substitution for Format 2: // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#62-chained-contexts-substitution-format-2-class-based-glyph-contexts - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -252,7 +252,7 @@ public override bool TryUpdatePosition( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs index d3537c99..9db7de54 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs @@ -320,7 +320,7 @@ private static void ZeroMarkAdvances(FontMetrics fontMetrics, GlyphPositioningCo { int currentIndex = i + index; GlyphShapingData data = collection.GetGlyphShapingData(currentIndex); - if (AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphIds[0], data)) + if (AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, data.GlyphId, data)) { data.Bounds.Width = 0; data.Bounds.Height = 0; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType1SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType1SubTable.cs index 30006653..bf688f84 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType1SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType1SubTable.cs @@ -68,7 +68,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -127,7 +127,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType2SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType2SubTable.cs index 24501114..c83a7965 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType2SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType2SubTable.cs @@ -91,7 +91,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -101,8 +101,6 @@ public override bool TrySubstitution( if (offset > -1) { - // TODO: Looks like we should remove the glyph if the substitutes - // length = 0; collection.Replace(index, this.sequenceTables[offset].SubstituteGlyphs); return true; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType3SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType3SubTable.cs index 0974bb23..76fbef37 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType3SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType3SubTable.cs @@ -90,7 +90,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType4SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType4SubTable.cs index e88ce93c..4b61cea2 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType4SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType4SubTable.cs @@ -120,7 +120,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -183,7 +183,7 @@ public override bool TrySubstitution( for (int j = 0; j < matches.Length && isMarkLigature; j++) { GlyphShapingData match = collection.GetGlyphShapingData(matches[j]); - if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, match.GlyphIds[0], match)) + if (!AdvancedTypographicUtils.IsMarkGlyph(fontMetrics, match.GlyphId, match)) { isBaseLigature = false; isMarkLigature = false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType5SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType5SubTable.cs index c5c5eebe..f06f5036 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType5SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType5SubTable.cs @@ -54,7 +54,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -133,7 +133,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -211,7 +211,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType6SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType6SubTable.cs index b507840b..9aab6bc4 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType6SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType6SubTable.cs @@ -55,7 +55,7 @@ public override bool TrySubstitution( { // Implements Chained Contexts Substitution, Format 1: // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#61-chained-contexts-substitution-format-1-simple-glyph-contexts - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -147,7 +147,7 @@ public override bool TrySubstitution( { // Implements Chained Contexts Substitution for Format 2: // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#62-chained-contexts-substitution-format-2-class-based-glyph-contexts - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -234,7 +234,7 @@ public override bool TrySubstitution( ushort index, int count) { - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType8SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType8SubTable.cs index 11b33d9f..aea52ebc 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType8SubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSub/LookupType8SubTable.cs @@ -107,7 +107,7 @@ public override bool TrySubstitution( int count) { // https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#81-reverse-chaining-contextual-single-substitution-format-1-coverage-based-glyph-contexts - ushort glyphId = collection[index][0]; + ushort glyphId = collection[index]; if (glyphId == 0) { return false; @@ -121,7 +121,7 @@ public override bool TrySubstitution( for (int i = 0; i < this.backtrackCoverageTables.Length; ++i) { - ushort id = collection[index - 1 - i][0]; + ushort id = collection[index - 1 - i]; if (id == 0 || this.backtrackCoverageTables[i].CoverageIndexOf(id) < 0) { return false; @@ -130,7 +130,7 @@ public override bool TrySubstitution( for (int i = 0; i < this.lookaheadCoverageTables.Length; ++i) { - ushort id = collection[index + i][0]; + ushort id = collection[index + i]; if (id == 0 || this.lookaheadCoverageTables[i].CoverageIndexOf(id) < 0) { return false; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs index cb10582e..f3691f69 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GSubTable.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. +using System; using System.Collections.Generic; using SixLabors.Fonts.Tables.AdvancedTypographic.GSub; using SixLabors.Fonts.Tables.AdvancedTypographic.Shapers; @@ -125,6 +126,10 @@ public void ApplySubstitution(FontMetrics fontMetrics, GlyphSubstitutionCollecti // Assign Substitution features to each glyph. shaper.AssignFeatures(collection, index, count); + + // Shapers can adjust the count. + count = (ushort)Math.Min(count, collection.Count - index); + IEnumerable stageFeatures = shaper.GetShapingStageFeatures(); int currentCount = collection.Count; diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs index 55c4829c..7c1c2c7e 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs @@ -12,10 +12,6 @@ namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers /// internal sealed class ArabicShaper : DefaultShaper { - private static readonly Tag CcmpTag = Tag.Parse("ccmp"); - - private static readonly Tag LoclTag = Tag.Parse("locl"); - private static readonly Tag MsetTag = Tag.Parse("mset"); private static readonly Tag FinaTag = Tag.Parse("fina"); diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs index 1e189a6f..7e49d3f9 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs @@ -12,47 +12,47 @@ namespace SixLabors.Fonts.Tables.AdvancedTypographic.Shapers /// internal class DefaultShaper : BaseShaper { - private static readonly Tag RvnrTag = Tag.Parse("rvrn"); + protected static readonly Tag RvnrTag = Tag.Parse("rvrn"); - private static readonly Tag LtraTag = Tag.Parse("ltra"); + protected static readonly Tag LtraTag = Tag.Parse("ltra"); - private static readonly Tag LtrmTag = Tag.Parse("ltrm"); + protected static readonly Tag LtrmTag = Tag.Parse("ltrm"); - private static readonly Tag RtlaTag = Tag.Parse("rtla"); + protected static readonly Tag RtlaTag = Tag.Parse("rtla"); - private static readonly Tag RtlmTag = Tag.Parse("rtlm"); + protected static readonly Tag RtlmTag = Tag.Parse("rtlm"); - private static readonly Tag FracTag = Tag.Parse("frac"); + protected static readonly Tag FracTag = Tag.Parse("frac"); - private static readonly Tag NumrTag = Tag.Parse("numr"); + protected static readonly Tag NumrTag = Tag.Parse("numr"); - private static readonly Tag DnomTag = Tag.Parse("dnom"); + protected static readonly Tag DnomTag = Tag.Parse("dnom"); - private static readonly Tag CcmpTag = Tag.Parse("ccmp"); + protected static readonly Tag CcmpTag = Tag.Parse("ccmp"); - private static readonly Tag LoclTag = Tag.Parse("locl"); + protected static readonly Tag LoclTag = Tag.Parse("locl"); - private static readonly Tag RligTag = Tag.Parse("rlig"); + protected static readonly Tag RligTag = Tag.Parse("rlig"); - private static readonly Tag MarkTag = Tag.Parse("mark"); + protected static readonly Tag MarkTag = Tag.Parse("mark"); - private static readonly Tag MkmkTag = Tag.Parse("mkmk"); + protected static readonly Tag MkmkTag = Tag.Parse("mkmk"); - private static readonly Tag CaltTag = Tag.Parse("calt"); + protected static readonly Tag CaltTag = Tag.Parse("calt"); - private static readonly Tag CligTag = Tag.Parse("clig"); + protected static readonly Tag CligTag = Tag.Parse("clig"); - private static readonly Tag LigaTag = Tag.Parse("liga"); + protected static readonly Tag LigaTag = Tag.Parse("liga"); - private static readonly Tag RcltTag = Tag.Parse("rclt"); + protected static readonly Tag RcltTag = Tag.Parse("rclt"); - private static readonly Tag CursTag = Tag.Parse("curs"); + protected static readonly Tag CursTag = Tag.Parse("curs"); - private static readonly Tag KernTag = Tag.Parse("kern"); + protected static readonly Tag KernTag = Tag.Parse("kern"); - private static readonly Tag VertTag = Tag.Parse("vert"); + protected static readonly Tag VertTag = Tag.Parse("vert"); - private static readonly Tag VKernTag = Tag.Parse("vkrn"); + protected static readonly Tag VKernTag = Tag.Parse("vkrn"); private static readonly CodePoint FractionSlash = new(0x2044); diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs new file mode 100644 index 00000000..9b49be12 --- /dev/null +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs @@ -0,0 +1,412 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using SixLabors.Fonts.Unicode; + +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 + { + private static readonly Tag LjmoTag = Tag.Parse("ljmo"); + + private static readonly Tag VjmoTag = Tag.Parse("vjmo"); + + private static readonly Tag TjmoTag = Tag.Parse("tjmo"); + + private const int HangulBase = 0xac00; + private const int LBase = 0x1100; // lead + private const int VBase = 0x1161; // vowel + private const int TBase = 0x11a7; // trail + private const int LCount = 19; + private const int VCount = 21; + private const int TCount = 28; + private const int LEnd = LBase + LCount - 1; + private const int VEnd = VBase + VCount - 1; + private const int TEnd = TBase + TCount - 1; + private const int DottedCircle = 0x25cc; + + // Character categories + private const byte X = 0; // Other character + private const byte L = 1; // Leading consonant + private const byte V = 2; // Medial vowel + private const byte T = 3; // Trailing consonant + private const byte LV = 4; // Composed syllable + private const byte LVT = 5; // Composed syllable + private const byte M = 6; // Tone mark + + // State machine actions + private const byte None = 0; + private const byte Decompose = 1; + private const byte Compose = 2; + private const byte ToneMark = 4; + private const byte Invalid = 5; + + // Build a state machine that accepts valid syllables, and applies actions along the way. + // The logic this is implementing is documented at the top of the file. + 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 } }, + }; + + public HangulShaper(TextOptions textOptions) + : base(MarkZeroingMode.None, textOptions) + { + } + + public override void AssignFeatures(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); + + base.AssignFeatures(collection, index, 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) + { + // GSub + int state = 0; + for (int i = 0; i < count; i++) + { + if (i + index >= substitutionCollection.Count) + { + break; + } + + GlyphShapingData data = substitutionCollection.GetGlyphShapingData(i + index); + CodePoint codePoint = data.CodePoint; + int type = GetSyllableType(codePoint); + byte[] actionsWithState = StateTable[state, type]; + byte action = actionsWithState[0]; + state = actionsWithState[1]; + 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); + } + + break; + + case Compose: + + // Found a decomposed syllable. Try to compose if supported by the font. + i = this.ComposeGlyph(substitutionCollection, data, i, type); + 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); + 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.GetGlyphShapingData(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; + default: + break; + } + } + } + } + + 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, + }; + } + + private static int GetSyllableLength(CodePoint codePoint) + => GetSyllableType(codePoint) switch + { + LV or LVT => 1, + V => 2, + T => 3, + _ => 0, + }; + + private int DecomposeGlyph(GlyphSubstitutionCollection collection, GlyphShapingData data, int index) + { + // 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 = data.TextRun.Font!.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 = stackalloc ushort[2]; + ii[0] = ljmo; + ii[1] = vjmo; + + collection.Replace(index, ii); + collection.EnableShapingFeature(index, LjmoTag); + collection.EnableShapingFeature(index + 1, VjmoTag); + return index + 1; + } + + Span iii = stackalloc ushort[3]; + iii[0] = ljmo; + iii[1] = vjmo; + iii[2] = tjmo; + + collection.Replace(index, iii); + collection.EnableShapingFeature(index, LjmoTag); + collection.EnableShapingFeature(index + 1, VjmoTag); + collection.EnableShapingFeature(index + 2, TjmoTag); + return index + 2; + } + + private int ComposeGlyph(GlyphSubstitutionCollection collection, GlyphShapingData data, int index, int type) + { + if (index == 0) + { + return index; + } + + GlyphShapingData prev = collection.GetGlyphShapingData(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.GetGlyphShapingData(ljmo).CodePoint; + CodePoint v = collection.GetGlyphShapingData(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.GetGlyphShapingData(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. + FontMetrics metrics = data.TextRun.Font!.FontMetrics; + if (metrics.TryGetGlyphId(s, out ushort id)) + { + int del = prevType == V ? 3 : 2; + int idx = index - del + 1; + collection.Replace(idx, del - 1, id); + collection.GetGlyphShapingData(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. + data = collection.GetGlyphShapingData(index - 1); + this.DecomposeGlyph(collection, data, index - 1); + return index + 1; + } + + return index; + } + + 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 metrics = data.TextRun.Font!.FontMetrics; + foreach (GlyphMetrics gm in metrics.GetGlyphMetrics(data.CodePoint, collection.TextOptions.ColorFontSupport)) + { + if (gm.Width == 0) + { + return; + } + } + + GlyphShapingData prev = collection.GetGlyphShapingData(index - 1); + int len = GetSyllableLength(prev.CodePoint); + collection.MoveGlyph(index, index - len); + } + + private int InsertDottedCircle(GlyphSubstitutionCollection collection, GlyphShapingData data, int index) + { + bool after = false; + FontMetrics metrics = data.TextRun.Font!.FontMetrics; + + if (metrics.TryGetGlyphId(new(DottedCircle), out ushort id)) + { + foreach (GlyphMetrics gm in metrics.GetGlyphMetrics(data.CodePoint, collection.TextOptions.ColorFontSupport)) + { + if (gm.Width != 0) + { + after = true; + break; + } + } + + // If the tone mark is zero width, insert the dotted circle before, otherwise after + Span glyphs = stackalloc ushort[2]; + if (after) + { + glyphs[0] = data.GlyphId; + glyphs[1] = id; + } + else + { + glyphs[0] = id; + glyphs[1] = data.GlyphId; + } + + collection.Replace(index, glyphs); + return index + 1; + } + + return index; + } + + private static bool IsCombiningL(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, LBase, LEnd); + + private static bool IsCombiningV(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, VBase, VEnd); + + private static bool IsCombiningT(CodePoint code) => UnicodeUtility.IsInRangeInclusive((uint)code.Value, TBase + 1, TEnd); + } +} diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs index 47e2bd1e..7cc28117 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ShaperFactory.cs @@ -24,6 +24,7 @@ or ScriptClass.PhagsPa or ScriptClass.Mandaic or ScriptClass.Manichaean or ScriptClass.PsalterPahlavi => new ArabicShaper(textOptions), + ScriptClass.Hangul => new HangulShaper(textOptions), _ => new DefaultShaper(textOptions), }; } diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/TagEntry.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/TagEntry.cs index aafd4dca..f8e4a9ee 100644 --- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/TagEntry.cs +++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/TagEntry.cs @@ -6,7 +6,7 @@ namespace SixLabors.Fonts.Tables.AdvancedTypographic { [DebuggerDisplay("Tag: {Tag}, Enabled: {Enabled}")] - internal class TagEntry + internal struct TagEntry { public TagEntry(Tag tag, bool enabled) { diff --git a/src/SixLabors.Fonts/Tables/General/KerningTable.cs b/src/SixLabors.Fonts/Tables/General/KerningTable.cs index da870fea..ea39f8b9 100644 --- a/src/SixLabors.Fonts/Tables/General/KerningTable.cs +++ b/src/SixLabors.Fonts/Tables/General/KerningTable.cs @@ -59,8 +59,8 @@ public static KerningTable Load(BigEndianBinaryReader reader) public void UpdatePositions(FontMetrics fontMetrics, GlyphPositioningCollection collection, ushort left, ushort right) { - ushort previous = collection[left][0]; - ushort current = collection[right][0]; + ushort previous = collection[left]; + ushort current = collection[right]; if (previous == 0 || current == 0) { return; diff --git a/src/SixLabors.Fonts/Tables/SkippingGlyphIterator.cs b/src/SixLabors.Fonts/Tables/SkippingGlyphIterator.cs index 1bfef134..4747e9d0 100644 --- a/src/SixLabors.Fonts/Tables/SkippingGlyphIterator.cs +++ b/src/SixLabors.Fonts/Tables/SkippingGlyphIterator.cs @@ -73,7 +73,7 @@ private void Move(int direction) private bool ShouldIgnore(int index) { GlyphShapingData data = this.Collection.GetGlyphShapingData(index); - GlyphShapingClass shapingClass = AdvancedTypographicUtils.GetGlyphShapingClass(this.fontMetrics, data.GlyphIds[0], data); + GlyphShapingClass shapingClass = AdvancedTypographicUtils.GetGlyphShapingClass(this.fontMetrics, data.GlyphId, data); return (this.ignoreMarks && shapingClass.IsMark) || (this.ignoreBaseGlypghs && shapingClass.IsBase) || (this.ignoreLigatures && shapingClass.IsLigature) || diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs index bf6f5468..01c38bb9 100644 --- a/src/SixLabors.Fonts/TextLayout.cs +++ b/src/SixLabors.Fonts/TextLayout.cs @@ -713,7 +713,7 @@ private static TextBox BreakLines( var codePointEnumerator = new SpanCodePointEnumerator(graphemeEnumerator.Current); while (codePointEnumerator.MoveNext()) { - if (!positionings.TryGetGlyphMetricsAtOffset(codePointIndex, out float pointSize, out GlyphMetrics[]? metrics)) + if (!positionings.TryGetGlyphMetricsAtOffset(codePointIndex, out float pointSize, out bool isDecomposed, out IReadOnlyList? metrics)) { // Codepoint was skipped during original enumeration. codePointIndex++; @@ -736,7 +736,7 @@ private static TextBox BreakLines( { glyphAdvance *= options.TabWidth; } - else if (metrics.Length == 1 && (CodePoint.IsZeroWidthJoiner(codePoint) || CodePoint.IsZeroWidthNonJoiner(codePoint))) + else if (metrics.Count == 1 && (CodePoint.IsZeroWidthJoiner(codePoint) || CodePoint.IsZeroWidthNonJoiner(codePoint))) { // The zero-width joiner characters should be ignored when determining word or // line break boundaries so are safe to skip here. Any existing instances are the result of font error @@ -746,13 +746,18 @@ private static TextBox BreakLines( } else if (!CodePoint.IsNewLine(codePoint)) { - // Standard text. Use the largest advance for the metrics. + // Standard text. + // If decomposed we need to add the advance; otherwise, use the largest advance for the metrics. if (isHorizontal) { - for (int i = 1; i < metrics.Length; i++) + for (int i = 1; i < metrics.Count; i++) { float a = metrics[i].AdvanceWidth; - if (a > glyphAdvance) + if (isDecomposed) + { + glyphAdvance += a; + } + else if (a > glyphAdvance) { glyphAdvance = a; } @@ -760,10 +765,14 @@ private static TextBox BreakLines( } else { - for (int i = 1; i < metrics.Length; i++) + for (int i = 1; i < metrics.Count; i++) { float a = metrics[i].AdvanceHeight; - if (a > glyphAdvance) + if (isDecomposed) + { + glyphAdvance += a; + } + else if (a > glyphAdvance) { glyphAdvance = a; } @@ -875,10 +884,17 @@ private static TextBox BreakLines( GlyphMetrics metric = metrics[0]; float scaleY = pointSize / metric.ScaleFactor.Y; float ascender = metric.FontMetrics.Ascender * scaleY; - if (metric.TopSideBearing < 0) + + // Adjust ascender for glyphs with a negative tsb. e.g. emoji to prevent cutoff. + short tsbOffset = 0; + for (int i = 0; i < metrics.Count; i++) + { + tsbOffset = Math.Min(tsbOffset, metrics[i].TopSideBearing); + } + + if (tsbOffset < 0) { - // Adjust for glyphs with a negative tsb. e.g. emoji. - ascender -= metric.TopSideBearing * scaleY; + ascender -= tsbOffset * scaleY; } float descender = Math.Abs(metric.FontMetrics.Descender * scaleY); @@ -959,7 +975,7 @@ internal sealed class TextLine public GlyphLayoutData this[int index] => this.data[index]; public void Add( - GlyphMetrics[] metrics, + IReadOnlyList metrics, float pointSize, float scaledAdvance, float scaledLineHeight, @@ -1213,7 +1229,7 @@ private static OrderedBidiRun LinearReOrder(OrderedBidiRun? line) internal readonly struct GlyphLayoutData { public GlyphLayoutData( - GlyphMetrics[] metrics, + IReadOnlyList metrics, float pointSize, float scaledAdvance, float scaledLineHeight, @@ -1238,7 +1254,7 @@ public GlyphLayoutData( public CodePoint CodePoint => this.Metrics[0].CodePoint; - public GlyphMetrics[] Metrics { get; } + public IReadOnlyList Metrics { get; } public float PointSize { get; } diff --git a/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs b/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs index a6edeb2d..53e052bf 100644 --- a/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs +++ b/src/SixLabors.Fonts/Unicode/UnicodeUtility.cs @@ -230,6 +230,180 @@ public static bool IsCJKCodePoint(uint value) return false; } + /// + /// Returns if is a Default Ignorable Code Point. + /// + /// + /// + /// + /// + public static bool IsDefaultIgnorableCodePoint(uint value) + { + // SOFT HYPHEN + if (value == 0x00AD) + { + return true; + } + + // COMBINING GRAPHEME JOINER + if (value == 0x034F) + { + return true; + } + + // COMBINING GRAPHEME JOINER + if (value == 0x061C) + { + return true; + } + + // HANGUL CHOSEONG FILLER..HANGUL JUNGSEONG FILLER + if (IsInRangeInclusive(value, 0x115F, 0x1160)) + { + return true; + } + + // KHMER VOWEL INHERENT AQ..KHMER VOWEL INHERENT AA + if (IsInRangeInclusive(value, 0x17B4, 0x17B5)) + { + return true; + } + + // MONGOLIAN FREE VARIATION SELECTOR ONE..MONGOLIAN FREE VARIATION SELECTOR THREE + if (IsInRangeInclusive(value, 0x180B, 0x180D)) + { + return true; + } + + // MONGOLIAN VOWEL SEPARATOR + if (value == 0x180E) + { + return true; + } + + // MONGOLIAN FREE VARIATION SELECTOR FOUR + if (value == 0x180F) + { + return true; + } + + // ZERO WIDTH SPACE..RIGHT-TO-LEFT MARK + if (IsInRangeInclusive(value, 0x200B, 0x200F)) + { + return true; + } + + // LEFT-TO-RIGHT EMBEDDING..RIGHT-TO-LEFT OVERRIDE + if (IsInRangeInclusive(value, 0x202A, 0x202E)) + { + return true; + } + + // WORD JOINER..INVISIBLE PLUS + if (IsInRangeInclusive(value, 0x2060, 0x2064)) + { + return true; + } + + // + if (value == 0x2065) + { + return true; + } + + // LEFT-TO-RIGHT ISOLATE..NOMINAL DIGIT SHAPES + if (IsInRangeInclusive(value, 0x2066, 0x206F)) + { + return true; + } + + // HANGUL FILLER + if (value == 0x3164) + { + return true; + } + + // VARIATION SELECTOR-1..VARIATION SELECTOR-16 + if (IsInRangeInclusive(value, 0xFE00, 0xFE0F)) + { + return true; + } + + // ZERO WIDTH NO-BREAK SPACE + if (value == 0xFEFF) + { + return true; + } + + // HALFWIDTH HANGUL FILLER + if (value == 0xFFA0) + { + return true; + } + + // .. + if (IsInRangeInclusive(value, 0xFFF0, 0xFFF8)) + { + return true; + } + + // SHORTHAND FORMAT LETTER OVERLAP..SHORTHAND FORMAT UP STEP + if (IsInRangeInclusive(value, 0x1BCA0, 0x1BCA3)) + { + return true; + } + + // MUSICAL SYMBOL BEGIN BEAM..MUSICAL SYMBOL END PHRASE + if (IsInRangeInclusive(value, 0x1D173, 0x1D17A)) + { + return true; + } + + // + if (value == 0xE0000) + { + return true; + } + + // LANGUAGE TAG + if (value == 0xE0001) + { + return true; + } + + // .. + if (IsInRangeInclusive(value, 0xE0002, 0xE001F)) + { + return true; + } + + // TAG SPACE..CANCEL TAG + if (IsInRangeInclusive(value, 0xE0020, 0xE007F)) + { + return true; + } + + // .. + if (IsInRangeInclusive(value, 0xE0080, 0xE00FF)) + { + return true; + } + + // VARIATION SELECTOR-17..VARIATION SELECTOR-256 + if (IsInRangeInclusive(value, 0xE0100, 0xE01EF)) + { + return true; + } + + // .. + if (IsInRangeInclusive(value, 0xE01F0, 0xE0FFF)) + { + return true; + } + + return false; + } + /// /// Returns the Unicode plane (0 through 16, inclusive) which contains this code point. /// diff --git a/src/UnicodeTrieGenerator/Rules/README.md b/src/UnicodeTrieGenerator/Rules/README.md index 26282114..c8aa4e2e 100644 --- a/src/UnicodeTrieGenerator/Rules/README.md +++ b/src/UnicodeTrieGenerator/Rules/README.md @@ -1,3 +1,3 @@ Files sourced from: -https://www.unicode.org/Public/13.0.0/ucd/ +https://www.unicode.org/Public/14.0.0/ucd/ diff --git a/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.Hangul.cs b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.Hangul.cs new file mode 100644 index 00000000..aa08069c --- /dev/null +++ b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.Hangul.cs @@ -0,0 +1,192 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using Xunit; + +namespace SixLabors.Fonts.Tests.Tables.AdvancedTypographic.Gsub +{ + /// + /// Tests adapted from . + /// + public partial class GSubTableTests + { + // TODO: Switch to NotoSansKR-Regular when we have CFF support. +#if OS_WINDOWS + private readonly Font hangulFont = SystemFonts.CreateFont("Malgun Gothic", 12); + + [Fact] + public void ShouldUseComposedSyllables() + { + // arrange + const string input = "\uD734\uAC00\u0020\uAC00\u002D\u002D\u0020\u0028\uC624\u002D\u002D\u0029"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 2953, 636, 3, 636, 16, 16, 3, 11, 2077, 16, 16, 12 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldComposeDecomposedSyllables() + { + // arrange + const string input = "\u1112\u1172\u1100\u1161\u0020\u1100\u1161\u002D\u002D\u0020\u0028\u110B\u1169\u002D\u002D\u0029"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 2953, 636, 3, 636, 16, 16, 3, 11, 2077, 16, 16, 12 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldUseOTFeaturesForNonCombining_L_V_T() + { + // arrange + const string input = "\ua960\ud7b0\ud7cb"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 21150, 21436, 21569 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldDecompose_LV_T_To_L_V_T_If_LVT_IsNotSupported() + { + // combine at first, but the T is non-combining, so this + // tests that the gets decomposed again in this case. + + // arrange + const string input = "\u1100\u1161\ud7cb"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 20667, 21294, 21569 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldReorderToneMarksToBeginningOf_L_V_Syllables() + { + // arrange + const string input = "\ua960\ud7b0\u302f"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 20665, 21150, 21435 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldReorderToneMarksToBeginningOf_L_V_T_Syllables() + { + // arrange + const string input = "\ua960\ud7b0\ud7cb\u302f"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 20665, 21150, 21436, 21569 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldReorderToneMarksToBeginningOf_LV_Syllables() + { + // arrange + const string input = "\uac00\u302f"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 20665, 636 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldReorderToneMarksToBeginningOf_LVT_Syllables() + { + // arrange + const string input = "\uac01\u302f"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 20665, 637 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } + + [Fact] + public void ShouldInsertDottedCircleForInvalidToneMarks() + { + // arrange + const string input = "\u1100\u302f\u1161"; + ColorGlyphRenderer renderer = new(); + int[] expectedGlyphIndices = { 2986, 20665, 21620, 3078 }; + + // act + TextRenderer.RenderTextTo(renderer, input, new TextOptions(this.hangulFont)); + + // assert + Assert.Equal(expectedGlyphIndices.Length, renderer.GlyphKeys.Count); + for (int i = 0; i < expectedGlyphIndices.Length; i++) + { + Assert.Equal(expectedGlyphIndices[i], renderer.GlyphKeys[i].GlyphIndex); + } + } +#endif + } +} diff --git a/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.cs b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.cs index b8e40d78..db3867c6 100644 --- a/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.cs +++ b/tests/SixLabors.Fonts.Tests/Tables/AdvancedTypographic/Gsub/GSubTableTests.cs @@ -7,7 +7,7 @@ namespace SixLabors.Fonts.Tests.Tables.AdvancedTypographic.GSub { - public class GSubTableTests + public partial class GSubTableTests { [Theory] [InlineData("ا", 139)] diff --git a/tests/SixLabors.Fonts.Tests/Unicode/UnicodeUtilityTests.cs b/tests/SixLabors.Fonts.Tests/Unicode/UnicodeUtilityTests.cs index b0959cff..ed4b66c4 100644 --- a/tests/SixLabors.Fonts.Tests/Unicode/UnicodeUtilityTests.cs +++ b/tests/SixLabors.Fonts.Tests/Unicode/UnicodeUtilityTests.cs @@ -39,5 +39,51 @@ public void NoFalsePositiveCJKCodePoints(uint min, uint max) Assert.False(UnicodeUtility.IsCJKCodePoint(i)); } } + + [Theory] + [InlineData(0x00AD, 0x00AD)] + [InlineData(0x034F, 0x034F)] + [InlineData(0x061C, 0x061C)] + [InlineData(0x115F, 0x1160)] + [InlineData(0x17B4, 0x17B5)] + [InlineData(0x180B, 0x180D)] + [InlineData(0x180E, 0x180E)] + [InlineData(0x180F, 0x180F)] + [InlineData(0x200B, 0x200F)] + [InlineData(0x202A, 0x202E)] + [InlineData(0x2060, 0x2064)] + [InlineData(0x2065, 0x2065)] + [InlineData(0x2066, 0x206F)] + [InlineData(0x3164, 0x3164)] + [InlineData(0xFE00, 0xFE0F)] + [InlineData(0xFEFF, 0xFEFF)] + [InlineData(0xFFA0, 0xFFA0)] + [InlineData(0xFFF0, 0xFFF8)] + [InlineData(0x1BCA0, 0x1BCA3)] + [InlineData(0x1D173, 0x1D17A)] + [InlineData(0xE0000, 0xE0000)] + [InlineData(0xE0001, 0xE0001)] + [InlineData(0xE0002, 0xE001F)] + [InlineData(0xE0020, 0xE007F)] + [InlineData(0xE0080, 0xE00FF)] + [InlineData(0xE0100, 0xE01EF)] + [InlineData(0xE01F0, 0xE0FFF)] + public void CanDetectDefaultIgnorableCodePoint(uint min, uint max) + { + for (uint i = min; i <= max; i++) + { + Assert.True(UnicodeUtility.IsDefaultIgnorableCodePoint(i)); + } + } + + [Theory] + [InlineData(0x0u, 0x7Fu)] // ASCII + public void NoFalsePositiveDefaultIgnorableCodePoint(uint min, uint max) + { + for (uint i = min; i <= max; i++) + { + Assert.False(UnicodeUtility.IsDefaultIgnorableCodePoint(i)); + } + } } }