diff --git a/src/SixLabors.Fonts/CaretMovement.cs b/src/SixLabors.Fonts/CaretMovement.cs
new file mode 100644
index 00000000..b780c864
--- /dev/null
+++ b/src/SixLabors.Fonts/CaretMovement.cs
@@ -0,0 +1,60 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies a caret movement operation within laid-out text.
+///
+public enum CaretMovement
+{
+ ///
+ /// Move to the previous grapheme insertion position.
+ ///
+ Previous,
+
+ ///
+ /// Move to the next grapheme insertion position.
+ ///
+ Next,
+
+ ///
+ /// Move to the previous Unicode word boundary.
+ ///
+ PreviousWord,
+
+ ///
+ /// Move to the next Unicode word boundary.
+ ///
+ NextWord,
+
+ ///
+ /// Move to the start of the current line.
+ ///
+ LineStart,
+
+ ///
+ /// Move to the end of the current line.
+ ///
+ LineEnd,
+
+ ///
+ /// Move to the start of the laid-out text.
+ ///
+ TextStart,
+
+ ///
+ /// Move to the end of the laid-out text.
+ ///
+ TextEnd,
+
+ ///
+ /// Move to the previous visual line.
+ ///
+ LineUp,
+
+ ///
+ /// Move to the next visual line.
+ ///
+ LineDown
+}
diff --git a/src/SixLabors.Fonts/CaretPlacement.cs b/src/SixLabors.Fonts/CaretPlacement.cs
new file mode 100644
index 00000000..d73d5b23
--- /dev/null
+++ b/src/SixLabors.Fonts/CaretPlacement.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies an absolute caret placement within a laid-out text scope.
+///
+public enum CaretPlacement
+{
+ ///
+ /// Place the caret at the start of the laid-out text scope.
+ ///
+ Start,
+
+ ///
+ /// Place the caret at the end of the laid-out text scope.
+ ///
+ End
+}
diff --git a/src/SixLabors.Fonts/CaretPosition.cs b/src/SixLabors.Fonts/CaretPosition.cs
new file mode 100644
index 00000000..0131b557
--- /dev/null
+++ b/src/SixLabors.Fonts/CaretPosition.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents a caret line in laid-out text.
+///
+public readonly struct CaretPosition
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The zero-based line index.
+ /// The grapheme insertion index in the original text.
+ /// The UTF-16 index in the original text.
+ /// The caret start point in pixel units.
+ /// The caret end point in pixel units.
+ /// Whether the caret has a second visual position.
+ /// The secondary caret start point in pixel units.
+ /// The secondary caret end point in pixel units.
+ /// The position to preserve when moving between visual lines.
+ internal CaretPosition(
+ int lineIndex,
+ int graphemeIndex,
+ int stringIndex,
+ Vector2 start,
+ Vector2 end,
+ bool hasSecondary,
+ Vector2 secondaryStart,
+ Vector2 secondaryEnd,
+ float lineNavigationPosition)
+ {
+ this.LineIndex = lineIndex;
+ this.GraphemeIndex = graphemeIndex;
+ this.StringIndex = stringIndex;
+ this.Start = start;
+ this.End = end;
+ this.HasSecondary = hasSecondary;
+ this.SecondaryStart = secondaryStart;
+ this.SecondaryEnd = secondaryEnd;
+ this.LineNavigationPosition = lineNavigationPosition;
+ }
+
+ ///
+ /// Gets the zero-based line index.
+ ///
+ public int LineIndex { get; }
+
+ ///
+ /// Gets the zero-based grapheme index in the original text.
+ ///
+ public int GraphemeIndex { get; }
+
+ ///
+ /// Gets the zero-based UTF-16 code unit index in the original text.
+ ///
+ public int StringIndex { get; }
+
+ ///
+ /// Gets the caret start point in pixel units.
+ ///
+ public Vector2 Start { get; }
+
+ ///
+ /// Gets the caret end point in pixel units.
+ ///
+ public Vector2 End { get; }
+
+ ///
+ /// Gets a value indicating whether a second visual caret position is available.
+ ///
+ public bool HasSecondary { get; }
+
+ ///
+ /// Gets the secondary caret start point in pixel units.
+ ///
+ public Vector2 SecondaryStart { get; }
+
+ ///
+ /// Gets the secondary caret end point in pixel units.
+ ///
+ public Vector2 SecondaryEnd { get; }
+
+ ///
+ /// Gets the position to preserve when moving between visual lines.
+ ///
+ internal float LineNavigationPosition { get; }
+}
diff --git a/src/SixLabors.Fonts/FileFontMetrics.cs b/src/SixLabors.Fonts/FileFontMetrics.cs
index e32572d4..313f4207 100644
--- a/src/SixLabors.Fonts/FileFontMetrics.cs
+++ b/src/SixLabors.Fonts/FileFontMetrics.cs
@@ -126,7 +126,7 @@ internal override bool TryGetMarkAttachmentClass(ushort glyphId, [NotNullWhen(tr
=> this.fontMetrics.Value.TryGetMarkAttachmentClass(glyphId, out markAttachmentClass);
///
- public override bool TryGetVariationAxes(out VariationAxis[]? variationAxes)
+ public override bool TryGetVariationAxes(out ReadOnlyMemory variationAxes)
=> this.fontMetrics.Value.TryGetVariationAxes(out variationAxes);
///
@@ -140,11 +140,11 @@ public override bool TryGetGlyphMetrics(
TextDecorations textDecorations,
LayoutMode layoutMode,
ColorFontSupport support,
- [NotNullWhen(true)] out GlyphMetrics? metrics)
+ [NotNullWhen(true)] out FontGlyphMetrics? metrics)
=> this.fontMetrics.Value.TryGetGlyphMetrics(codePoint, textAttributes, textDecorations, layoutMode, support, out metrics);
///
- internal override GlyphMetrics GetGlyphMetrics(
+ internal override FontGlyphMetrics GetGlyphMetrics(
CodePoint codePoint,
ushort glyphId,
TextAttributes textAttributes,
@@ -154,7 +154,7 @@ internal override GlyphMetrics GetGlyphMetrics(
=> this.fontMetrics.Value.GetGlyphMetrics(codePoint, glyphId, textAttributes, textDecorations, layoutMode, support);
///
- public override IReadOnlyList GetAvailableCodePoints()
+ public override ReadOnlyMemory GetAvailableCodePoints()
=> this.fontMetrics.Value.GetAvailableCodePoints();
///
@@ -185,8 +185,8 @@ internal override ReadOnlySpan GetNormalizedCoordinates()
/// Reads a from the specified stream.
///
/// The file path.
- /// a .
- public static FileFontMetrics[] LoadFontCollection(string path)
+ /// A read-only memory region containing the font metrics.
+ public static ReadOnlyMemory LoadFontCollection(string path)
{
using FileStream fs = File.OpenRead(path);
long startPos = fs.Position;
diff --git a/src/SixLabors.Fonts/Font.cs b/src/SixLabors.Fonts/Font.cs
index 477592ca..7ae6f092 100644
--- a/src/SixLabors.Fonts/Font.cs
+++ b/src/SixLabors.Fonts/Font.cs
@@ -261,7 +261,7 @@ public bool TryGetGlyph(
[NotNullWhen(true)] out Glyph? glyph)
{
TextRun textRun = new() { Start = 0, End = 1, Font = this, TextAttributes = textAttributes, TextDecorations = textDecorations };
- if (this.FontMetrics.TryGetGlyphMetrics(codePoint, textAttributes, textDecorations, layoutMode, support, out GlyphMetrics? metrics))
+ if (this.FontMetrics.TryGetGlyphMetrics(codePoint, textAttributes, textDecorations, layoutMode, support, out FontGlyphMetrics? metrics))
{
glyph = new(metrics.CloneForRendering(textRun), this.Size);
return true;
@@ -357,10 +357,16 @@ private string LoadFontName()
}
// Can't find style requested so let's just try returning the default.
- IEnumerable? styles = this.Family.GetAvailableStyles();
- FontStyle defaultStyle = styles.Contains(FontStyle.Regular)
- ? FontStyle.Regular
- : styles.First();
+ ReadOnlySpan styles = this.Family.GetAvailableStyles().Span;
+ FontStyle defaultStyle = styles[0];
+ foreach (FontStyle style in styles)
+ {
+ if (style == FontStyle.Regular)
+ {
+ defaultStyle = FontStyle.Regular;
+ break;
+ }
+ }
this.Family.TryGetMetrics(defaultStyle, out metrics);
return metrics;
diff --git a/src/SixLabors.Fonts/FontCollection.cs b/src/SixLabors.Fonts/FontCollection.cs
index 2d99b98d..5d988955 100644
--- a/src/SixLabors.Fonts/FontCollection.cs
+++ b/src/SixLabors.Fonts/FontCollection.cs
@@ -61,19 +61,19 @@ public FontFamily Add(Stream stream, out FontDescription description)
=> this.AddImpl(stream, CultureInfo.InvariantCulture, out description);
///
- public IEnumerable AddCollection(string path)
+ public ReadOnlyMemory AddCollection(string path)
=> this.AddCollection(path, out _);
///
- public IEnumerable AddCollection(string path, out IEnumerable descriptions)
+ public ReadOnlyMemory AddCollection(string path, out ReadOnlyMemory descriptions)
=> this.AddCollectionImpl(path, CultureInfo.InvariantCulture, out descriptions);
///
- public IEnumerable AddCollection(Stream stream)
+ public ReadOnlyMemory AddCollection(Stream stream)
=> this.AddCollection(stream, out _);
///
- public IEnumerable AddCollection(Stream stream, out IEnumerable descriptions)
+ public ReadOnlyMemory AddCollection(Stream stream, out ReadOnlyMemory descriptions)
=> this.AddCollectionImpl(stream, CultureInfo.InvariantCulture, out descriptions);
///
@@ -101,25 +101,25 @@ public FontFamily AddWithCulture(Stream stream, CultureInfo culture, out FontDes
=> this.AddImpl(stream, culture, out description);
///
- public IEnumerable AddCollection(string path, CultureInfo culture)
+ public ReadOnlyMemory AddCollection(string path, CultureInfo culture)
=> this.AddCollection(path, culture, out _);
///
- public IEnumerable AddCollection(
+ public ReadOnlyMemory AddCollection(
string path,
CultureInfo culture,
- out IEnumerable descriptions)
+ out ReadOnlyMemory descriptions)
=> this.AddCollectionImpl(path, culture, out descriptions);
///
- public IEnumerable AddCollection(Stream stream, CultureInfo culture)
+ public ReadOnlyMemory AddCollection(Stream stream, CultureInfo culture)
=> this.AddCollection(stream, culture, out _);
///
- public IEnumerable AddCollection(
+ public ReadOnlyMemory AddCollection(
Stream stream,
CultureInfo culture,
- out IEnumerable descriptions)
+ out ReadOnlyMemory descriptions)
=> this.AddCollectionImpl(stream, culture, out descriptions);
///
@@ -178,7 +178,7 @@ IEnumerable IReadOnlyFontMetricsCollection.GetAllMetrics(string nam
}
///
- IEnumerable IReadOnlyFontMetricsCollection.GetAllStyles(string name, CultureInfo culture)
+ ReadOnlyMemory IReadOnlyFontMetricsCollection.GetAllStyles(string name, CultureInfo culture)
=> ((IReadOnlyFontMetricsCollection)this).GetAllMetrics(name, culture).Select(x => x.Description.Style).ToArray();
///
@@ -208,47 +208,58 @@ private FontFamily AddImpl(Stream stream, CultureInfo culture, out FontDescripti
return ((IFontMetricsCollection)this).AddMetrics(metrics, culture);
}
- private HashSet AddCollectionImpl(
+ private ReadOnlyMemory AddCollectionImpl(
string path,
CultureInfo culture,
- out IEnumerable descriptions)
+ out ReadOnlyMemory descriptions)
{
- FileFontMetrics[] fonts = FileFontMetrics.LoadFontCollection(path);
+ ReadOnlyMemory fontMetrics = FileFontMetrics.LoadFontCollection(path);
+ ReadOnlySpan fonts = fontMetrics.Span;
FontDescription[] description = new FontDescription[fonts.Length];
- HashSet families = [];
+ FontFamily[] families = new FontFamily[fonts.Length];
+ int familyCount = 0;
for (int i = 0; i < fonts.Length; i++)
{
description[i] = fonts[i].Description;
FontFamily family = ((IFontMetricsCollection)this).AddMetrics(fonts[i], culture);
- families.Add(family);
+
+ if (!families.AsSpan(0, familyCount).Contains(family))
+ {
+ families[familyCount++] = family;
+ }
}
descriptions = description;
- return families;
+ return new ReadOnlyMemory(families, 0, familyCount);
}
- private HashSet AddCollectionImpl(
+ private ReadOnlyMemory AddCollectionImpl(
Stream stream,
CultureInfo culture,
- out IEnumerable descriptions)
+ out ReadOnlyMemory descriptions)
{
long startPos = stream.Position;
using BigEndianBinaryReader reader = new(stream, true);
TtcHeader ttcHeader = TtcHeader.Read(reader);
- List result = new((int)ttcHeader.NumFonts);
- HashSet installedFamilies = [];
+ FontDescription[] result = new FontDescription[(int)ttcHeader.NumFonts];
+ FontFamily[] installedFamilies = new FontFamily[(int)ttcHeader.NumFonts];
+ int familyCount = 0;
for (int i = 0; i < ttcHeader.NumFonts; ++i)
{
stream.Position = startPos + ttcHeader.OffsetTable[i];
StreamFontMetrics instance = StreamFontMetrics.LoadFont(stream);
- installedFamilies.Add(((IFontMetricsCollection)this).AddMetrics(instance, culture));
- FontDescription fontDescription = instance.Description;
- result.Add(fontDescription);
+ FontFamily family = ((IFontMetricsCollection)this).AddMetrics(instance, culture);
+ result[i] = instance.Description;
+
+ if (!installedFamilies.AsSpan(0, familyCount).Contains(family))
+ {
+ installedFamilies[familyCount++] = family;
+ }
}
descriptions = result;
- return installedFamilies;
+ return new ReadOnlyMemory(installedFamilies, 0, familyCount);
}
private FontFamily[] FamiliesByCultureImpl(CultureInfo culture)
diff --git a/src/SixLabors.Fonts/FontDescription.cs b/src/SixLabors.Fonts/FontDescription.cs
index da0d75d4..c1c19248 100644
--- a/src/SixLabors.Fonts/FontDescription.cs
+++ b/src/SixLabors.Fonts/FontDescription.cs
@@ -135,8 +135,8 @@ internal static FontDescription LoadDescription(FontReader reader)
/// Reads all the s from the file at the specified path (typically a .ttc file like simsun.ttc).
///
/// The file path.
- /// a .
- public static FontDescription[] LoadFontCollectionDescriptions(string path)
+ /// A read-only memory region containing the font descriptions.
+ public static ReadOnlyMemory LoadFontCollectionDescriptions(string path)
{
Guard.NotNullOrWhiteSpace(path, nameof(path));
@@ -148,8 +148,8 @@ public static FontDescription[] LoadFontCollectionDescriptions(string path)
/// Reads all the s from the specified stream (typically a .ttc file like simsun.ttc).
///
/// The stream to read the font collection from.
- /// a .
- public static FontDescription[] LoadFontCollectionDescriptions(Stream stream)
+ /// A read-only memory region containing the font descriptions.
+ public static ReadOnlyMemory LoadFontCollectionDescriptions(Stream stream)
{
long startPos = stream.Position;
using var reader = new BigEndianBinaryReader(stream, true);
diff --git a/src/SixLabors.Fonts/FontFamily.cs b/src/SixLabors.Fonts/FontFamily.cs
index 59e8db43..94d72f0a 100644
--- a/src/SixLabors.Fonts/FontFamily.cs
+++ b/src/SixLabors.Fonts/FontFamily.cs
@@ -134,8 +134,8 @@ public readonly Font CreateFont(float size, FontStyle style, params FontVariatio
///
/// Gets the collection of that are currently available.
///
- /// The .
- public readonly IEnumerable GetAvailableStyles()
+ /// A read-only memory region containing the available font styles.
+ public readonly ReadOnlyMemory GetAvailableStyles()
{
if (this == default)
{
@@ -150,31 +150,38 @@ public readonly IEnumerable GetAvailableStyles()
///
///
/// When this method returns, contains the filesystem paths to the font family sources,
- /// if the path exists; otherwise, an empty value for the type of the paths parameter.
+ /// if the path exists; otherwise, an empty memory region.
/// This parameter is passed uninitialized.
///
///
/// if the was created via filesystem paths; otherwise, .
///
- public bool TryGetPaths(out IEnumerable paths)
+ public bool TryGetPaths(out ReadOnlyMemory paths)
{
if (this == default)
{
FontsThrowHelper.ThrowDefaultInstance();
}
- var filePaths = new List();
- foreach (FontStyle style in this.GetAvailableStyles())
+ ReadOnlySpan styles = this.GetAvailableStyles().Span;
+ string[]? filePaths = null;
+ int pathCount = 0;
+
+ foreach (FontStyle style in styles)
{
if (this.collection.TryGetMetrics(this.Name, this.Culture, style, out FontMetrics? metrics)
&& metrics is FileFontMetrics fileMetrics)
{
- filePaths.Add(fileMetrics.Path);
+ filePaths ??= new string[styles.Length];
+ filePaths[pathCount++] = fileMetrics.Path;
}
}
- paths = filePaths;
- return filePaths.Count > 0;
+ paths = pathCount > 0
+ ? new ReadOnlyMemory(filePaths!, 0, pathCount)
+ : ReadOnlyMemory.Empty;
+
+ return !paths.IsEmpty;
}
///
diff --git a/src/SixLabors.Fonts/FontGlyphMetrics.cs b/src/SixLabors.Fonts/FontGlyphMetrics.cs
new file mode 100644
index 00000000..96978973
--- /dev/null
+++ b/src/SixLabors.Fonts/FontGlyphMetrics.cs
@@ -0,0 +1,534 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Tables.General;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents a glyph metric from a particular font face.
+///
+public abstract class FontGlyphMetrics
+{
+ private static readonly Vector2 YInverter = new(1, -1);
+
+ internal FontGlyphMetrics(
+ StreamFontMetrics font,
+ ushort glyphId,
+ CodePoint codePoint,
+ Bounds bounds,
+ ushort advanceWidth,
+ ushort advanceHeight,
+ short leftSideBearing,
+ short topSideBearing,
+ ushort unitsPerEM,
+ TextAttributes textAttributes,
+ TextDecorations textDecorations,
+ GlyphType glyphType)
+ {
+ this.FontMetrics = font;
+ this.GlyphId = glyphId;
+ this.CodePoint = codePoint;
+ this.Bounds = bounds;
+ this.Width = bounds.Max.X - bounds.Min.X;
+ this.Height = bounds.Max.Y - bounds.Min.Y;
+ this.UnitsPerEm = unitsPerEM;
+ this.AdvanceWidth = advanceWidth;
+ this.AdvanceHeight = advanceHeight;
+ this.LeftSideBearing = leftSideBearing;
+ this.RightSideBearing = (short)(this.AdvanceWidth - this.LeftSideBearing - this.Width);
+ this.TopSideBearing = topSideBearing;
+ this.BottomSideBearing = (short)(this.AdvanceHeight - this.TopSideBearing - this.Height);
+ this.TextAttributes = textAttributes;
+ this.TextDecorations = textDecorations;
+ this.GlyphType = glyphType;
+
+ Vector2 offset = Vector2.Zero;
+ Vector2 scaleFactor = new(unitsPerEM * 72F);
+
+ if ((textAttributes & TextAttributes.Subscript) == TextAttributes.Subscript)
+ {
+ float units = this.UnitsPerEm;
+ scaleFactor /= new Vector2(font.SubscriptXSize / units, font.SubscriptYSize / units);
+ offset = new(font.SubscriptXOffset, font.SubscriptYOffset < 0 ? font.SubscriptYOffset : -font.SubscriptYOffset);
+ }
+ else if ((textAttributes & TextAttributes.Superscript) == TextAttributes.Superscript)
+ {
+ float units = this.UnitsPerEm;
+ scaleFactor /= new Vector2(font.SuperscriptXSize / units, font.SuperscriptYSize / units);
+ offset = new(font.SuperscriptXOffset, font.SuperscriptYOffset < 0 ? -font.SuperscriptYOffset : font.SuperscriptYOffset);
+ }
+
+ this.ScaleFactor = scaleFactor;
+ this.Offset = offset;
+ }
+
+ internal FontGlyphMetrics(
+ StreamFontMetrics font,
+ ushort glyphId,
+ CodePoint codePoint,
+ Bounds bounds,
+ ushort advanceWidth,
+ ushort advanceHeight,
+ short leftSideBearing,
+ short topSideBearing,
+ ushort unitsPerEM,
+ Vector2 offset,
+ Vector2 scaleFactor,
+ TextRun textRun,
+ GlyphType glyphType)
+ {
+ // This is used during cloning. Ensure anything that could be changed is copied.
+ this.FontMetrics = font;
+ this.GlyphId = glyphId;
+ this.CodePoint = codePoint;
+ this.Bounds = new Bounds(bounds.Min, bounds.Max);
+ this.Width = bounds.Max.X - bounds.Min.X;
+ this.Height = bounds.Max.Y - bounds.Min.Y;
+ this.UnitsPerEm = unitsPerEM;
+ this.AdvanceWidth = advanceWidth;
+ this.AdvanceHeight = advanceHeight;
+ this.LeftSideBearing = leftSideBearing;
+ this.RightSideBearing = (short)(this.AdvanceWidth - this.LeftSideBearing - this.Width);
+ this.TopSideBearing = topSideBearing;
+ this.BottomSideBearing = (short)(this.AdvanceHeight - this.TopSideBearing - this.Height);
+ this.TextAttributes = textRun.TextAttributes;
+ this.TextDecorations = textRun.TextDecorations;
+ this.GlyphType = glyphType;
+ this.ScaleFactor = scaleFactor;
+ this.Offset = offset;
+ this.TextRun = textRun;
+ }
+
+ ///
+ /// Gets the font metrics.
+ ///
+ internal StreamFontMetrics FontMetrics { get; }
+
+ ///
+ /// Gets the Unicode codepoint of the glyph.
+ ///
+ public CodePoint CodePoint { get; }
+
+ ///
+ /// Gets the advance width for horizontal layout, expressed in font units.
+ ///
+ public ushort AdvanceWidth { get; private set; }
+
+ ///
+ /// Gets the advance height for vertical layout, expressed in font units.
+ ///
+ public ushort AdvanceHeight { get; private set; }
+
+ ///
+ /// Gets the left side bearing for horizontal layout, expressed in font units.
+ ///
+ public short LeftSideBearing { get; }
+
+ ///
+ /// Gets the right side bearing for horizontal layout, expressed in font units.
+ ///
+ public short RightSideBearing { get; }
+
+ ///
+ /// Gets the top side bearing for vertical layout, expressed in font units.
+ ///
+ public short TopSideBearing { get; }
+
+ ///
+ /// Gets the bottom side bearing for vertical layout, expressed in font units.
+ ///
+ public short BottomSideBearing { get; }
+
+ ///
+ /// Gets the bounds, expressed in font units.
+ ///
+ internal Bounds Bounds { get; }
+
+ ///
+ /// Gets the width, expressed in font units.
+ ///
+ public float Width { get; }
+
+ ///
+ /// Gets the height, expressed in font units.
+ ///
+ public float Height { get; }
+
+ ///
+ /// Gets the glyph type.
+ ///
+ public GlyphType GlyphType { get; }
+
+ ///
+ public ushort UnitsPerEm { get; }
+
+ ///
+ /// Gets the id of the glyph within the font tables.
+ ///
+ public ushort GlyphId { get; }
+
+ ///
+ /// Gets the scale factor that is applied to all glyphs in this face.
+ /// Normally calculated as 72 * so that 1pt = 1px
+ /// unless the glyph has that apply scaling adjustment.
+ ///
+ public Vector2 ScaleFactor { get; }
+
+ ///
+ /// Gets or sets the offset in font design units.
+ ///
+ internal Vector2 Offset { get; set; }
+
+ ///
+ /// Gets the text run that the glyph belongs to.
+ ///
+ internal TextRun TextRun { get; } = null!;
+
+ ///
+ /// Gets the text attributes applied to the glyph.
+ ///
+ public TextAttributes TextAttributes { get; }
+
+ ///
+ /// Gets the text decorations applied to the glyph.
+ ///
+ public TextDecorations TextDecorations { get; }
+
+ ///
+ /// Performs a semi-deep clone (FontMetrics are not cloned) for rendering
+ /// This allows caching the original in the font metrics.
+ ///
+ /// The current text run this glyph belongs to.
+ /// The new .
+ internal abstract FontGlyphMetrics CloneForRendering(TextRun textRun);
+
+ ///
+ /// Apply an offset to the glyph.
+ ///
+ /// The x-offset.
+ /// The y-offset.
+ internal void ApplyOffset(short x, short y)
+ => this.Offset = Vector2.Transform(this.Offset, Matrix3x2.CreateTranslation(x, y));
+
+ ///
+ /// Applies an advance to the glyph.
+ ///
+ /// The x-advance.
+ /// The y-advance.
+ internal void ApplyAdvance(short x, short y)
+ {
+ this.AdvanceWidth = (ushort)(this.AdvanceWidth + x);
+
+ // AdvanceHeight values grow downward but font-space grows upward, hence negation
+ this.AdvanceHeight = (ushort)(this.AdvanceHeight - y);
+ }
+
+ ///
+ /// Sets a new advance width.
+ ///
+ /// The x-advance.
+ internal void SetAdvanceWidth(ushort x) => this.AdvanceWidth = x;
+
+ ///
+ /// Sets a new advance height.
+ ///
+ /// The y-advance.
+ internal void SetAdvanceHeight(ushort y) => this.AdvanceHeight = y;
+
+ ///
+ /// Calculates the glyph bounding box in device-space (Y-down) coordinates,
+ /// given the layout mode, render origin, and scaled point size.
+ ///
+ ///
+ /// Steps:
+ /// 1) Select glyph bounds (or synthesize from advances if empty).
+ /// 2) Apply rotation if the layout mode is vertical-rotated.
+ /// 3) Convert from Y-up to Y-down coordinates.
+ /// 4) Scale and translate to device space using the specified origin.
+ ///
+ /// The glyph layout mode (horizontal, vertical, or vertical rotated).
+ /// The render-space origin in pixels.
+ /// The scaled point size, mapped to pixels by the caller.
+ ///
+ /// A representing the glyph bounds in device space.
+ ///
+ internal FontRectangle GetBoundingBox(GlyphLayoutMode mode, Vector2 origin, float scaledPointSize)
+ {
+ Vector2 scale = new(scaledPointSize / this.ScaleFactor.X, scaledPointSize / this.ScaleFactor.Y);
+ Bounds b = this.Bounds;
+
+ // 1) Substitute fallback bounds if the glyph has no outline.
+ if (b.Equals(Bounds.Empty))
+ {
+ if (mode == GlyphLayoutMode.Vertical)
+ {
+ // For vertical layout, set Y-up min = -AdvanceHeight to 0 so Y-down is 0..+AdvanceHeight.
+ b = new Bounds(0f, -this.AdvanceHeight, 0f, 0f);
+ }
+ else
+ {
+ // For horizontal layout, just use advance width.
+ b = new Bounds(0f, 0f, this.AdvanceWidth, 0f);
+ }
+ }
+
+ // 2) Rotate for vertical rotated layout.
+ Vector2 offsetUp = this.Offset;
+ if (mode == GlyphLayoutMode.VerticalRotated)
+ {
+ Matrix3x2 rot = Matrix3x2.CreateRotation(-MathF.PI / 2F);
+ b = Bounds.Transform(in b, rot);
+ offsetUp = Vector2.Transform(offsetUp, rot);
+ }
+
+ // 3) Flip Y to convert to device-space (Y-down).
+ Vector2 minDown = b.Min * YInverter;
+ Vector2 maxDown = b.Max * YInverter;
+ Vector2 offsetDown = offsetUp * YInverter;
+
+ // Normalize bounds after flipping.
+ float minX = MathF.Min(minDown.X, maxDown.X);
+ float maxX = MathF.Max(minDown.X, maxDown.X);
+ float minY = MathF.Min(minDown.Y, maxDown.Y);
+ float maxY = MathF.Max(minDown.Y, maxDown.Y);
+
+ // 4) Apply scaling and origin translation.
+ Vector2 size = new(maxX - minX, maxY - minY);
+ size *= scale;
+ Vector2 location = origin + ((new Vector2(minX, minY) + offsetDown) * scale);
+
+ return new FontRectangle(location.X, location.Y, size.X, size.Y);
+ }
+
+ ///
+ /// Renders the glyph to the render surface in font units relative to a bottom left origin at (0,0)
+ ///
+ /// The surface renderer.
+ /// The index of the grapheme this glyph is part of.
+ /// The origin used to render the glyph outline.
+ /// The origin used to render text decorations.
+ /// The glyph layout mode to render using.
+ /// The options used to influence the rendering of this glyph.
+ internal abstract void RenderTo(
+ IGlyphRenderer renderer,
+ int graphemeIndex,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
+ GlyphLayoutMode mode,
+ TextOptions options);
+
+ ///
+ /// Renders text decorations, such as underline, strikeout, and overline, for the current glyph to the specified
+ /// glyph renderer at the given location and layout mode.
+ ///
+ /// When rendering in vertical layout modes, decoration positions are synthesized to match common
+ /// typographic conventions. The renderer may override which decorations are enabled. Overline thickness is derived
+ /// from underline metrics if not explicitly specified.
+ /// The glyph renderer that receives the decoration drawing commands.
+ /// The position, in device-independent coordinates, where the decorations should be rendered relative to the glyph.
+ /// The layout mode that determines the orientation and positioning of the decorations (e.g., horizontal, vertical,
+ /// or vertical rotated).
+ /// The transformation matrix applied to the decoration coordinates before rendering.
+ /// The scaled pixels-per-em value used to adjust decoration size and positioning for the current rendering context.
+ /// Additional text rendering options that may influence decoration appearance or behavior.
+ protected void RenderDecorationsTo(
+ IGlyphRenderer renderer,
+ Vector2 location,
+ GlyphLayoutMode mode,
+ Matrix3x2 transform,
+ float scaledPPEM,
+ TextOptions options)
+ {
+ bool perGlyph = options.DecorationPositioningMode == DecorationPositioningMode.GlyphFont;
+ FontMetrics fontMetrics = perGlyph
+ ? this.FontMetrics
+ : options.Font.FontMetrics;
+
+ // The scale factor for the decoration length is treated separately from other factors
+ // as it is used to scale the length of the decoration line.
+ // This must always be derived from the glyph's own scale factor to ensure correct length.
+ Vector2 lengthScaleFactor = this.ScaleFactor;
+
+ // These factors determine horizontal and vertical scaling and offset for the decorations.
+ // and are either per-glyph or derived from the common font metrics.
+ Vector2 scaleFactor;
+ Vector2 offset;
+ if (perGlyph)
+ {
+ // Use the pre-calculated values from this glyph.
+ scaleFactor = this.ScaleFactor;
+ offset = this.Offset;
+ }
+ else
+ {
+ // To ensure that we share the scaling when sharing font metrics we need to
+ // recalculate the offset and scale factor here using the common font metrics.
+ scaleFactor = new(fontMetrics.UnitsPerEm * 72F);
+ offset = Vector2.Zero;
+ if ((this.TextAttributes & TextAttributes.Subscript) == TextAttributes.Subscript)
+ {
+ float units = this.UnitsPerEm;
+ scaleFactor /= new Vector2(fontMetrics.SubscriptXSize / units, fontMetrics.SubscriptYSize / units);
+ offset = new(fontMetrics.SubscriptXOffset, fontMetrics.SubscriptYOffset < 0 ? fontMetrics.SubscriptYOffset : -fontMetrics.SubscriptYOffset);
+ }
+ else if ((this.TextAttributes & TextAttributes.Superscript) == TextAttributes.Superscript)
+ {
+ float units = this.UnitsPerEm;
+ scaleFactor /= new Vector2(fontMetrics.SuperscriptXSize / units, fontMetrics.SuperscriptYSize / units);
+ offset = new(fontMetrics.SuperscriptXOffset, fontMetrics.SuperscriptYOffset < 0 ? -fontMetrics.SuperscriptYOffset : fontMetrics.SuperscriptYOffset);
+ }
+ }
+
+ bool isVerticalLayout = mode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated;
+ (Vector2 Start, Vector2 End, float Thickness) GetEnds(TextDecorations decorations, float thickness, float decoratorPosition)
+ {
+ // For vertical layout we need to draw a vertical line.
+ if (isVerticalLayout)
+ {
+ float length = mode == GlyphLayoutMode.VerticalRotated ? this.AdvanceWidth : this.AdvanceHeight;
+ if (length == 0)
+ {
+ return (Vector2.Zero, Vector2.Zero, 0);
+ }
+
+ Vector2 lengthScale = new Vector2(scaledPPEM) / lengthScaleFactor;
+ Vector2 scale = new Vector2(scaledPPEM) / scaleFactor;
+
+ // Undo the vertical offset applied when laying out the text.
+ Vector2 scaledOffset = (offset + new Vector2(decoratorPosition, 0)) * scale;
+
+ length *= lengthScale.Y;
+ thickness *= scale.X;
+
+ Vector2 tl = new(scaledOffset.X, scaledOffset.Y);
+ Vector2 tr = new(scaledOffset.X + thickness, scaledOffset.Y);
+ Vector2 bl = new(scaledOffset.X, scaledOffset.Y + length);
+
+ thickness = tr.X - tl.X;
+
+ // Horizontally offset the line to the correct horizontal position
+ // based upon which side drawing occurs of the line.
+ float m = decorations switch
+ {
+ TextDecorations.Strikeout => .5F,
+ TextDecorations.Overline => 3,
+ _ => 1,
+ };
+
+ // Account for any future pixel clamping.
+ scaledOffset = new Vector2(thickness * m, 0) + location;
+ tl += scaledOffset;
+ bl += scaledOffset;
+
+ return (tl, bl, thickness);
+ }
+ else
+ {
+ float length = this.AdvanceWidth;
+ if (length == 0)
+ {
+ return (Vector2.Zero, Vector2.Zero, 0);
+ }
+
+ Vector2 lengthScale = new Vector2(scaledPPEM) / lengthScaleFactor;
+ Vector2 scale = new Vector2(scaledPPEM) / scaleFactor;
+ Vector2 scaledOffset = (offset + new Vector2(0, decoratorPosition)) * scale;
+
+ length *= lengthScale.X;
+ thickness *= scale.Y;
+
+ Vector2 tl = new(scaledOffset.X, scaledOffset.Y);
+ Vector2 tr = new(scaledOffset.X + length, scaledOffset.Y);
+ Vector2 bl = new(scaledOffset.X, scaledOffset.Y + thickness);
+
+ thickness = bl.Y - tl.Y;
+ tl = (Vector2.Transform(tl, transform) * YInverter) + location;
+ tr = (Vector2.Transform(tr, transform) * YInverter) + location;
+
+ return (tl, tr, thickness);
+ }
+ }
+
+ void SetDecoration(TextDecorations decorations, float thickness, float position)
+ {
+ (Vector2 start, Vector2 end, float calcThickness) = GetEnds(decorations, thickness, position);
+ if (calcThickness != 0)
+ {
+ renderer.SetDecoration(decorations, start, end, calcThickness);
+ }
+ }
+
+ // Allow the renderer to override the decorations to attach.
+ // When rendering glyphs vertically we use synthesized positions based upon comparisons with Pango/browsers.
+ // We deviate from browsers in a few ways:
+ // - When rendering rotated glyphs and use the default values because it fits the glyphs better.
+ // - We include the adjusted scale for subscript and superscript glyphs.
+ // - We make no attempt to adjust the underline position along a text line to render at the same position.
+ TextDecorations decorations = renderer.EnabledDecorations();
+ bool synthesized = mode == GlyphLayoutMode.Vertical;
+ if ((decorations & TextDecorations.Underline) == TextDecorations.Underline)
+ {
+ SetDecoration(TextDecorations.Underline, fontMetrics.UnderlineThickness, synthesized ? Math.Abs(fontMetrics.UnderlinePosition) : fontMetrics.UnderlinePosition);
+ }
+
+ if ((decorations & TextDecorations.Strikeout) == TextDecorations.Strikeout)
+ {
+ SetDecoration(TextDecorations.Strikeout, fontMetrics.StrikeoutSize, synthesized ? fontMetrics.UnitsPerEm * .5F : fontMetrics.StrikeoutPosition);
+ }
+
+ if ((decorations & TextDecorations.Overline) == TextDecorations.Overline)
+ {
+ // There's no built in metrics for overline thickness so use underline.
+ SetDecoration(TextDecorations.Overline, fontMetrics.UnderlineThickness, fontMetrics.UnitsPerEm - fontMetrics.UnderlinePosition);
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether the specified code point should be skipped when rendering.
+ ///
+ /// The code point.
+ /// The .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ protected internal static bool ShouldSkipGlyphRendering(CodePoint codePoint)
+ => UnicodeUtility.ShouldNotBeRendered(codePoint);
+
+ ///
+ /// Returns the size to render/measure the glyph based on the given size and resolution in px units.
+ ///
+ /// The font size in pt units.
+ /// The DPI (Dots Per Inch) to render/measure the glyph at
+ /// The .
+ internal float GetScaledSize(float pointSize, float dpi)
+ {
+ float scaledPPEM = dpi * pointSize;
+ bool forcePPEMToInt = (this.FontMetrics.HeadFlags & HeadTable.HeadFlags.ForcePPEMToInt) != 0;
+
+ if (forcePPEMToInt)
+ {
+ scaledPPEM = MathF.Round(scaledPPEM);
+ }
+
+ return scaledPPEM;
+ }
+
+ ///
+ /// Gets the rotation matrix for the glyph based on the layout mode.
+ ///
+ /// The glyph layout mode.
+ /// The.
+ internal static Matrix3x2 GetRotationMatrix(GlyphLayoutMode mode)
+ {
+ if (mode == GlyphLayoutMode.VerticalRotated)
+ {
+ // Rotate 90 degrees clockwise.
+ return Matrix3x2.CreateRotation(-MathF.PI / 2F);
+ }
+
+ return Matrix3x2.Identity;
+ }
+}
diff --git a/src/SixLabors.Fonts/FontMetrics.cs b/src/SixLabors.Fonts/FontMetrics.cs
index 85e897fe..892c9a7a 100644
--- a/src/SixLabors.Fonts/FontMetrics.cs
+++ b/src/SixLabors.Fonts/FontMetrics.cs
@@ -177,9 +177,9 @@ internal FontMetrics()
/// Tries to get the variation axes that this font supports.
/// The font needs to have a fvar table.
///
- /// An array with Variation axes.
+ /// A read-only memory region containing the variation axes.
/// True, if fvar table is present.
- public abstract bool TryGetVariationAxes(out VariationAxis[]? variationAxes);
+ public abstract bool TryGetVariationAxes(out ReadOnlyMemory variationAxes);
///
/// Returns a value indicating whether the specified glyph is in the given mark filtering set.
@@ -213,13 +213,13 @@ public abstract bool TryGetGlyphMetrics(
TextDecorations textDecorations,
LayoutMode layoutMode,
ColorFontSupport support,
- [NotNullWhen(true)] out GlyphMetrics? metrics);
+ [NotNullWhen(true)] out FontGlyphMetrics? metrics);
///
/// Gets the unicode codepoints for which a glyph exists in the font.
///
- /// The .
- public abstract IReadOnlyList GetAvailableCodePoints();
+ /// A read-only memory region containing the available codepoints.
+ public abstract ReadOnlyMemory GetAvailableCodePoints();
///
/// Gets the glyph metrics for a given code point and glyph id.
@@ -233,8 +233,8 @@ public abstract bool TryGetGlyphMetrics(
/// The text decorations applied to the glyph.
/// The layout mode applied to the glyph.
/// Options for enabling color font support during layout and rendering.
- /// The .
- internal abstract GlyphMetrics GetGlyphMetrics(
+ /// The font glyph metrics.
+ internal abstract FontGlyphMetrics GetGlyphMetrics(
CodePoint codePoint,
ushort glyphId,
TextAttributes textAttributes,
diff --git a/src/SixLabors.Fonts/Glyph.cs b/src/SixLabors.Fonts/Glyph.cs
index c69247ec..863512f7 100644
--- a/src/SixLabors.Fonts/Glyph.cs
+++ b/src/SixLabors.Fonts/Glyph.cs
@@ -7,42 +7,48 @@
namespace SixLabors.Fonts;
///
-/// A glyph from a particular font face.
+/// Represents a font-specific glyph at the point size used for layout and rendering.
///
public readonly struct Glyph
{
private readonly float pointSize;
- internal Glyph(GlyphMetrics glyphMetrics, float pointSize)
+ internal Glyph(FontGlyphMetrics glyphMetrics, float pointSize)
{
this.GlyphMetrics = glyphMetrics;
this.pointSize = pointSize;
}
///
- /// Gets the glyph metrics.
+ /// Gets the font metrics for this glyph.
///
- public GlyphMetrics GlyphMetrics { get; }
+ public FontGlyphMetrics GlyphMetrics { get; }
///
- /// Calculates the bounding box.
+ /// Calculates the rendered glyph bounds for the specified layout mode and origin.
///
- /// The glyph layout mode to measure using.
- /// The location to calculate from.
- /// The DPI (Dots Per Inch) to measure the glyph at.
- /// The bounding box
- public FontRectangle BoundingBox(GlyphLayoutMode mode, Vector2 location, float dpi)
- => this.GlyphMetrics.GetBoundingBox(mode, location, this.pointSize * dpi);
+ /// The glyph layout mode to measure with.
+ /// The glyph origin to calculate the bounds from.
+ /// The DPI to measure the glyph at.
+ /// The rendered glyph bounds.
+ public FontRectangle BoundingBox(GlyphLayoutMode mode, Vector2 glyphOrigin, float dpi)
+ => this.GlyphMetrics.GetBoundingBox(mode, glyphOrigin, this.pointSize * dpi);
///
- /// Renders the glyph to the render surface relative to a top left origin.
+ /// Renders the glyph to the render surface.
///
- /// The surface.
+ /// The target render surface.
/// The index of the grapheme this glyph is part of.
- /// The location to render the glyph at.
- /// The offset of the glyph vector relative to the top-left position of the glyph advance.
+ /// The origin used to render the glyph outline.
+ /// The origin used to render text decorations.
/// The glyph layout mode to render using.
/// The options to render using.
- internal void RenderTo(IGlyphRenderer surface, int graphemeIndex, Vector2 location, Vector2 offset, GlyphLayoutMode mode, TextOptions options)
- => this.GlyphMetrics.RenderTo(surface, graphemeIndex, location, offset, mode, options);
+ internal void RenderTo(
+ IGlyphRenderer surface,
+ int graphemeIndex,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
+ GlyphLayoutMode mode,
+ TextOptions options)
+ => this.GlyphMetrics.RenderTo(surface, graphemeIndex, glyphOrigin, decorationOrigin, mode, options);
}
diff --git a/src/SixLabors.Fonts/GlyphBounds.cs b/src/SixLabors.Fonts/GlyphBounds.cs
deleted file mode 100644
index 87730e98..00000000
--- a/src/SixLabors.Fonts/GlyphBounds.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (c) Six Labors.
-// Licensed under the Six Labors Split License.
-
-using SixLabors.Fonts.Unicode;
-
-namespace SixLabors.Fonts;
-
-///
-/// Represents the bounds of a for a given .
-///
-public readonly struct GlyphBounds
-{
- ///
- /// Initializes a new instance of the struct.
- ///
- /// The Unicode codepoint for the glyph.
- /// The glyph bounds.
- /// The index of the grapheme in original text.
- /// The index of the codepoint in original text..
- public GlyphBounds(CodePoint codePoint, in FontRectangle bounds, int graphemeIndex, int stringIndex)
- {
- this.Codepoint = codePoint;
- this.Bounds = bounds;
- this.GraphemeIndex = graphemeIndex;
- this.StringIndex = stringIndex;
- }
-
- ///
- /// Gets the Unicode codepoint of the glyph.
- ///
- public CodePoint Codepoint { get; }
-
- ///
- /// Gets the glyph bounds.
- ///
- public FontRectangle Bounds { get; }
-
- ///
- /// Gets grapheme index of glyph in original text.
- ///
- public int GraphemeIndex { get; }
-
- ///
- /// Gets string index of glyph in original text.
- ///
- public int StringIndex { get; }
-
- ///
- public override string ToString()
- => $"Codepoint: {this.Codepoint}, Bounds: {this.Bounds}.";
-}
diff --git a/src/SixLabors.Fonts/GlyphLayout.cs b/src/SixLabors.Fonts/GlyphLayout.cs
index 35ebf5db..67480e2a 100644
--- a/src/SixLabors.Fonts/GlyphLayout.cs
+++ b/src/SixLabors.Fonts/GlyphLayout.cs
@@ -7,69 +7,85 @@
namespace SixLabors.Fonts;
///
-/// A glyphs layout and location
+/// Represents the layout positions of a glyph entry emitted from a laid-out .
///
internal readonly struct GlyphLayout
{
internal GlyphLayout(
Glyph glyph,
- Vector2 boxLocation,
- Vector2 penLocation,
- Vector2 offset,
+ Vector2 advanceOrigin,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
float advanceWidth,
float advanceHeight,
GlyphLayoutMode layoutMode,
+ int bidiLevel,
bool isStartOfLine,
int graphemeIndex,
int stringIndex)
{
this.Glyph = glyph;
this.CodePoint = glyph.GlyphMetrics.CodePoint;
- this.BoxLocation = boxLocation;
- this.PenLocation = penLocation;
- this.Offset = offset;
+ this.AdvanceOrigin = advanceOrigin;
+ this.GlyphOrigin = glyphOrigin;
+ this.DecorationOrigin = decorationOrigin;
this.AdvanceX = advanceWidth;
this.AdvanceY = advanceHeight;
this.LayoutMode = layoutMode;
+ this.BidiLevel = bidiLevel;
this.IsStartOfLine = isStartOfLine;
this.GraphemeIndex = graphemeIndex;
this.StringIndex = stringIndex;
}
///
- /// Gets the glyph.
+ /// Gets the font-specific glyph for this laid-out glyph entry.
///
public Glyph Glyph { get; }
///
- /// Gets the codepoint represented by this glyph.
+ /// Gets the code point represented by this glyph.
///
public CodePoint CodePoint { get; }
///
- /// Gets the location of the glyph box.
+ /// Gets the origin of the logical advance box in DPI-normalized layout units.
///
- public Vector2 BoxLocation { get; }
+ ///
+ /// Multiply by the target DPI to convert to device pixels.
+ ///
+ public Vector2 AdvanceOrigin { get; }
///
- /// Gets the location to render the glyph at.
+ /// Gets the origin used to render the glyph outline in DPI-normalized layout units.
///
- public Vector2 PenLocation { get; }
+ ///
+ /// Multiply by the target DPI to convert to device pixels.
+ ///
+ public Vector2 GlyphOrigin { get; }
///
- /// Gets the offset of the glyph vector relative to the top-left position of the glyph advance.
- /// For horizontal layout this will always be .
+ /// Gets the origin used to render text decorations in DPI-normalized layout units.
///
- public Vector2 Offset { get; }
+ ///
+ /// Multiply by the target DPI to convert to device pixels.
+ ///
+ public Vector2 DecorationOrigin { get; }
///
- /// Gets the width.
+ /// Gets the advance in the x direction in DPI-normalized layout units.
///
+ ///
+ /// Multiply by the target DPI to convert to device pixels.
+ ///
public float AdvanceX { get; }
///
- /// Gets the height.
+ /// Gets the advance in the y direction in DPI-normalized layout units.
///
+ ///
+ /// Multiply by the target DPI to convert to device pixels.
+ ///
public float AdvanceY { get; }
///
@@ -77,18 +93,23 @@ internal GlyphLayout(
///
public GlyphLayoutMode LayoutMode { get; }
+ ///
+ /// Gets the resolved bidi embedding level.
+ ///
+ internal int BidiLevel { get; }
+
///
/// Gets a value indicating whether this glyph is the first glyph on a new line.
///
public bool IsStartOfLine { get; }
///
- /// Gets grapheme index of glyph in original text.
+ /// Gets the zero-based grapheme index in the original text.
///
public int GraphemeIndex { get; }
///
- /// Gets string index of glyph in original text.
+ /// Gets the zero-based UTF-16 code unit index in the original text.
///
public int StringIndex { get; }
@@ -98,22 +119,33 @@ internal GlyphLayout(
/// The .
public bool IsWhiteSpace() => UnicodeUtility.ShouldRenderWhiteSpaceOnly(this.CodePoint);
- internal FontRectangle BoundingBox(float dpi)
- {
- // Same logic as in GlyphMetrics.RenderTo
- Vector2 location = this.PenLocation;
- Vector2 offset = this.Offset;
-
- location *= dpi;
- offset *= dpi;
- Vector2 renderLocation = location + offset;
+ ///
+ /// Measures the positioned logical advance rectangle in pixel units.
+ ///
+ /// The target DPI.
+ /// The measured advance rectangle.
+ internal FontRectangle MeasureAdvance(float dpi)
+ => new(
+ this.AdvanceOrigin.X * dpi,
+ this.AdvanceOrigin.Y * dpi,
+ this.AdvanceX * dpi,
+ this.AdvanceY * dpi);
- FontRectangle box = this.Glyph.BoundingBox(this.LayoutMode, renderLocation, dpi);
+ ///
+ /// Measures the rendered glyph bounds in pixel units.
+ ///
+ /// The target DPI.
+ /// The measured rendered bounds.
+ internal FontRectangle MeasureBounds(float dpi)
+ {
+ // Same logic as in GlyphMetrics.RenderTo.
+ Vector2 glyphOrigin = this.GlyphOrigin * dpi;
+ FontRectangle box = this.Glyph.BoundingBox(this.LayoutMode, glyphOrigin, dpi);
+ // Whitespace uses the layout advance because it occupies measurable
+ // text space even though the renderer suppresses its outline.
if (this.IsWhiteSpace())
{
- // Take the layout advance width/height to account for advance multipliers that can cause
- // the glyph to extend beyond the box. For example '\t'.
if (this.LayoutMode == GlyphLayoutMode.Vertical)
{
return new FontRectangle(
@@ -147,7 +179,7 @@ public override string ToString()
{
string s = this.IsStartOfLine ? "@ " : string.Empty;
string ws = this.IsWhiteSpace() ? "!" : string.Empty;
- Vector2 l = this.PenLocation;
+ Vector2 l = this.GlyphOrigin;
return $"{s}{ws}{this.CodePoint.ToDebuggerDisplay()} {l.X},{l.Y} {this.AdvanceX}x{this.AdvanceY}";
}
}
diff --git a/src/SixLabors.Fonts/GlyphLayoutData.cs b/src/SixLabors.Fonts/GlyphLayoutData.cs
new file mode 100644
index 00000000..d9643bf0
--- /dev/null
+++ b/src/SixLabors.Fonts/GlyphLayoutData.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Collections.Generic;
+using System.Diagnostics;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Per-codepoint shaping data stored inside a .
+/// Each entry corresponds to a single codepoint — complex scripts may map one grapheme to
+/// multiple entries (tracked via ).
+///
+[DebuggerDisplay("{DebuggerDisplay,nq}")]
+internal struct GlyphLayoutData
+{
+ internal const int NoHyphenationMarker = -1;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The shaped glyph metrics for this codepoint.
+ /// The point size at which the glyph is rendered.
+ /// The scaled advance of this entry.
+ /// The scaled line height contributed by this entry.
+ /// The scaled typographic ascender.
+ /// The scaled typographic descender.
+ /// The symmetric metrics delta applied during line-box construction.
+ /// The minimum scaled Y (topmost ink) across .
+ /// The resolved bidi run this entry belongs to.
+ /// The grapheme index in the source text.
+ /// Whether this is the last codepoint in its grapheme cluster.
+ /// The codepoint index in the source text.
+ /// The index of this codepoint within its grapheme cluster.
+ /// Whether the entry participates in a transformed vertical layout.
+ /// Whether the entry was produced by Unicode decomposition.
+ /// The UTF-16 character index in the source string.
+ /// The marker index to use if this entry becomes a selected soft-hyphen break.
+ public GlyphLayoutData(
+ IReadOnlyList metrics,
+ float pointSize,
+ float scaledAdvance,
+ float scaledLineHeight,
+ float scaledAscender,
+ float scaledDescender,
+ float scaledDelta,
+ float scaledMinY,
+ BidiRun bidiRun,
+ int graphemeIndex,
+ bool isLastInGrapheme,
+ int codePointIndex,
+ int graphemeCodePointIndex,
+ bool isTransformed,
+ bool isDecomposed,
+ int stringIndex,
+ int hyphenationMarkerIndex = NoHyphenationMarker)
+ {
+ this.Metrics = metrics;
+ this.PointSize = pointSize;
+ this.ScaledAdvance = scaledAdvance;
+ this.ScaledLineHeight = scaledLineHeight;
+ this.ScaledAscender = scaledAscender;
+ this.ScaledDescender = scaledDescender;
+ this.ScaledDelta = scaledDelta;
+ this.ScaledMinY = scaledMinY;
+ this.BidiRun = bidiRun;
+ this.GraphemeIndex = graphemeIndex;
+ this.IsLastInGrapheme = isLastInGrapheme;
+ this.CodePointIndex = codePointIndex;
+ this.GraphemeCodePointIndex = graphemeCodePointIndex;
+ this.IsTransformed = isTransformed;
+ this.IsDecomposed = isDecomposed;
+ this.StringIndex = stringIndex;
+ this.HyphenationMarkerIndex = hyphenationMarkerIndex;
+ }
+
+ /// Gets the source codepoint for this entry.
+ public readonly CodePoint CodePoint => this.Metrics[0].CodePoint;
+
+ /// Gets the shaped glyph metrics produced for this codepoint (one codepoint may map to several glyphs).
+ public IReadOnlyList Metrics { get; }
+
+ /// Gets the point size at which this entry is rendered.
+ public float PointSize { get; }
+
+ /// Gets or sets the scaled advance of this entry (mutated by justification).
+ public float ScaledAdvance { get; set; }
+
+ /// Gets the scaled line height contributed by this entry, before line-spacing is applied.
+ public float ScaledLineHeight { get; }
+
+ /// Gets the scaled typographic ascender.
+ public float ScaledAscender { get; }
+
+ /// Gets the scaled typographic descender.
+ public float ScaledDescender { get; }
+
+ /// Gets the symmetric ascender/descender delta applied during line-box construction.
+ public float ScaledDelta { get; }
+
+ /// Gets the smallest (most negative) scaled Y across .
+ public float ScaledMinY { get; }
+
+ /// Gets the resolved bidi run this entry belongs to.
+ public BidiRun BidiRun { get; }
+
+ /// Gets the text direction derived from .
+ public readonly TextDirection TextDirection => (TextDirection)this.BidiRun.Direction;
+
+ /// Gets the zero-based grapheme index in the original text.
+ public int GraphemeIndex { get; }
+
+ /// Gets or sets a value indicating whether this is the last entry in its grapheme cluster.
+ public bool IsLastInGrapheme { get; set; }
+
+ /// Gets the index of this codepoint within its grapheme cluster (0-based).
+ public int GraphemeCodePointIndex { get; }
+
+ /// Gets the codepoint index in the source text.
+ public int CodePointIndex { get; }
+
+ /// Gets a value indicating whether the entry participates in a transformed vertical layout.
+ public bool IsTransformed { get; }
+
+ /// Gets a value indicating whether the entry was produced by Unicode decomposition.
+ public bool IsDecomposed { get; }
+
+ /// Gets the zero-based UTF-16 code unit index in the original text.
+ public int StringIndex { get; }
+
+ /// Gets the marker index to use if this entry becomes a selected soft-hyphen break.
+ public int HyphenationMarkerIndex { get; }
+
+ /// Gets a value indicating whether the codepoint is a line-break character.
+ public readonly bool IsNewLine => CodePoint.IsNewLine(this.CodePoint);
+
+ private readonly string DebuggerDisplay => FormattableString
+ .Invariant($"{this.CodePoint.ToDebuggerDisplay()} : {this.TextDirection} : {this.CodePointIndex}, level: {this.BidiRun.Level}");
+}
diff --git a/src/SixLabors.Fonts/GlyphMetrics.cs b/src/SixLabors.Fonts/GlyphMetrics.cs
index fef13c07..0becda66 100644
--- a/src/SixLabors.Fonts/GlyphMetrics.cs
+++ b/src/SixLabors.Fonts/GlyphMetrics.cs
@@ -1,528 +1,71 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using System.Numerics;
-using System.Runtime.CompilerServices;
-using SixLabors.Fonts.Rendering;
-using SixLabors.Fonts.Tables.General;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts;
///
-/// Represents a glyph metric from a particular font face.
+/// Represents one laid-out glyph entry in final layout order.
///
-public abstract class GlyphMetrics
+public readonly struct GlyphMetrics
{
- private static readonly Vector2 YInverter = new(1, -1);
-
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The Unicode code point represented by the glyph entry.
+ /// The positioned logical advance rectangle for the glyph entry in pixel units.
+ /// The rendered rectangle for the glyph entry in pixel units.
+ /// The union of the positioned logical advance rectangle and rendered rectangle in pixel units.
+ /// The grapheme index in the original text.
+ /// The UTF-16 index in the original text where the glyph entry begins.
internal GlyphMetrics(
- StreamFontMetrics font,
- ushort glyphId,
CodePoint codePoint,
- Bounds bounds,
- ushort advanceWidth,
- ushort advanceHeight,
- short leftSideBearing,
- short topSideBearing,
- ushort unitsPerEM,
- TextAttributes textAttributes,
- TextDecorations textDecorations,
- GlyphType glyphType)
+ in FontRectangle advance,
+ in FontRectangle bounds,
+ in FontRectangle renderableBounds,
+ int graphemeIndex,
+ int stringIndex)
{
- this.FontMetrics = font;
- this.GlyphId = glyphId;
this.CodePoint = codePoint;
+ this.Advance = advance;
this.Bounds = bounds;
- this.Width = bounds.Max.X - bounds.Min.X;
- this.Height = bounds.Max.Y - bounds.Min.Y;
- this.UnitsPerEm = unitsPerEM;
- this.AdvanceWidth = advanceWidth;
- this.AdvanceHeight = advanceHeight;
- this.LeftSideBearing = leftSideBearing;
- this.RightSideBearing = (short)(this.AdvanceWidth - this.LeftSideBearing - this.Width);
- this.TopSideBearing = topSideBearing;
- this.BottomSideBearing = (short)(this.AdvanceHeight - this.TopSideBearing - this.Height);
- this.TextAttributes = textAttributes;
- this.TextDecorations = textDecorations;
- this.GlyphType = glyphType;
-
- Vector2 offset = Vector2.Zero;
- Vector2 scaleFactor = new(unitsPerEM * 72F);
-
- if ((textAttributes & TextAttributes.Subscript) == TextAttributes.Subscript)
- {
- float units = this.UnitsPerEm;
- scaleFactor /= new Vector2(font.SubscriptXSize / units, font.SubscriptYSize / units);
- offset = new(font.SubscriptXOffset, font.SubscriptYOffset < 0 ? font.SubscriptYOffset : -font.SubscriptYOffset);
- }
- else if ((textAttributes & TextAttributes.Superscript) == TextAttributes.Superscript)
- {
- float units = this.UnitsPerEm;
- scaleFactor /= new Vector2(font.SuperscriptXSize / units, font.SuperscriptYSize / units);
- offset = new(font.SuperscriptXOffset, font.SuperscriptYOffset < 0 ? -font.SuperscriptYOffset : font.SuperscriptYOffset);
- }
-
- this.ScaleFactor = scaleFactor;
- this.Offset = offset;
- }
-
- internal GlyphMetrics(
- StreamFontMetrics font,
- ushort glyphId,
- CodePoint codePoint,
- Bounds bounds,
- ushort advanceWidth,
- ushort advanceHeight,
- short leftSideBearing,
- short topSideBearing,
- ushort unitsPerEM,
- Vector2 offset,
- Vector2 scaleFactor,
- TextRun textRun,
- GlyphType glyphType)
- {
- // This is used during cloning. Ensure anything that could be changed is copied.
- this.FontMetrics = font;
- this.GlyphId = glyphId;
- this.CodePoint = codePoint;
- this.Bounds = new Bounds(bounds.Min, bounds.Max);
- this.Width = bounds.Max.X - bounds.Min.X;
- this.Height = bounds.Max.Y - bounds.Min.Y;
- this.UnitsPerEm = unitsPerEM;
- this.AdvanceWidth = advanceWidth;
- this.AdvanceHeight = advanceHeight;
- this.LeftSideBearing = leftSideBearing;
- this.RightSideBearing = (short)(this.AdvanceWidth - this.LeftSideBearing - this.Width);
- this.TopSideBearing = topSideBearing;
- this.BottomSideBearing = (short)(this.AdvanceHeight - this.TopSideBearing - this.Height);
- this.TextAttributes = textRun.TextAttributes;
- this.TextDecorations = textRun.TextDecorations;
- this.GlyphType = glyphType;
- this.ScaleFactor = scaleFactor;
- this.Offset = offset;
- this.TextRun = textRun;
+ this.RenderableBounds = renderableBounds;
+ this.GraphemeIndex = graphemeIndex;
+ this.StringIndex = stringIndex;
}
///
- /// Gets the font metrics.
- ///
- internal StreamFontMetrics FontMetrics { get; }
-
- ///
- /// Gets the Unicode codepoint of the glyph.
+ /// Gets the Unicode code point represented by the glyph entry.
///
public CodePoint CodePoint { get; }
///
- /// Gets the advance width for horizontal layout, expressed in font units.
+ /// Gets the positioned logical advance rectangle for the glyph entry in pixel units.
///
- public ushort AdvanceWidth { get; private set; }
+ public FontRectangle Advance { get; }
///
- /// Gets the advance height for vertical layout, expressed in font units.
+ /// Gets the rendered rectangle for the glyph entry in pixel units.
///
- public ushort AdvanceHeight { get; private set; }
+ public FontRectangle Bounds { get; }
///
- /// Gets the left side bearing for horizontal layout, expressed in font units.
+ /// Gets the union of the positioned logical advance rectangle and rendered rectangle in pixel units.
///
- public short LeftSideBearing { get; }
+ public FontRectangle RenderableBounds { get; }
///
- /// Gets the right side bearing for horizontal layout, expressed in font units.
+ /// Gets the zero-based grapheme index in the original text.
///
- public short RightSideBearing { get; }
+ public int GraphemeIndex { get; }
///
- /// Gets the top side bearing for vertical layout, expressed in font units.
+ /// Gets the zero-based UTF-16 code unit index in the original text.
///
- public short TopSideBearing { get; }
-
- ///
- /// Gets the bottom side bearing for vertical layout, expressed in font units.
- ///
- public short BottomSideBearing { get; }
-
- ///
- /// Gets the bounds, expressed in font units.
- ///
- internal Bounds Bounds { get; }
-
- ///
- /// Gets the width, expressed in font units.
- ///
- public float Width { get; }
-
- ///
- /// Gets the height, expressed in font units.
- ///
- public float Height { get; }
-
- ///
- /// Gets the glyph type.
- ///
- public GlyphType GlyphType { get; }
-
- ///
- public ushort UnitsPerEm { get; }
-
- ///
- /// Gets the id of the glyph within the font tables.
- ///
- public ushort GlyphId { get; }
-
- ///
- /// Gets the scale factor that is applied to all glyphs in this face.
- /// Normally calculated as 72 * so that 1pt = 1px
- /// unless the glyph has that apply scaling adjustment.
- ///
- public Vector2 ScaleFactor { get; }
-
- ///
- /// Gets or sets the offset in font design units.
- ///
- internal Vector2 Offset { get; set; }
-
- ///
- /// Gets the text run that the glyph belongs to.
- ///
- internal TextRun TextRun { get; } = null!;
-
- ///
- /// Gets the text attributes applied to the glyph.
- ///
- public TextAttributes TextAttributes { get; }
-
- ///
- /// Gets the text decorations applied to the glyph.
- ///
- public TextDecorations TextDecorations { get; }
-
- ///
- /// Performs a semi-deep clone (FontMetrics are not cloned) for rendering
- /// This allows caching the original in the font metrics.
- ///
- /// The current text run this glyph belongs to.
- /// The new .
- internal abstract GlyphMetrics CloneForRendering(TextRun textRun);
-
- ///
- /// Apply an offset to the glyph.
- ///
- /// The x-offset.
- /// The y-offset.
- internal void ApplyOffset(short x, short y)
- => this.Offset = Vector2.Transform(this.Offset, Matrix3x2.CreateTranslation(x, y));
-
- ///
- /// Applies an advance to the glyph.
- ///
- /// The x-advance.
- /// The y-advance.
- internal void ApplyAdvance(short x, short y)
- {
- this.AdvanceWidth = (ushort)(this.AdvanceWidth + x);
-
- // AdvanceHeight values grow downward but font-space grows upward, hence negation
- this.AdvanceHeight = (ushort)(this.AdvanceHeight - y);
- }
+ public int StringIndex { get; }
- ///
- /// Sets a new advance width.
- ///
- /// The x-advance.
- internal void SetAdvanceWidth(ushort x) => this.AdvanceWidth = x;
-
- ///
- /// Sets a new advance height.
- ///
- /// The y-advance.
- internal void SetAdvanceHeight(ushort y) => this.AdvanceHeight = y;
-
- ///
- /// Calculates the glyph bounding box in device-space (Y-down) coordinates,
- /// given the layout mode, render origin, and scaled point size.
- ///
- ///
- /// Steps:
- /// 1) Select glyph bounds (or synthesize from advances if empty).
- /// 2) Apply rotation if the layout mode is vertical-rotated.
- /// 3) Convert from Y-up to Y-down coordinates.
- /// 4) Scale and translate to device space using the specified origin.
- ///
- /// The glyph layout mode (horizontal, vertical, or vertical rotated).
- /// The render-space origin in pixels.
- /// The scaled point size, mapped to pixels by the caller.
- ///
- /// A representing the glyph bounds in device space.
- ///
- internal FontRectangle GetBoundingBox(GlyphLayoutMode mode, Vector2 origin, float scaledPointSize)
- {
- Vector2 scale = new(scaledPointSize / this.ScaleFactor.X, scaledPointSize / this.ScaleFactor.Y);
- Bounds b = this.Bounds;
-
- // 1) Substitute fallback bounds if the glyph has no outline.
- if (b.Equals(Bounds.Empty))
- {
- if (mode == GlyphLayoutMode.Vertical)
- {
- // For vertical layout, set Y-up min = -AdvanceHeight to 0 so Y-down is 0..+AdvanceHeight.
- b = new Bounds(0f, -this.AdvanceHeight, 0f, 0f);
- }
- else
- {
- // For horizontal layout, just use advance width.
- b = new Bounds(0f, 0f, this.AdvanceWidth, 0f);
- }
- }
-
- // 2) Rotate for vertical rotated layout.
- Vector2 offsetUp = this.Offset;
- if (mode == GlyphLayoutMode.VerticalRotated)
- {
- Matrix3x2 rot = Matrix3x2.CreateRotation(-MathF.PI / 2F);
- b = Bounds.Transform(in b, rot);
- offsetUp = Vector2.Transform(offsetUp, rot);
- }
-
- // 3) Flip Y to convert to device-space (Y-down).
- Vector2 minDown = b.Min * YInverter;
- Vector2 maxDown = b.Max * YInverter;
- Vector2 offsetDown = offsetUp * YInverter;
-
- // Normalize bounds after flipping.
- float minX = MathF.Min(minDown.X, maxDown.X);
- float maxX = MathF.Max(minDown.X, maxDown.X);
- float minY = MathF.Min(minDown.Y, maxDown.Y);
- float maxY = MathF.Max(minDown.Y, maxDown.Y);
-
- // 4) Apply scaling and origin translation.
- Vector2 size = new(maxX - minX, maxY - minY);
- size *= scale;
- Vector2 location = origin + ((new Vector2(minX, minY) + offsetDown) * scale);
-
- return new FontRectangle(location.X, location.Y, size.X, size.Y);
- }
-
- ///
- /// Renders the glyph to the render surface in font units relative to a bottom left origin at (0,0)
- ///
- /// The surface renderer.
- /// The index of the grapheme this glyph is part of.
- /// The location representing offset of the glyph outer bounds relative to the origin.
- /// The offset of the glyph vector relative to the top-left position of the glyph advance.
- /// The glyph layout mode to render using.
- /// The options used to influence the rendering of this glyph.
- internal abstract void RenderTo(IGlyphRenderer renderer, int graphemeIndex, Vector2 location, Vector2 offset, GlyphLayoutMode mode, TextOptions options);
-
- ///
- /// Renders text decorations, such as underline, strikeout, and overline, for the current glyph to the specified
- /// glyph renderer at the given location and layout mode.
- ///
- /// When rendering in vertical layout modes, decoration positions are synthesized to match common
- /// typographic conventions. The renderer may override which decorations are enabled. Overline thickness is derived
- /// from underline metrics if not explicitly specified.
- /// The glyph renderer that receives the decoration drawing commands.
- /// The position, in device-independent coordinates, where the decorations should be rendered relative to the glyph.
- /// The layout mode that determines the orientation and positioning of the decorations (e.g., horizontal, vertical,
- /// or vertical rotated).
- /// The transformation matrix applied to the decoration coordinates before rendering.
- /// The scaled pixels-per-em value used to adjust decoration size and positioning for the current rendering context.
- /// Additional text rendering options that may influence decoration appearance or behavior.
- protected void RenderDecorationsTo(
- IGlyphRenderer renderer,
- Vector2 location,
- GlyphLayoutMode mode,
- Matrix3x2 transform,
- float scaledPPEM,
- TextOptions options)
- {
- bool perGlyph = options.DecorationPositioningMode == DecorationPositioningMode.GlyphFont;
- FontMetrics fontMetrics = perGlyph
- ? this.FontMetrics
- : options.Font.FontMetrics;
-
- // The scale factor for the decoration length is treated separately from other factors
- // as it is used to scale the length of the decoration line.
- // This must always be derived from the glyph's own scale factor to ensure correct length.
- Vector2 lengthScaleFactor = this.ScaleFactor;
-
- // These factors determine horizontal and vertical scaling and offset for the decorations.
- // and are either per-glyph or derived from the common font metrics.
- Vector2 scaleFactor;
- Vector2 offset;
- if (perGlyph)
- {
- // Use the pre-calculated values from this glyph.
- scaleFactor = this.ScaleFactor;
- offset = this.Offset;
- }
- else
- {
- // To ensure that we share the scaling when sharing font metrics we need to
- // recalculate the offset and scale factor here using the common font metrics.
- scaleFactor = new(fontMetrics.UnitsPerEm * 72F);
- offset = Vector2.Zero;
- if ((this.TextAttributes & TextAttributes.Subscript) == TextAttributes.Subscript)
- {
- float units = this.UnitsPerEm;
- scaleFactor /= new Vector2(fontMetrics.SubscriptXSize / units, fontMetrics.SubscriptYSize / units);
- offset = new(fontMetrics.SubscriptXOffset, fontMetrics.SubscriptYOffset < 0 ? fontMetrics.SubscriptYOffset : -fontMetrics.SubscriptYOffset);
- }
- else if ((this.TextAttributes & TextAttributes.Superscript) == TextAttributes.Superscript)
- {
- float units = this.UnitsPerEm;
- scaleFactor /= new Vector2(fontMetrics.SuperscriptXSize / units, fontMetrics.SuperscriptYSize / units);
- offset = new(fontMetrics.SuperscriptXOffset, fontMetrics.SuperscriptYOffset < 0 ? -fontMetrics.SuperscriptYOffset : fontMetrics.SuperscriptYOffset);
- }
- }
-
- bool isVerticalLayout = mode is GlyphLayoutMode.Vertical or GlyphLayoutMode.VerticalRotated;
- (Vector2 Start, Vector2 End, float Thickness) GetEnds(TextDecorations decorations, float thickness, float decoratorPosition)
- {
- // For vertical layout we need to draw a vertical line.
- if (isVerticalLayout)
- {
- float length = mode == GlyphLayoutMode.VerticalRotated ? this.AdvanceWidth : this.AdvanceHeight;
- if (length == 0)
- {
- return (Vector2.Zero, Vector2.Zero, 0);
- }
-
- Vector2 lengthScale = new Vector2(scaledPPEM) / lengthScaleFactor;
- Vector2 scale = new Vector2(scaledPPEM) / scaleFactor;
-
- // Undo the vertical offset applied when laying out the text.
- Vector2 scaledOffset = (offset + new Vector2(decoratorPosition, 0)) * scale;
-
- length *= lengthScale.Y;
- thickness *= scale.X;
-
- Vector2 tl = new(scaledOffset.X, scaledOffset.Y);
- Vector2 tr = new(scaledOffset.X + thickness, scaledOffset.Y);
- Vector2 bl = new(scaledOffset.X, scaledOffset.Y + length);
-
- thickness = tr.X - tl.X;
-
- // Horizontally offset the line to the correct horizontal position
- // based upon which side drawing occurs of the line.
- float m = decorations switch
- {
- TextDecorations.Strikeout => .5F,
- TextDecorations.Overline => 3,
- _ => 1,
- };
-
- // Account for any future pixel clamping.
- scaledOffset = new Vector2(thickness * m, 0) + location;
- tl += scaledOffset;
- bl += scaledOffset;
-
- return (tl, bl, thickness);
- }
- else
- {
- float length = this.AdvanceWidth;
- if (length == 0)
- {
- return (Vector2.Zero, Vector2.Zero, 0);
- }
-
- Vector2 lengthScale = new Vector2(scaledPPEM) / lengthScaleFactor;
- Vector2 scale = new Vector2(scaledPPEM) / scaleFactor;
- Vector2 scaledOffset = (offset + new Vector2(0, decoratorPosition)) * scale;
-
- length *= lengthScale.X;
- thickness *= scale.Y;
-
- Vector2 tl = new(scaledOffset.X, scaledOffset.Y);
- Vector2 tr = new(scaledOffset.X + length, scaledOffset.Y);
- Vector2 bl = new(scaledOffset.X, scaledOffset.Y + thickness);
-
- thickness = bl.Y - tl.Y;
- tl = (Vector2.Transform(tl, transform) * YInverter) + location;
- tr = (Vector2.Transform(tr, transform) * YInverter) + location;
-
- return (tl, tr, thickness);
- }
- }
-
- void SetDecoration(TextDecorations decorations, float thickness, float position)
- {
- (Vector2 start, Vector2 end, float calcThickness) = GetEnds(decorations, thickness, position);
- if (calcThickness != 0)
- {
- renderer.SetDecoration(decorations, start, end, calcThickness);
- }
- }
-
- // Allow the renderer to override the decorations to attach.
- // When rendering glyphs vertically we use synthesized positions based upon comparisons with Pango/browsers.
- // We deviate from browsers in a few ways:
- // - When rendering rotated glyphs and use the default values because it fits the glyphs better.
- // - We include the adjusted scale for subscript and superscript glyphs.
- // - We make no attempt to adjust the underline position along a text line to render at the same position.
- TextDecorations decorations = renderer.EnabledDecorations();
- bool synthesized = mode == GlyphLayoutMode.Vertical;
- if ((decorations & TextDecorations.Underline) == TextDecorations.Underline)
- {
- SetDecoration(TextDecorations.Underline, fontMetrics.UnderlineThickness, synthesized ? Math.Abs(fontMetrics.UnderlinePosition) : fontMetrics.UnderlinePosition);
- }
-
- if ((decorations & TextDecorations.Strikeout) == TextDecorations.Strikeout)
- {
- SetDecoration(TextDecorations.Strikeout, fontMetrics.StrikeoutSize, synthesized ? fontMetrics.UnitsPerEm * .5F : fontMetrics.StrikeoutPosition);
- }
-
- if ((decorations & TextDecorations.Overline) == TextDecorations.Overline)
- {
- // There's no built in metrics for overline thickness so use underline.
- SetDecoration(TextDecorations.Overline, fontMetrics.UnderlineThickness, fontMetrics.UnitsPerEm - fontMetrics.UnderlinePosition);
- }
- }
-
- ///
- /// Gets a value indicating whether the specified code point should be skipped when rendering.
- ///
- /// The code point.
- /// The .
- [MethodImpl(MethodImplOptions.AggressiveInlining)]
- protected internal static bool ShouldSkipGlyphRendering(CodePoint codePoint)
- => UnicodeUtility.ShouldNotBeRendered(codePoint);
-
- ///
- /// Returns the size to render/measure the glyph based on the given size and resolution in px units.
- ///
- /// The font size in pt units.
- /// The DPI (Dots Per Inch) to render/measure the glyph at
- /// The .
- internal float GetScaledSize(float pointSize, float dpi)
- {
- float scaledPPEM = dpi * pointSize;
- bool forcePPEMToInt = (this.FontMetrics.HeadFlags & HeadTable.HeadFlags.ForcePPEMToInt) != 0;
-
- if (forcePPEMToInt)
- {
- scaledPPEM = MathF.Round(scaledPPEM);
- }
-
- return scaledPPEM;
- }
-
- ///
- /// Gets the rotation matrix for the glyph based on the layout mode.
- ///
- /// The glyph layout mode.
- /// The.
- internal static Matrix3x2 GetRotationMatrix(GlyphLayoutMode mode)
- {
- if (mode == GlyphLayoutMode.VerticalRotated)
- {
- // Rotate 90 degrees clockwise.
- return Matrix3x2.CreateRotation(-MathF.PI / 2F);
- }
-
- return Matrix3x2.Identity;
- }
+ ///
+ public override string ToString()
+ => $"CodePoint: {this.CodePoint}, Advance: {this.Advance}, Bounds: {this.Bounds}, RenderableBounds: {this.RenderableBounds}.";
}
diff --git a/src/SixLabors.Fonts/GlyphPositioningCollection.cs b/src/SixLabors.Fonts/GlyphPositioningCollection.cs
index 2a4d3389..a8e23645 100644
--- a/src/SixLabors.Fonts/GlyphPositioningCollection.cs
+++ b/src/SixLabors.Fonts/GlyphPositioningCollection.cs
@@ -97,7 +97,7 @@ public void DisableShapingFeature(int index, Tag feature)
/// Whether the glyph is the result of a substitution.
/// Whether the glyph is the result of a vertical substitution.
/// 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.
@@ -110,9 +110,9 @@ public bool TryGetGlyphMetricsAtOffset(
out bool isSubstituted,
out bool isVerticalSubstitution,
out bool isDecomposed,
- [NotNullWhen(true)] out IReadOnlyList? metrics)
+ [NotNullWhen(true)] out IReadOnlyList? data)
{
- List match = [];
+ List match = [];
pointSize = 0;
isSubstituted = false;
isVerticalSubstitution = false;
@@ -132,18 +132,22 @@ public bool TryGetGlyphMetricsAtOffset(
}
GlyphPositioningData glyph = this.glyphs[i];
- isSubstituted = glyph.Data.IsSubstituted;
- isDecomposed = glyph.Data.IsDecomposed;
-
- foreach (Tag feature in glyph.Data.AppliedFeatures)
+ if (!glyph.Data.IsPlaceholder)
{
- isVerticalSubstitution |= feature == vert;
- isVerticalSubstitution |= feature == vrt2;
- isVerticalSubstitution |= feature == vrtr;
+ isSubstituted = glyph.Data.IsSubstituted;
+ isDecomposed = glyph.Data.IsDecomposed;
+
+ foreach (Tag feature in glyph.Data.AppliedFeatures)
+ {
+ isVerticalSubstitution |= feature == vert;
+ isVerticalSubstitution |= feature == vrt2;
+ isVerticalSubstitution |= feature == vrtr;
+ }
+
+ pointSize = glyph.PointSize;
}
- pointSize = glyph.PointSize;
- match.Add(glyph.Metrics);
+ match.Add(glyph);
}
else if (match.Count > 0)
{
@@ -152,7 +156,7 @@ public bool TryGetGlyphMetricsAtOffset(
}
}
- metrics = match;
+ data = match;
return match.Count > 0;
}
@@ -208,7 +212,7 @@ public bool TryUpdate(Font font, GlyphSubstitutionCollection collection)
isVertical |= feature == vrtr;
}
- GlyphMetrics metrics = fontMetrics.GetGlyphMetrics(codePoint, id, textAttributes, textDecorations, layoutMode, colorFontSupport);
+ FontGlyphMetrics metrics = fontMetrics.GetGlyphMetrics(codePoint, id, textAttributes, textDecorations, layoutMode, colorFontSupport);
{
// If the glyphs are fallbacks we don't want them as
// we've already captured them on the first run.
@@ -278,6 +282,35 @@ public bool TryAdd(Font font, GlyphSubstitutionCollection collection)
CodePoint codePoint = data.CodePoint;
ushort id = data.GlyphId;
+ if (data.IsPlaceholder)
+ {
+ // Placeholders are synthetic glyphs: they need layout metrics but must not
+ // go through font glyph lookup, fallback resolution, or GPOS positioning.
+ StreamFontMetrics streamFontMetrics = fontMetrics is FileFontMetrics fileFontMetrics
+ ? fileFontMetrics.StreamFontMetrics
+ : (StreamFontMetrics)fontMetrics;
+
+ FontGlyphMetrics placeholderMetrics = new PlaceholderGlyphMetrics(
+ streamFontMetrics,
+ data.TextRun.Placeholder.GetValueOrDefault(),
+ font.Size,
+ this.TextOptions.Dpi,
+ data.TextRun);
+
+ GlyphShapingBounds placeholderBounds = layoutMode.IsVertical()
+ ? new(0, 0, 0, placeholderMetrics.AdvanceHeight)
+ : new(0, 0, placeholderMetrics.AdvanceWidth, 0);
+
+ GlyphShapingData placeholderData = new(data, true)
+ {
+ Bounds = placeholderBounds,
+ IsPositioned = true
+ };
+
+ this.glyphs.Add(new(offset, placeholderData, font.Size, placeholderMetrics));
+ continue;
+ }
+
// 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.
TextAttributes textAttributes = data.TextRun.TextAttributes;
@@ -291,7 +324,7 @@ public bool TryAdd(Font font, GlyphSubstitutionCollection collection)
isVertical |= feature == vrtr;
}
- GlyphMetrics metrics = fontMetrics.GetGlyphMetrics(codePoint, id, textAttributes, textDecorations, layoutMode, colorFontSupport);
+ FontGlyphMetrics metrics = fontMetrics.GetGlyphMetrics(codePoint, id, textAttributes, textDecorations, layoutMode, colorFontSupport);
if (metrics.GlyphType == GlyphType.Fallback && !CodePoint.IsControl(codePoint))
{
@@ -327,7 +360,7 @@ public void UpdatePosition(FontMetrics fontMetrics, int index)
}
ushort glyphId = data.GlyphId;
- GlyphMetrics m = this.glyphs[index].Metrics;
+ FontGlyphMetrics m = this.glyphs[index].Metrics;
if (m.GlyphId == glyphId && fontMetrics == m.FontMetrics)
{
@@ -363,7 +396,7 @@ public void Advance(FontMetrics fontMetrics, int index, ushort glyphId, short dx
Tag vrtr = KnownFeatureTags.VerticalAlternatesForRotation;
GlyphPositioningData glyph = this.glyphs[index];
- GlyphMetrics m = glyph.Metrics;
+ FontGlyphMetrics m = glyph.Metrics;
if (m.GlyphId == glyphId && fontMetrics == m.FontMetrics)
{
@@ -398,9 +431,9 @@ public bool ShouldProcess(FontMetrics fontMetrics, int index)
}
[DebuggerDisplay("{DebuggerDisplay,nq}")]
- private class GlyphPositioningData
+ public class GlyphPositioningData
{
- public GlyphPositioningData(int offset, GlyphShapingData data, float pointSize, GlyphMetrics metrics)
+ public GlyphPositioningData(int offset, GlyphShapingData data, float pointSize, FontGlyphMetrics metrics)
{
this.Offset = offset;
this.Data = data;
@@ -414,7 +447,7 @@ public GlyphPositioningData(int offset, GlyphShapingData data, float pointSize,
public float PointSize { get; set; }
- public GlyphMetrics Metrics { get; set; }
+ public FontGlyphMetrics Metrics { get; set; }
private string DebuggerDisplay => FormattableString.Invariant($"Offset: {this.Offset}, Data: {this.Data.ToDebuggerDisplay()}");
}
diff --git a/src/SixLabors.Fonts/GlyphShapingData.cs b/src/SixLabors.Fonts/GlyphShapingData.cs
index e144a2f6..910cc895 100644
--- a/src/SixLabors.Fonts/GlyphShapingData.cs
+++ b/src/SixLabors.Fonts/GlyphShapingData.cs
@@ -41,6 +41,8 @@ public GlyphShapingData(GlyphShapingData data, bool clearFeatures = false)
this.CursiveAttachment = data.CursiveAttachment;
this.IsSubstituted = data.IsSubstituted;
this.IsDecomposed = data.IsDecomposed;
+ this.IsPlaceholder = data.IsPlaceholder;
+ this.BidiRun = data.BidiRun;
this.IsPositioned = data.IsPositioned;
this.IsKerned = data.IsKerned;
@@ -183,6 +185,16 @@ public ushort GlyphId
///
public bool IsDecomposed { get; set; }
+ ///
+ /// Gets or sets a value indicating whether this glyph represents an inline placeholder.
+ ///
+ public bool IsPlaceholder { get; set; }
+
+ ///
+ /// Gets or sets the bidi run assigned to an inline placeholder.
+ ///
+ public BidiRun BidiRun { get; set; }
+
///
/// Gets or sets a value indicating whether this glyph has been positioned.
///
diff --git a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs
index 68ed4d59..23895fa2 100644
--- a/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs
+++ b/src/SixLabors.Fonts/GlyphSubstitutionCollection.cs
@@ -130,6 +130,23 @@ public void AddGlyph(ushort glyphId, CodePoint codePoint, TextDirection directio
GlyphId = glyphId,
}));
+ ///
+ /// Adds an atomic inline placeholder to the collection.
+ ///
+ /// The object replacement codepoint used for Unicode processing.
+ /// The resolved bidi run for the placeholder.
+ /// The text run this placeholder belongs to.
+ /// The zero-based index within the input codepoint collection.
+ public void AddPlaceholder(CodePoint codePoint, BidiRun bidiRun, TextRun textRun, int offset)
+ => this.glyphs.Add(new(offset, new(textRun)
+ {
+ CodePoint = codePoint,
+ Direction = (TextDirection)bidiRun.Direction,
+ GlyphId = 0,
+ IsPlaceholder = true,
+ BidiRun = bidiRun,
+ }));
+
///
/// Moves the specified glyph to the specified position.
///
@@ -218,9 +235,7 @@ public void Sort(int startIndex, int endIndex, Comparison comp
while (j > startIndex && comparer(glyphs[j - 1].Data, glyphs[j].Data) > 0)
{
// Swap Data references between adjacent slots.
- GlyphShapingData temp = glyphs[j - 1].Data;
- glyphs[j - 1].Data = glyphs[j].Data;
- glyphs[j].Data = temp;
+ (glyphs[j].Data, glyphs[j - 1].Data) = (glyphs[j - 1].Data, glyphs[j].Data);
j--;
}
}
diff --git a/src/SixLabors.Fonts/GlyphType.cs b/src/SixLabors.Fonts/GlyphType.cs
index 38dd0417..c627d643 100644
--- a/src/SixLabors.Fonts/GlyphType.cs
+++ b/src/SixLabors.Fonts/GlyphType.cs
@@ -21,5 +21,10 @@ public enum GlyphType
///
/// This is a multi-layer colored glyph (emoji).
///
- Painted
+ Painted,
+
+ ///
+ /// This is an atomic inline placeholder supplied by the caller.
+ ///
+ Placeholder
}
diff --git a/src/SixLabors.Fonts/GraphemeMetrics.cs b/src/SixLabors.Fonts/GraphemeMetrics.cs
new file mode 100644
index 00000000..d99f8172
--- /dev/null
+++ b/src/SixLabors.Fonts/GraphemeMetrics.cs
@@ -0,0 +1,73 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents one coalesced grapheme in final layout order.
+///
+public readonly struct GraphemeMetrics
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The positioned logical advance rectangle for the grapheme in pixel units.
+ /// The rendered glyph bounds for the grapheme in pixel units.
+ /// The union of the positioned logical advance bounds and rendered glyph bounds in pixel units.
+ /// The grapheme index in the original text.
+ /// The UTF-16 index in the original text where the grapheme begins.
+ /// The resolved bidi embedding level.
+ /// Whether the grapheme represents a line break.
+ internal GraphemeMetrics(
+ FontRectangle advance,
+ FontRectangle bounds,
+ FontRectangle renderableBounds,
+ int graphemeIndex,
+ int stringIndex,
+ int bidiLevel,
+ bool isLineBreak)
+ {
+ this.Advance = advance;
+ this.Bounds = bounds;
+ this.RenderableBounds = renderableBounds;
+ this.GraphemeIndex = graphemeIndex;
+ this.StringIndex = stringIndex;
+ this.BidiLevel = bidiLevel;
+ this.IsLineBreak = isLineBreak;
+ }
+
+ ///
+ /// Gets the positioned logical advance rectangle for the grapheme in pixel units.
+ ///
+ public FontRectangle Advance { get; }
+
+ ///
+ /// Gets the rendered glyph bounds for the grapheme in pixel units.
+ ///
+ public FontRectangle Bounds { get; }
+
+ ///
+ /// Gets the union of the positioned logical advance bounds and rendered glyph bounds in pixel units.
+ ///
+ public FontRectangle RenderableBounds { get; }
+
+ ///
+ /// Gets the zero-based grapheme index in the original text.
+ ///
+ public int GraphemeIndex { get; }
+
+ ///
+ /// Gets the zero-based UTF-16 code unit index in the original text.
+ ///
+ public int StringIndex { get; }
+
+ ///
+ /// Gets the resolved bidi embedding level.
+ ///
+ internal int BidiLevel { get; }
+
+ ///
+ /// Gets a value indicating whether this grapheme represents a line break.
+ ///
+ public bool IsLineBreak { get; }
+}
diff --git a/src/SixLabors.Fonts/IFontCollection.cs b/src/SixLabors.Fonts/IFontCollection.cs
index 162e4ebc..0577b961 100644
--- a/src/SixLabors.Fonts/IFontCollection.cs
+++ b/src/SixLabors.Fonts/IFontCollection.cs
@@ -45,31 +45,31 @@ public interface IFontCollection : IReadOnlyFontCollection
/// Adds a true type font collection (.ttc).
///
/// The font collection path.
- /// The new .
- public IEnumerable AddCollection(string path);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(string path);
///
/// Adds a true type font collection (.ttc).
///
/// The font collection path.
/// The descriptions of the added fonts.
- /// The new .
- public IEnumerable AddCollection(string path, out IEnumerable descriptions);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(string path, out ReadOnlyMemory descriptions);
///
/// Adds a true type font collection (.ttc).
///
/// The font stream.
- /// The new .
- public IEnumerable AddCollection(Stream stream);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(Stream stream);
///
/// Adds a true type font collection (.ttc).
///
/// The font stream.
/// The descriptions of the added fonts.
- /// The new .
- public IEnumerable AddCollection(Stream stream, out IEnumerable descriptions);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(Stream stream, out ReadOnlyMemory descriptions);
///
/// Adds a font to the collection.
@@ -110,8 +110,8 @@ public interface IFontCollection : IReadOnlyFontCollection
///
/// The font collection path.
/// The culture of the fonts to add.
- /// The new .
- public IEnumerable AddCollection(string path, CultureInfo culture);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(string path, CultureInfo culture);
///
/// Adds a true type font collection (.ttc).
@@ -119,19 +119,19 @@ public interface IFontCollection : IReadOnlyFontCollection
/// The font collection path.
/// The culture of the fonts to add.
/// The descriptions of the added fonts.
- /// The new .
- public IEnumerable AddCollection(
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(
string path,
CultureInfo culture,
- out IEnumerable descriptions);
+ out ReadOnlyMemory descriptions);
///
/// Adds a true type font collection (.ttc).
///
/// The font stream.
/// The culture of the fonts to add.
- /// The new .
- public IEnumerable AddCollection(Stream stream, CultureInfo culture);
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(Stream stream, CultureInfo culture);
///
/// Adds a true type font collection (.ttc).
@@ -139,9 +139,9 @@ public IEnumerable AddCollection(
/// The font stream.
/// The culture of the fonts to add.
/// The descriptions of the added fonts.
- /// The new .
- public IEnumerable AddCollection(
+ /// A read-only memory region containing the new values.
+ public ReadOnlyMemory AddCollection(
Stream stream,
CultureInfo culture,
- out IEnumerable descriptions);
+ out ReadOnlyMemory descriptions);
}
diff --git a/src/SixLabors.Fonts/IReadonlyFontMetricsCollection.cs b/src/SixLabors.Fonts/IReadonlyFontMetricsCollection.cs
index 0b11f645..4816825a 100644
--- a/src/SixLabors.Fonts/IReadonlyFontMetricsCollection.cs
+++ b/src/SixLabors.Fonts/IReadonlyFontMetricsCollection.cs
@@ -44,9 +44,9 @@ internal interface IReadOnlyFontMetricsCollection
///
/// The font family name.
/// The culture to use when searching for a match.
- /// The .
+ /// A read-only memory region containing the available font styles.
/// is
- public IEnumerable GetAllStyles(string name, CultureInfo culture);
+ public ReadOnlyMemory GetAllStyles(string name, CultureInfo culture);
///
public IEnumerator GetEnumerator();
diff --git a/src/SixLabors.Fonts/LineLayout.cs b/src/SixLabors.Fonts/LineLayout.cs
new file mode 100644
index 00000000..dd949f0f
--- /dev/null
+++ b/src/SixLabors.Fonts/LineLayout.cs
@@ -0,0 +1,204 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents one laid-out line from a .
+///
+public sealed class LineLayout
+{
+ private readonly TextBox textBox;
+ private readonly TextOptions options;
+ private readonly float wrappingLength;
+ private readonly int lineIndex;
+ private readonly LayoutMode layoutMode;
+ private readonly ReadOnlyMemory graphemeMetrics;
+ private readonly ReadOnlyMemory wordMetrics;
+ private GlyphMetrics[]? glyphMetrics;
+
+ internal LineLayout(
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength,
+ int lineIndex,
+ in LineMetrics metrics,
+ ReadOnlyMemory graphemeMetrics,
+ ReadOnlyMemory wordMetrics)
+ {
+ this.textBox = textBox;
+ this.options = options;
+ this.wrappingLength = wrappingLength;
+ this.lineIndex = lineIndex;
+ this.layoutMode = options.LayoutMode;
+ this.LineMetrics = metrics;
+ this.graphemeMetrics = graphemeMetrics;
+ this.wordMetrics = wordMetrics;
+ }
+
+ ///
+ /// Gets the measured line metrics.
+ ///
+ public LineMetrics LineMetrics { get; }
+
+ ///
+ /// Gets the grapheme metrics entries for this line in final layout order.
+ ///
+ public ReadOnlySpan GraphemeMetrics => this.graphemeMetrics.Span;
+
+ ///
+ /// Hit tests the supplied point against this line's grapheme advance bounds.
+ ///
+ /// The point in pixel units.
+ /// The hit-tested grapheme position.
+ public TextHit HitTest(Vector2 point)
+ => TextInteraction.HitTestLine(this.lineIndex, this.GraphemeMetrics, point, this.layoutMode);
+
+ ///
+ /// Gets the caret position for the supplied hit.
+ ///
+ /// The hit-tested grapheme position.
+ /// The caret position in pixel units.
+ public CaretPosition GetCaretPosition(TextHit hit)
+ => TextInteraction.GetCaretPositionLine(
+ this.lineIndex,
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ hit.GraphemeInsertionIndex,
+ this.layoutMode);
+
+ ///
+ /// Gets an absolute caret position in the laid-out line.
+ ///
+ /// The absolute caret placement.
+ /// The caret position in pixel units.
+ public CaretPosition GetCaret(CaretPlacement placement)
+ => TextInteraction.GetCaretLine(
+ this.lineIndex,
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ placement,
+ this.layoutMode,
+ this.textBox.TextDirection());
+
+ ///
+ /// Moves the supplied caret by the requested operation within this line.
+ ///
+ /// The current caret position.
+ /// The movement operation.
+ /// The moved caret position in pixel units.
+ public CaretPosition MoveCaret(CaretPosition caret, CaretMovement movement)
+ => TextInteraction.MoveCaretLine(
+ this.lineIndex,
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ this.wordMetrics.Span,
+ caret,
+ movement,
+ this.layoutMode,
+ this.textBox.TextDirection());
+
+ ///
+ /// Gets the word metrics for the word-boundary segment containing the supplied hit-tested grapheme position.
+ ///
+ /// The hit-tested grapheme position.
+ /// The word metrics containing the hit grapheme.
+ public WordMetrics GetWordMetrics(TextHit hit)
+ => TextInteraction.GetWordMetrics(this.wordMetrics.Span, hit.GraphemeIndex);
+
+ ///
+ /// Gets the word metrics for the word-boundary segment containing the supplied caret position.
+ ///
+ /// The caret position.
+ /// The word metrics containing the caret's grapheme insertion index.
+ public WordMetrics GetWordMetrics(CaretPosition caret)
+ => TextInteraction.GetWordMetrics(this.wordMetrics.Span, caret.GraphemeIndex);
+
+ ///
+ /// Gets selection bounds between two hit-tested grapheme positions.
+ ///
+ /// The fixed selection endpoint.
+ /// The active selection endpoint.
+ /// A read-only memory region containing the selection bounds in visual order and pixel units.
+ public ReadOnlyMemory GetSelectionBounds(TextHit anchor, TextHit focus)
+ => TextInteraction.GetSelectionBoundsLine(
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ anchor.GraphemeInsertionIndex,
+ focus.GraphemeInsertionIndex,
+ this.layoutMode);
+
+ ///
+ /// Gets selection bounds between two caret positions.
+ ///
+ /// The fixed selection endpoint.
+ /// The active selection endpoint.
+ /// A read-only memory region containing the selection bounds in visual order and pixel units.
+ public ReadOnlyMemory GetSelectionBounds(CaretPosition anchor, CaretPosition focus)
+ => TextInteraction.GetSelectionBoundsLine(
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ anchor.GraphemeIndex,
+ focus.GraphemeIndex,
+ this.layoutMode);
+
+ ///
+ /// Gets line-local selection bounds for the supplied grapheme metrics.
+ ///
+ /// The grapheme metrics to select.
+ /// A read-only memory region containing the selection bounds in visual order and pixel units.
+ public ReadOnlyMemory GetSelectionBounds(GraphemeMetrics metrics)
+ => TextInteraction.GetSelectionBoundsLine(
+ this.LineMetrics,
+ metrics,
+ this.layoutMode);
+
+ ///
+ /// Gets line-local selection bounds for the supplied word metrics.
+ ///
+ /// The word metrics to select.
+ /// A read-only memory region containing the selection bounds in visual order and pixel units.
+ public ReadOnlyMemory GetSelectionBounds(WordMetrics metrics)
+ => TextInteraction.GetSelectionBoundsLine(
+ this.LineMetrics,
+ this.GraphemeMetrics,
+ metrics.GraphemeStart,
+ metrics.GraphemeEnd,
+ this.layoutMode);
+
+ ///
+ public ReadOnlyMemory GetGlyphMetrics()
+ => this.glyphMetrics ??= TextBlock.GetGlyphMetricsArray(
+ this.textBox,
+ this.options,
+ this.wrappingLength,
+ this.lineIndex);
+
+ ///
+ /// Renders this line to the supplied glyph renderer.
+ ///
+ /// The target renderer.
+ public void RenderTo(IGlyphRenderer renderer)
+ {
+ FontRectangle bounds = FontRectangle.Empty;
+ ReadOnlySpan glyphMetrics = this.GetGlyphMetrics().Span;
+
+ for (int i = 0; i < glyphMetrics.Length; i++)
+ {
+ bounds = i == 0
+ ? glyphMetrics[i].Bounds
+ : FontRectangle.Union(bounds, glyphMetrics[i].Bounds);
+ }
+
+ TextBlock.RenderTo(
+ renderer,
+ this.textBox,
+ this.options,
+ this.wrappingLength,
+ bounds,
+ this.lineIndex);
+ }
+}
diff --git a/src/SixLabors.Fonts/LineLayoutEnumerator.cs b/src/SixLabors.Fonts/LineLayoutEnumerator.cs
new file mode 100644
index 00000000..bcfad3f1
--- /dev/null
+++ b/src/SixLabors.Fonts/LineLayoutEnumerator.cs
@@ -0,0 +1,67 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Walks a one laid-out line at a time.
+///
+///
+/// Each produced line is positioned independently, without cumulative offsets from earlier or later lines.
+///
+public sealed class LineLayoutEnumerator
+{
+ private readonly TextBlock textBlock;
+ private readonly TextLineBreakEnumerator lineEnumerator;
+ private readonly TextDirection textDirection;
+ private readonly bool suppressLayout;
+ private LineLayout? current;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The prepared text block to enumerate.
+ internal LineLayoutEnumerator(TextBlock textBlock)
+ {
+ this.textBlock = textBlock;
+ this.lineEnumerator = new(textBlock.LogicalLine, textBlock.Options);
+ this.textDirection = TextLayout.GetTextDirection(textBlock.LogicalLine, textBlock.Options);
+ this.suppressLayout = textBlock.Options.MaxLines == 0;
+ }
+
+ ///
+ /// Gets the current line layout.
+ ///
+ public LineLayout Current => this.current!;
+
+ ///
+ /// Advances to the next line using the supplied wrapping length.
+ ///
+ ///
+ /// The wrapping length applies only to the line being produced by this call.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// when a line was produced.
+ public bool MoveNext(float wrappingLength)
+ {
+ if (this.suppressLayout)
+ {
+ return false;
+ }
+
+ if (!this.lineEnumerator.MoveNext(wrappingLength))
+ {
+ return false;
+ }
+
+ // The walker lays out each produced line independently so callers can
+ // place variable-width lines into custom columns, shapes, or virtualized
+ // surfaces without inheriting block-level line offsets.
+ this.current = this.textBlock.GetLineLayout(
+ this.lineEnumerator.Current,
+ wrappingLength,
+ this.textDirection);
+
+ return true;
+ }
+}
diff --git a/src/SixLabors.Fonts/LineMetrics.cs b/src/SixLabors.Fonts/LineMetrics.cs
index 00968cbf..712481ce 100644
--- a/src/SixLabors.Fonts/LineMetrics.cs
+++ b/src/SixLabors.Fonts/LineMetrics.cs
@@ -1,36 +1,39 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Numerics;
+
namespace SixLabors.Fonts;
///
/// Encapsulates measured metrics for a single laid-out text line.
///
-///
-/// This type is layout-mode agnostic:
-///
-/// - Horizontal layouts: is the X start position and is the width.
-/// - Vertical layouts: is the Y start position and is the height.
-///
-///
public readonly struct LineMetrics
{
///
/// Initializes a new instance of the struct.
///
- /// Ascender line position within the line box.
- /// Baseline position within the line box.
- /// Descender line position within the line box.
- /// Total line-box size (includes effective line spacing).
- /// Line start position in the primary layout flow direction after alignment.
- /// Line extent in the primary layout flow direction.
- public LineMetrics(
+ /// The ascender line position within the line box.
+ /// The baseline position within the line box.
+ /// The descender line position within the line box.
+ /// The total line-box size for this line.
+ /// The logical line box start position in pixel units.
+ /// The logical line box extent in pixel units.
+ /// The UTF-16 index in the original text where this line begins.
+ /// The grapheme index in the original text where this line begins.
+ /// The number of graphemes in the line.
+ /// The offset of this line's first grapheme metrics entry.
+ internal LineMetrics(
float ascender,
float baseline,
float descender,
float lineHeight,
- float start,
- float extent)
+ Vector2 start,
+ Vector2 extent,
+ int stringIndex,
+ int graphemeIndex,
+ int graphemeCount,
+ int graphemeOffset)
{
this.Ascender = ascender;
this.Baseline = baseline;
@@ -38,6 +41,10 @@ public LineMetrics(
this.LineHeight = lineHeight;
this.Start = start;
this.Extent = extent;
+ this.StringIndex = stringIndex;
+ this.GraphemeIndex = graphemeIndex;
+ this.GraphemeCount = graphemeCount;
+ this.GraphemeOffset = graphemeOffset;
}
///
@@ -72,12 +79,32 @@ public LineMetrics(
public float LineHeight { get; }
///
- /// Gets the line start position in the primary layout flow direction.
+ /// Gets the logical line box start position in pixel units.
+ ///
+ public Vector2 Start { get; }
+
+ ///
+ /// Gets the logical line box extent in pixel units.
+ ///
+ public Vector2 Extent { get; }
+
+ ///
+ /// Gets the zero-based UTF-16 code unit index in the original text.
+ ///
+ public int StringIndex { get; }
+
+ ///
+ /// Gets the zero-based grapheme index in the original text.
+ ///
+ public int GraphemeIndex { get; }
+
+ ///
+ /// Gets the number of graphemes in the line.
///
- public float Start { get; }
+ public int GraphemeCount { get; }
///
- /// Gets the line extent in the primary layout flow direction.
+ /// Gets the offset of this line's first entry in the flattened grapheme metrics buffer.
///
- public float Extent { get; }
+ internal int GraphemeOffset { get; }
}
diff --git a/src/SixLabors.Fonts/LogicalTextLine.cs b/src/SixLabors.Fonts/LogicalTextLine.cs
new file mode 100644
index 00000000..437e86e6
--- /dev/null
+++ b/src/SixLabors.Fonts/LogicalTextLine.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Contains a composed logical text line and its width-independent line break opportunities.
+///
+internal readonly struct LogicalTextLine
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The composed logical text line.
+ /// The collected line break opportunities.
+ /// The collected word-boundary segment runs.
+ /// The visible hyphenation markers created for soft hyphen entries.
+ public LogicalTextLine(
+ TextLine textLine,
+ List lineBreaks,
+ List wordSegments,
+ List hyphenationMarkers)
+ {
+ this.TextLine = textLine;
+ this.LineBreaks = lineBreaks;
+ this.WordSegments = wordSegments;
+ this.HyphenationMarkers = hyphenationMarkers;
+ }
+
+ ///
+ /// Gets the composed logical text line.
+ ///
+ public TextLine TextLine { get; }
+
+ ///
+ /// Gets the collected line break opportunities.
+ ///
+ public List LineBreaks { get; }
+
+ ///
+ /// Gets the collected word-boundary segment runs.
+ ///
+ public List WordSegments { get; }
+
+ ///
+ /// Gets the visible hyphenation markers created for soft hyphen entries.
+ ///
+ public List HyphenationMarkers { get; }
+}
diff --git a/src/SixLabors.Fonts/PlaceholderGlyphMetrics.cs b/src/SixLabors.Fonts/PlaceholderGlyphMetrics.cs
new file mode 100644
index 00000000..edfa262d
--- /dev/null
+++ b/src/SixLabors.Fonts/PlaceholderGlyphMetrics.cs
@@ -0,0 +1,165 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents synthetic glyph metrics for an atomic inline placeholder.
+///
+internal sealed class PlaceholderGlyphMetrics : FontGlyphMetrics
+{
+ private readonly TextPlaceholder placeholder;
+ private readonly float pointSize;
+ private readonly float dpi;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The font metrics used for shared line metrics and decoration settings.
+ /// The placeholder dimensions and alignment settings.
+ /// The point size used for layout.
+ /// The resolution used to convert placeholder pixels into layout units.
+ /// The text run this placeholder belongs to.
+ internal PlaceholderGlyphMetrics(
+ StreamFontMetrics font,
+ TextPlaceholder placeholder,
+ float pointSize,
+ float dpi,
+ TextRun textRun)
+ : base(
+ font,
+ 0,
+ CodePoint.ObjectReplacementChar,
+ GetBounds(placeholder, pointSize, dpi, font),
+ ToGlyphUnits(placeholder.Width, pointSize, dpi, font.ScaleFactor),
+ ToGlyphUnits(placeholder.Height, pointSize, dpi, font.ScaleFactor),
+ 0,
+ 0,
+ font.UnitsPerEm,
+ Vector2.Zero,
+ new Vector2(font.ScaleFactor),
+ textRun,
+ GlyphType.Placeholder)
+ {
+ this.placeholder = placeholder;
+ this.pointSize = pointSize;
+ this.dpi = dpi;
+ }
+
+ ///
+ internal override FontGlyphMetrics CloneForRendering(TextRun textRun)
+ => new PlaceholderGlyphMetrics(
+ this.FontMetrics,
+ this.placeholder,
+ this.pointSize,
+ this.dpi,
+ textRun);
+
+ ///
+ internal override void RenderTo(
+ IGlyphRenderer renderer,
+ int graphemeIndex,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
+ GlyphLayoutMode mode,
+ TextOptions options)
+ {
+ // Placeholders reserve layout space only; the caller owns the object rendering.
+ }
+
+ ///
+ /// Converts the placeholder box into glyph bounds in the same synthetic font-unit space as its advances.
+ ///
+ /// The placeholder dimensions and baseline offset.
+ /// The point size used for layout.
+ /// The resolution used to convert placeholder pixels into layout units.
+ /// The font metrics used by glyph layout.
+ /// The placeholder bounds expressed in synthetic font units.
+ private static Bounds GetBounds(TextPlaceholder placeholder, float pointSize, float dpi, StreamFontMetrics font)
+ {
+ float scaleFactor = font.ScaleFactor;
+ float width = ToGlyphUnitsFloat(placeholder.Width, pointSize, dpi, scaleFactor);
+ float height = ToGlyphUnitsFloat(placeholder.Height, pointSize, dpi, scaleFactor);
+ float baselineOffset = ToGlyphUnitsFloat(placeholder.BaselineOffset, pointSize, dpi, scaleFactor);
+ float lineHeight = font.UnitsPerEm;
+ float metricsDelta = (font.HorizontalMetrics.LineHeight - lineHeight) * .5F;
+ float ascender = font.HorizontalMetrics.Ascender - metricsDelta;
+ float descender = Math.Abs(font.HorizontalMetrics.Descender) - metricsDelta;
+ float coreHeight = ascender + descender + (2 * metricsDelta);
+ float extra = lineHeight - coreHeight;
+
+ // Top/middle/bottom align against the surrounding run font's normal
+ // line box, expressed relative to the text baseline in Y-up font units.
+ float lineTop = ascender + metricsDelta + (extra * .5F);
+ float lineBottom = lineTop - lineHeight;
+ float top = baselineOffset;
+ float bottom = baselineOffset - height;
+
+ switch (placeholder.Alignment)
+ {
+ case TextPlaceholderAlignment.AboveBaseline:
+ top = height;
+ bottom = 0;
+ break;
+
+ case TextPlaceholderAlignment.BelowBaseline:
+ top = 0;
+ bottom = -height;
+ break;
+
+ case TextPlaceholderAlignment.Top:
+ top = lineTop;
+ bottom = top - height;
+ break;
+
+ case TextPlaceholderAlignment.Bottom:
+ // Top, middle, and bottom align against the full line-height
+ // box, not just the ascender/descender band.
+ bottom = lineBottom;
+ top = bottom + height;
+ break;
+
+ case TextPlaceholderAlignment.Middle:
+ float center = (lineTop + lineBottom) * .5F;
+ top = center + (height * .5F);
+ bottom = center - (height * .5F);
+ break;
+
+ default:
+ top = baselineOffset;
+ bottom = baselineOffset - height;
+ break;
+ }
+
+ // Placeholder bounds are authored in device pixels and converted into
+ // synthetic font units so the normal glyph scaling path maps them back
+ // to device-space size while preserving the requested baseline alignment.
+ return new Bounds(0, top, width, bottom);
+ }
+
+ ///
+ /// Converts a placeholder pixel measurement into glyph units for the current layout scale.
+ ///
+ /// The placeholder measurement in pixels.
+ /// The point size used for layout.
+ /// The resolution used to convert placeholder pixels into layout units.
+ /// The font scale factor used by glyph layout.
+ /// The measurement expressed in synthetic font units.
+ private static ushort ToGlyphUnits(float pixels, float pointSize, float dpi, float scaleFactor)
+ => (ushort)MathF.Round(ToGlyphUnitsFloat(pixels, pointSize, dpi, scaleFactor));
+
+ ///
+ /// Converts a placeholder pixel measurement into fractional glyph units for bounds placement.
+ ///
+ /// The placeholder measurement in pixels.
+ /// The point size used for layout.
+ /// The resolution used to convert placeholder pixels into layout units.
+ /// The font scale factor used by glyph layout.
+ /// The measurement expressed in synthetic font units.
+ private static float ToGlyphUnitsFloat(float pixels, float pointSize, float dpi, float scaleFactor)
+ => pixels * scaleFactor / (pointSize * dpi);
+}
diff --git a/src/SixLabors.Fonts/PreparedTextLayoutDesign.md b/src/SixLabors.Fonts/PreparedTextLayoutDesign.md
new file mode 100644
index 00000000..164b5587
--- /dev/null
+++ b/src/SixLabors.Fonts/PreparedTextLayoutDesign.md
@@ -0,0 +1,534 @@
+# Text Measurement and Interaction APIs
+
+This document describes the public measurement and selection surface for laid-out
+text. The intent is that callers can measure, render, hit-test, place carets,
+and draw selections without reimplementing bidi, grapheme, hard-break, or layout
+mode rules outside the library.
+
+All positional metrics exposed by these APIs are in pixel units.
+
+## API Layers
+
+There are four layers:
+
+- `TextMeasurer`: one-shot convenience APIs for measuring a string.
+- `TextBlock`: prepared text that can be measured or rendered repeatedly.
+- `TextMetrics`: the full measurement result for one laid-out text block.
+- `LineLayout`: one laid-out line with line-local measurement and interaction APIs.
+
+Use `TextMeasurer` for simple one-off work. Use `TextBlock` when the same text
+will be measured, rendered, wrapped, or inspected more than once.
+
+## One-Shot Measurement
+
+`TextMeasurer` is the shortest path from text and options to measurements.
+`TextOptions.WrappingLength` controls wrapping for these methods.
+
+```csharp
+TextOptions options = new(font)
+{
+ Origin = new Vector2(20, 30),
+
+ // TextMeasurer reads WrappingLength from TextOptions.
+ WrappingLength = 320
+};
+
+TextMetrics metrics = TextMeasurer.Measure(text, options);
+FontRectangle advance = TextMeasurer.MeasureAdvance(text, options);
+FontRectangle bounds = TextMeasurer.MeasureBounds(text, options);
+FontRectangle renderableBounds = TextMeasurer.MeasureRenderableBounds(text, options);
+```
+
+The aggregate rectangles answer different questions:
+
+- `MeasureAdvance`: the logical line-box advance of the text.
+- `MeasureBounds`: the rendered glyph bounds.
+- `MeasureRenderableBounds`: the union of logical advance and rendered glyph bounds.
+
+Use `MeasureAdvance` for layout flow. Use `MeasureBounds` for tight ink bounds.
+Use `MeasureRenderableBounds` when both typographic advance and rendered glyph
+overshoot must fit.
+
+## Prepared Measurement
+
+`TextBlock` prepares the wrapping-independent text work once. Pass the wrapping
+length to each operation. `TextOptions.WrappingLength` is ignored by the
+constructor.
+
+```csharp
+TextBlock block = new(text, options);
+
+// Each operation supplies the wrapping length; the constructor does not.
+TextMetrics narrow = block.Measure(240);
+TextMetrics wide = block.Measure(480);
+
+FontRectangle narrowBounds = block.MeasureBounds(240);
+FontRectangle wideBounds = block.MeasureBounds(480);
+```
+
+Use `-1` as the wrapping length to disable wrapping.
+
+```csharp
+// -1 disables wrapping for TextBlock operations.
+TextMetrics unwrapped = block.Measure(-1);
+```
+
+`TextBlock` also exposes direct detail APIs when a full `TextMetrics` object is
+not needed:
+
+```csharp
+ReadOnlyMemory lines = block.GetLineMetrics(320);
+ReadOnlyMemory graphemes = block.GetGraphemeMetrics(320);
+ReadOnlyMemory words = block.GetWordMetrics(320);
+ReadOnlyMemory glyphs = block.GetGlyphMetrics(320);
+```
+
+Method-returned measurement collections use `ReadOnlyMemory` because they are
+snapshots that callers may store with their own layout state. Owner-backed
+properties, such as `TextMetrics.LineMetrics` and `LineLayout.GraphemeMetrics`,
+use `ReadOnlySpan` because the owner object already controls the lifetime.
+
+## TextMetrics
+
+`TextMetrics` is the result to keep when callers need several measurements from
+the same laid-out text.
+
+```csharp
+TextMetrics metrics = TextMeasurer.Measure(text, options);
+
+// These aggregate measurements answer different layout and rendering questions.
+FontRectangle advance = metrics.Advance;
+FontRectangle bounds = metrics.Bounds;
+FontRectangle renderableBounds = metrics.RenderableBounds;
+int lineCount = metrics.LineCount;
+
+ReadOnlySpan lines = metrics.LineMetrics;
+ReadOnlySpan graphemes = metrics.GraphemeMetrics;
+ReadOnlySpan words = metrics.WordMetrics;
+```
+
+The line and grapheme collections are in final layout order. That matters for
+bidi text and reverse line-order layout modes: source order and visual order can
+be different.
+
+`WordMetrics` are in source order because word-boundary navigation is a logical
+text operation. Selection and caret APIs convert those logical metrics back into
+visual geometry when needed.
+
+## Line Metrics
+
+`LineMetrics` describes one laid-out line.
+
+```csharp
+foreach (LineMetrics line in metrics.LineMetrics)
+{
+ // Start and Extent describe the positioned line box.
+ Vector2 start = line.Start;
+ Vector2 extent = line.Extent;
+ float baseline = line.Baseline;
+}
+```
+
+`Start` and `Extent` describe the positioned line box in pixel units. Selection
+and caret APIs use the line box for the cross-axis size, which matches normal
+text editor and browser behavior: selecting mixed font sizes on the same line
+paints a consistent line-height rectangle rather than one rectangle per glyph
+height.
+
+`StringIndex`, `GraphemeIndex`, and `GraphemeCount` describe the source text
+range owned by the line. `GraphemeCount` is not a glyph count.
+
+## Grapheme Metrics
+
+Use `GraphemeMetrics` for text interaction: hit testing, caret positioning,
+range selection, and UI overlays.
+
+```csharp
+foreach (GraphemeMetrics grapheme in metrics.GraphemeMetrics)
+{
+ // Use Advance for interaction and Bounds for rendered ink.
+ FontRectangle advance = grapheme.Advance;
+ FontRectangle bounds = grapheme.Bounds;
+ FontRectangle renderableBounds = grapheme.RenderableBounds;
+ bool isLineBreak = grapheme.IsLineBreak;
+}
+```
+
+The rectangles answer different questions:
+
+- `Advance`: the positioned logical advance rectangle for the grapheme.
+- `Bounds`: the rendered glyph bounds for the grapheme.
+- `RenderableBounds`: the union of advance and rendered glyph bounds.
+
+Use `Advance` for hit targets, carets, and selection geometry. Ink bounds can be
+empty, overhang the advance, or exclude whitespace, so they are not a reliable
+interaction target.
+
+`IsLineBreak` identifies hard-break graphemes that remain in the laid-out
+metrics. Hard breaks at the end of non-empty lines are trimmed with other
+trailing breaking whitespace; hard breaks that own blank lines remain because
+they provide the line geometry for selection and caret behavior.
+
+## Word Metrics
+
+`WordMetrics` describes one Unicode word-boundary segment from UAX #29.
+
+```csharp
+foreach (WordMetrics word in metrics.WordMetrics)
+{
+ FontRectangle advance = word.Advance;
+ FontRectangle bounds = word.Bounds;
+ FontRectangle renderableBounds = word.RenderableBounds;
+ int graphemeStart = word.GraphemeStart;
+ int graphemeEnd = word.GraphemeEnd;
+ int stringStart = word.StringStart;
+ int stringEnd = word.StringEnd;
+}
+```
+
+`Advance`, `Bounds`, and `RenderableBounds` have the same meanings as the
+equivalent `GraphemeMetrics` rectangles, but accumulated across the
+word-boundary segment. Whitespace segments keep their positioned bounds; they
+are not discarded just because they are separators.
+
+All `Start` values on `WordMetrics` are inclusive. All `End` values are exclusive.
+`GraphemeStart` and `GraphemeEnd` are grapheme insertion indices. `StringStart`
+and `StringEnd` are UTF-16 indices into the original text.
+
+Unicode word-boundary segments include separators. For example, `can't stop`
+contains three segments:
+
+```text
+can't
+[space]
+stop
+```
+
+This keeps the raw API aligned with the Unicode standard. Higher-level editor
+commands can choose whether to stop on separator boundaries or skip over them.
+
+## Glyph Metrics
+
+Glyph detail APIs expose laid-out glyph entries.
+
+```csharp
+ReadOnlyMemory glyphs = metrics.GetGlyphMetrics();
+
+foreach (GlyphMetrics glyph in glyphs.Span)
+{
+ FontRectangle advance = glyph.Advance;
+ FontRectangle bounds = glyph.Bounds;
+ FontRectangle renderableBounds = glyph.RenderableBounds;
+ CodePoint codePoint = glyph.CodePoint;
+}
+```
+
+Use glyph detail for rendering diagnostics, glyph-level visualization, or
+advanced inspection. Do not use glyph entries as character or caret positions:
+ligatures, decomposition, fallback, emoji, and combining marks mean one
+grapheme can map to multiple glyph entries, and multiple source characters can
+map to one visual glyph sequence.
+
+## Per-Line Layout
+
+`TextBlock.GetLineLayouts` returns line objects when callers want line-local
+inspection or interaction.
+
+```csharp
+TextBlock block = new(text, options);
+ReadOnlyMemory layout = block.GetLineLayouts(320);
+
+foreach (LineLayout line in layout.Span)
+{
+ // LineLayout exposes the slice of grapheme metrics owned by this line.
+ LineMetrics lineMetrics = line.LineMetrics;
+ ReadOnlySpan lineGraphemes = line.GraphemeMetrics;
+}
+```
+
+`LineLayout` mirrors the interaction and glyph-detail surface for a single line:
+
+```csharp
+TextHit hit = line.HitTest(point);
+
+// Passing the hit keeps trailing-edge and bidi handling inside the library.
+CaretPosition caret = line.GetCaretPosition(hit);
+CaretPosition next = line.MoveCaret(caret, CaretMovement.Next);
+WordMetrics word = line.GetWordMetrics(hit);
+ReadOnlyMemory selection = line.GetSelectionBounds(caret, next);
+ReadOnlyMemory wordSelection = line.GetSelectionBounds(word);
+ReadOnlyMemory glyphs = line.GetGlyphMetrics();
+```
+
+Use the full `TextMetrics` interaction methods for selections that can cross
+line boundaries. Use `LineLayout` when the caller already knows interaction is
+line-local.
+
+## Hit Testing
+
+Hit testing maps a point to the nearest grapheme and side.
+
+```csharp
+TextHit hit = metrics.HitTest(mousePosition);
+
+int lineIndex = hit.LineIndex;
+int graphemeIndex = hit.GraphemeIndex;
+// Use this value for carets and selection endpoints.
+int insertionIndex = hit.GraphemeInsertionIndex;
+```
+
+`GraphemeIndex` identifies the hit grapheme. `GraphemeInsertionIndex` identifies
+the logical caret position represented by the hit. For left-to-right text, the
+trailing side is usually `GraphemeIndex + 1`. For right-to-left text, the
+physical side is reversed, but callers do not need to apply that rule. Use
+`GraphemeInsertionIndex` or pass the `TextHit` directly to caret and selection
+APIs.
+
+For word selection, pass the hit directly to `GetWordMetrics`. This uses the
+grapheme that was hit, so clicking the trailing side of the final grapheme in a
+word still selects that word rather than the following separator segment.
+
+```csharp
+TextHit hit = metrics.HitTest(mousePosition);
+WordMetrics word = metrics.GetWordMetrics(hit);
+ReadOnlyMemory selection = metrics.GetSelectionBounds(word);
+```
+
+## Caret Positioning
+
+Caret APIs return positioned caret lines in pixel units. A caret is also the
+navigation token for keyboard/editor interaction.
+
+```csharp
+TextHit hit = metrics.HitTest(mousePosition);
+
+// The hit overload applies the correct grapheme insertion index.
+CaretPosition caret = metrics.GetCaretPosition(hit);
+
+DrawCaret(caret.Start, caret.End);
+
+if (caret.HasSecondary)
+{
+ DrawSecondaryCaret(caret.SecondaryStart, caret.SecondaryEnd);
+}
+```
+
+Use absolute placement when initializing a keyboard caret without a pointer hit.
+
+```csharp
+CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
+```
+
+At bidi boundaries, one logical insertion position can have two visual edges.
+`CaretPosition` exposes the secondary edge so editor-style callers can choose how
+to present or navigate that boundary without recomputing bidi affinity.
+
+## Caret Movement
+
+`MoveCaret` applies editor-style movement to a caret and returns the new caret.
+
+```csharp
+CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
+
+// Previous and Next move through logical grapheme insertion positions.
+caret = metrics.MoveCaret(caret, CaretMovement.Next);
+
+// PreviousWord and NextWord move through Unicode word boundaries.
+caret = metrics.MoveCaret(caret, CaretMovement.NextWord);
+
+// LineStart and LineEnd are the Home/End-style line movement operations.
+caret = metrics.MoveCaret(caret, CaretMovement.LineEnd);
+
+// TextStart and TextEnd are the whole-block equivalents.
+caret = metrics.MoveCaret(caret, CaretMovement.TextStart);
+```
+
+`LineUp` and `LineDown` move to adjacent visual lines while preserving the
+caret's requested position on the line.
+
+```csharp
+CaretPosition firstLineEnd = metrics.GetCaret(CaretPlacement.Start);
+firstLineEnd = metrics.MoveCaret(firstLineEnd, CaretMovement.LineEnd);
+
+// Repeated LineDown keeps the original line position even when an intermediate
+// line is shorter and the visible caret has to clamp to that line's end.
+CaretPosition middleLine = metrics.MoveCaret(firstLineEnd, CaretMovement.LineDown);
+CaretPosition finalLine = metrics.MoveCaret(middleLine, CaretMovement.LineDown);
+```
+
+This preserves normal rich-text editor behavior: moving down through a short line
+does not permanently lose the user's original horizontal or vertical line
+position.
+
+## Selection Bounds
+
+Selection APIs return rectangles in visual order and pixel units. The result is
+`ReadOnlyMemory` so callers can store it with selection state and
+use `.Span` when drawing.
+
+For pointer selection, use the hit overload. This keeps bidi and trailing-edge
+logic inside the library.
+
+```csharp
+TextHit anchor = metrics.HitTest(mouseDown);
+TextHit focus = metrics.HitTest(mouseMove);
+
+// The hit overload converts both endpoints to logical insertion indices.
+ReadOnlyMemory selection = metrics.GetSelectionBounds(anchor, focus);
+
+foreach (FontRectangle rectangle in selection.Span)
+{
+ FillSelectionRectangle(rectangle);
+}
+```
+
+For keyboard selection, keep an anchor caret and move the focus caret.
+
+```csharp
+CaretPosition anchor = metrics.GetCaret(CaretPlacement.Start);
+CaretPosition focus = anchor;
+
+// Shift+Right-style behavior updates only the focus caret.
+focus = metrics.MoveCaret(focus, CaretMovement.Next);
+
+ReadOnlyMemory selection = metrics.GetSelectionBounds(anchor, focus);
+```
+
+For word selection, use the word metrics overload.
+
+```csharp
+TextHit hit = metrics.HitTest(doubleClickPosition);
+WordMetrics word = metrics.GetWordMetrics(hit);
+
+ReadOnlyMemory selection = metrics.GetSelectionBounds(word);
+```
+
+Do not sort, union, or merge the returned rectangles unless the UI explicitly
+wants a different visual. A single logical selection can be visually
+discontinuous inside one line when it crosses bidi runs. Returning multiple
+rectangles allows browser-style selection where the unselected visual gap stays
+unpainted.
+
+## Bidi Drag Selection
+
+Consider a line whose source text is:
+
+```text
+Tall שלום عرب
+```
+
+In a left-to-right paragraph, the right-to-left run can paint with Arabic before
+Hebrew. When a user drags from the left edge of `Tall` toward the Hebrew word,
+the selection can become visually split:
+
+```text
+[Tall ] عرب [שלום]
+```
+
+Application code should not manually decide which physical edge of the Hebrew
+glyph means "before" or "after". The correct flow is:
+
+```csharp
+TextHit anchor = metrics.HitTest(mouseDown);
+TextHit focus = metrics.HitTest(mouseMove);
+
+// Bidi split selection is represented by the returned rectangle list.
+ReadOnlyMemory rectangles = metrics.GetSelectionBounds(anchor, focus);
+```
+
+The hit-test result carries the logical insertion index. The selection result is
+already split into the visual rectangles that should be painted.
+
+## Hard Line Breaks
+
+Hard line breaks that end non-empty lines are trimmed with trailing breaking
+whitespace. Hard line breaks that own blank lines remain as graphemes for source
+ranges, hit testing, caret movement, and selection painting.
+
+For text with two hard breaks in the middle:
+
+```text
+Tall عرب שלום
+
+Small مرحبا שלום
+```
+
+Full selection should paint three visual rows: the first text line, the blank
+line, and the second text line. The line break that ends a non-empty line should
+not add a separate painted box; the line break that owns the blank line should.
+
+Consumers should not special-case this. Draw the rectangles returned by
+`GetSelectionBounds`. Consumers that inspect individual graphemes can use
+`IsLineBreak` to identify the blank-line hard breaks that remain in the metrics.
+
+## Recommended Workflows
+
+For one-off measuring:
+
+```csharp
+// One-shot path for a single layout result.
+TextMetrics metrics = TextMeasurer.Measure(text, options);
+```
+
+For repeated wrapping or rendering:
+
+```csharp
+TextBlock block = new(text, options);
+
+// Reuse the prepared text for each requested wrapping length.
+TextMetrics narrow = block.Measure(240);
+TextMetrics wide = block.Measure(480);
+block.RenderTo(renderer, 480);
+```
+
+For text editor interaction:
+
+```csharp
+TextMetrics metrics = block.Measure(wrappingLength);
+
+TextHit anchor = metrics.HitTest(mouseDown);
+TextHit focus = metrics.HitTest(mouseMove);
+
+// Use hit-based overloads so interaction follows the laid-out bidi result.
+CaretPosition caret = metrics.GetCaretPosition(focus);
+ReadOnlyMemory selection = metrics.GetSelectionBounds(anchor, focus);
+```
+
+For keyboard navigation and selection:
+
+```csharp
+TextMetrics metrics = block.Measure(wrappingLength);
+CaretPosition caret = metrics.GetCaret(CaretPlacement.Start);
+CaretPosition anchor = caret;
+
+// The movement operation owns grapheme, line, and hard-break navigation rules.
+caret = metrics.MoveCaret(caret, CaretMovement.LineDown);
+caret = metrics.MoveCaret(caret, CaretMovement.NextWord);
+ReadOnlyMemory selection = metrics.GetSelectionBounds(anchor, caret);
+```
+
+For per-line UI:
+
+```csharp
+ReadOnlyMemory lines = block.GetLineLayouts(wrappingLength);
+
+foreach (LineLayout line in lines.Span)
+{
+ ReadOnlySpan graphemes = line.GraphemeMetrics;
+ ReadOnlyMemory glyphs = line.GetGlyphMetrics();
+}
+```
+
+## Design Principles
+
+- The library owns bidi, grapheme, hard-break, wrapping, and layout-mode rules.
+- Callers should pass points, hits, or logical ranges and draw the returned geometry.
+- Caret movement should flow through `MoveCaret`, not caller-side grapheme arithmetic.
+- Word selection should flow through `GetWordMetrics`, not caller-side Unicode boundary logic.
+- Grapheme metrics are the text interaction unit.
+- Word metrics describe logical source segments and their positioned geometry;
+ selection bounds are the visual geometry.
+- Glyph metrics are rendering-detail data, not caret or character data.
+- Selection rectangles are visual geometry, not a single logical union.
+- Per-line selection uses line-box height so selection remains visually stable
+ across mixed fonts and font sizes.
diff --git a/src/SixLabors.Fonts/Rendering/GlyphRendererParameters.cs b/src/SixLabors.Fonts/Rendering/GlyphRendererParameters.cs
index e55e2f3a..ec3376b5 100644
--- a/src/SixLabors.Fonts/Rendering/GlyphRendererParameters.cs
+++ b/src/SixLabors.Fonts/Rendering/GlyphRendererParameters.cs
@@ -15,7 +15,7 @@ namespace SixLabors.Fonts.Rendering;
public readonly struct GlyphRendererParameters : IEquatable
{
internal GlyphRendererParameters(
- GlyphMetrics metrics,
+ FontGlyphMetrics metrics,
TextRun textRun,
float pointSize,
float dpi,
@@ -60,7 +60,7 @@ internal GlyphRendererParameters(
public ushort CompositeGlyphId { get; }
///
- /// Gets the index of the grapheme this glyph belongs to.
+ /// Gets the zero-based grapheme index in the original text.
///
public int GraphemeIndex { get; }
diff --git a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
index 8bc7d343..15acfaec 100644
--- a/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Rendering/PaintedGlyphMetrics.cs
@@ -11,7 +11,7 @@ namespace SixLabors.Fonts.Rendering;
/// Geometry and paints are supplied in document-space by an interpreter; all layout transforms
/// (UPEM mapping, DPI/point-size scaling, rotation, final placement) are applied here.
///
-public sealed class PaintedGlyphMetrics : GlyphMetrics
+public sealed class PaintedGlyphMetrics : FontGlyphMetrics
{
private readonly IPaintedGlyphSource source;
@@ -92,7 +92,7 @@ internal PaintedGlyphMetrics(
=> this.source = source;
///
- internal override GlyphMetrics CloneForRendering(TextRun textRun)
+ internal override FontGlyphMetrics CloneForRendering(TextRun textRun)
=> new PaintedGlyphMetrics(
this.FontMetrics,
this.GlyphId,
@@ -112,8 +112,8 @@ internal override GlyphMetrics CloneForRendering(TextRun textRun)
internal override void RenderTo(
IGlyphRenderer renderer,
int graphemeIndex,
- Vector2 location,
- Vector2 offset,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
GlyphLayoutMode mode,
TextOptions options)
{
@@ -126,9 +126,8 @@ internal override void RenderTo(
float dpi = options.Dpi;
// Device-space placement.
- location *= dpi;
- offset *= dpi;
- Vector2 renderLocation = location + offset;
+ glyphOrigin *= dpi;
+ decorationOrigin *= dpi;
float scaledPpem = this.GetScaledSize(pointSize, dpi);
Vector2 scale = new Vector2(scaledPpem) / this.ScaleFactor; // uniform
@@ -138,10 +137,10 @@ internal override void RenderTo(
// Layout similarity: uniform scale then rotation; translation added below.
Matrix3x2 layout = Matrix3x2.CreateScale(scale);
layout *= rotation;
- layout.Translation = (this.Offset * scale) + renderLocation;
+ layout.Translation = (this.Offset * scale) + glyphOrigin;
// Bounds in device space for BeginGlyph.
- FontRectangle box = this.GetBoundingBox(mode, renderLocation, scaledPpem);
+ FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPpem);
GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex);
if (renderer.BeginGlyph(in box, in parameters))
@@ -160,7 +159,7 @@ internal override void RenderTo(
}
renderer.EndGlyph();
- this.RenderDecorationsTo(renderer, location, mode, rotation, scaledPpem, options);
+ this.RenderDecorationsTo(renderer, decorationOrigin, mode, rotation, scaledPpem, options);
}
}
diff --git a/src/SixLabors.Fonts/Rendering/TextRenderer.cs b/src/SixLabors.Fonts/Rendering/TextRenderer.cs
index 2b78466c..01ff5a85 100644
--- a/src/SixLabors.Fonts/Rendering/TextRenderer.cs
+++ b/src/SixLabors.Fonts/Rendering/TextRenderer.cs
@@ -4,7 +4,7 @@
namespace SixLabors.Fonts.Rendering;
///
-/// Encapsulated logic for laying out and then rendering text to a surface.
+/// Encapsulates logic for laying out and then rendering text to a surface.
///
public class TextRenderer
{
@@ -21,7 +21,7 @@ public class TextRenderer
///
/// The target renderer.
/// The text to render.
- /// The text options.
+ /// The text options. controls wrapping; use -1 to disable wrapping.
public static void RenderTextTo(IGlyphRenderer renderer, ReadOnlySpan text, TextOptions options)
=> new TextRenderer(renderer).RenderText(text, options);
@@ -30,35 +30,26 @@ public static void RenderTextTo(IGlyphRenderer renderer, ReadOnlySpan text
///
/// The target renderer.
/// The text to render.
- /// The text option.
+ /// The text options. controls wrapping; use -1 to disable wrapping.
public static void RenderTextTo(IGlyphRenderer renderer, string text, TextOptions options)
=> new TextRenderer(renderer).RenderText(text, options);
///
- /// Renders the text to the default renderer.
+ /// Renders the text to the configured renderer.
///
/// The text to render.
- /// The text options.
+ /// The text options. controls wrapping; use -1 to disable wrapping.
public void RenderText(string text, TextOptions options)
=> this.RenderText(text.AsSpan(), options);
///
- /// Renders the text to the default renderer.
+ /// Renders the text to the configured renderer.
///
/// The text to render.
- /// The style.
+ /// The text options. controls wrapping; use -1 to disable wrapping.
public void RenderText(ReadOnlySpan text, TextOptions options)
{
- IReadOnlyList glyphsToRender = TextLayout.GenerateLayout(text, options);
- FontRectangle rect = TextMeasurer.GetBounds(glyphsToRender, options.Dpi);
-
- this.renderer.BeginText(in rect);
-
- foreach (GlyphLayout g in glyphsToRender)
- {
- g.Glyph.RenderTo(this.renderer, g.GraphemeIndex, g.PenLocation, g.Offset, g.LayoutMode, options);
- }
-
- this.renderer.EndText();
+ TextBlock block = new(text, options);
+ block.RenderTo(this.renderer, options.WrappingLength);
}
}
diff --git a/src/SixLabors.Fonts/ShapedText.cs b/src/SixLabors.Fonts/ShapedText.cs
new file mode 100644
index 00000000..b91307d0
--- /dev/null
+++ b/src/SixLabors.Fonts/ShapedText.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Contains the width-independent result of shaping text before logical line composition.
+///
+internal readonly struct ShapedText
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The positioned glyph shaping collection.
+ /// The resolved bidi runs covering the shaped text.
+ /// The code point to bidi-run mapping built during shaping.
+ /// The layout mode used while shaping.
+ public ShapedText(
+ GlyphPositioningCollection positionings,
+ BidiRun[] bidiRuns,
+ Dictionary bidiMap,
+ LayoutMode layoutMode)
+ {
+ this.Positionings = positionings;
+ this.BidiRuns = bidiRuns;
+ this.BidiMap = bidiMap;
+ this.LayoutMode = layoutMode;
+ }
+
+ ///
+ /// Gets the positioned glyph shaping collection.
+ ///
+ public GlyphPositioningCollection Positionings { get; }
+
+ ///
+ /// Gets the resolved bidi runs covering the shaped text.
+ ///
+ public BidiRun[] BidiRuns { get; }
+
+ ///
+ /// Gets the code point to bidi-run mapping built during shaping.
+ ///
+ public Dictionary BidiMap { get; }
+
+ ///
+ /// Gets the layout mode used while shaping.
+ ///
+ public LayoutMode LayoutMode { get; }
+}
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
index 4a0a6d13..7c0f0bf5 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.Cff.cs
@@ -97,7 +97,7 @@ private static StreamFontMetrics LoadCompactFont(FontReader reader)
return new StreamFontMetrics(tables, glyphVariationProcessor);
}
- private GlyphMetrics CreateCffGlyphMetrics(
+ private FontGlyphMetrics CreateCffGlyphMetrics(
in CodePoint codePoint,
ushort glyphId,
GlyphType glyphType,
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
index 26b57fae..b29ad190 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.TrueType.cs
@@ -48,7 +48,7 @@ private TrueTypeInterpreter CreateInterpreter()
return interpreter;
}
- internal void ApplyTrueTypeHinting(HintingMode hintingMode, GlyphMetrics metrics, ref GlyphVector glyphVector, Vector2 scaleXY, float pixelSize)
+ internal void ApplyTrueTypeHinting(HintingMode hintingMode, FontGlyphMetrics metrics, ref GlyphVector glyphVector, Vector2 scaleXY, float pixelSize)
{
if (hintingMode == HintingMode.None || this.outlineType != OutlineType.TrueType)
{
@@ -181,7 +181,7 @@ private static StreamFontMetrics LoadTrueTypeFont(FontReader reader)
return new StreamFontMetrics(tables, glyphVariationProcessor);
}
- private GlyphMetrics CreateTrueTypeGlyphMetrics(
+ private FontGlyphMetrics CreateTrueTypeGlyphMetrics(
in CodePoint codePoint,
ushort glyphId,
GlyphType glyphType,
diff --git a/src/SixLabors.Fonts/StreamFontMetrics.cs b/src/SixLabors.Fonts/StreamFontMetrics.cs
index 1642e178..29dc9d05 100644
--- a/src/SixLabors.Fonts/StreamFontMetrics.cs
+++ b/src/SixLabors.Fonts/StreamFontMetrics.cs
@@ -32,7 +32,7 @@ internal partial class StreamFontMetrics : FontMetrics
private readonly OutlineType outlineType;
// https://docs.microsoft.com/en-us/typography/opentype/spec/otff#font-tables
- private readonly ConcurrentDictionary<(int CodePoint, ushort Id, TextAttributes Attributes, ColorFontSupport ColorSupport, bool IsVerticalLayout), GlyphMetrics> glyphCache;
+ private readonly ConcurrentDictionary<(int CodePoint, ushort Id, TextAttributes Attributes, ColorFontSupport ColorSupport, bool IsVerticalLayout), FontGlyphMetrics> glyphCache;
private readonly ConcurrentDictionary<(int CodePoint, int NextCodePoint), (bool Success, ushort GlyphId, bool SkipNextCodePoint)> glyphIdCache;
private readonly ConcurrentDictionary codePointCache;
private SvgGlyphSource? svgGlyphSource;
@@ -277,23 +277,23 @@ internal override bool TryGetMarkAttachmentClass(ushort glyphId, [NotNullWhen(tr
}
///
- public override bool TryGetVariationAxes(out VariationAxis[]? variationAxes)
+ public override bool TryGetVariationAxes(out ReadOnlyMemory variationAxes)
{
FVarTable? fvar = this.trueTypeFontTables?.Fvar ?? this.compactFontTables?.FVar;
Tables.General.Name.NameTable? names = this.trueTypeFontTables?.Name ?? this.compactFontTables?.Name;
if (fvar == null)
{
- variationAxes = [];
+ variationAxes = ReadOnlyMemory.Empty;
return false;
}
- variationAxes = new VariationAxis[fvar.Axes.Length];
+ VariationAxis[] axes = new VariationAxis[fvar.Axes.Length];
for (int i = 0; i < fvar.Axes.Length; i++)
{
VariationAxisRecord axis = fvar.Axes[i];
string name = names != null ? names.GetNameById(CultureInfo.InvariantCulture, axis.AxisNameId) : string.Empty;
- variationAxes[i] = new VariationAxis()
+ axes[i] = new VariationAxis()
{
Tag = axis.Tag,
Min = axis.MinValue,
@@ -303,6 +303,7 @@ public override bool TryGetVariationAxes(out VariationAxis[]? variationAxes)
};
}
+ variationAxes = axes;
return true;
}
@@ -323,7 +324,7 @@ public override bool TryGetGlyphMetrics(
TextDecorations textDecorations,
LayoutMode layoutMode,
ColorFontSupport support,
- [NotNullWhen(true)] out GlyphMetrics? metrics)
+ [NotNullWhen(true)] out FontGlyphMetrics? metrics)
{
// We return metrics for the special glyph representing a missing character, commonly known as .notdef.
this.TryGetGlyphId(codePoint, out ushort glyphId);
@@ -332,7 +333,7 @@ public override bool TryGetGlyphMetrics(
}
///
- internal override GlyphMetrics GetGlyphMetrics(
+ internal override FontGlyphMetrics GetGlyphMetrics(
CodePoint codePoint,
ushort glyphId,
TextAttributes textAttributes,
@@ -356,7 +357,7 @@ internal override GlyphMetrics GetGlyphMetrics(
(textDecorations, codePoint, this));
///
- public override IReadOnlyList GetAvailableCodePoints()
+ public override ReadOnlyMemory GetAvailableCodePoints()
{
CMapTable cmap = this.outlineType == OutlineType.TrueType
? this.trueTypeFontTables!.Cmap
@@ -780,8 +781,8 @@ private void ApplyMVarDeltas(HorizontalMetrics horizontalMetrics, VerticalMetric
/// Reads a from the specified stream.
///
/// The file path.
- /// a .
- public static StreamFontMetrics[] LoadFontCollection(string path)
+ /// A read-only memory region containing the font metrics.
+ public static ReadOnlyMemory LoadFontCollection(string path)
{
using FileStream fs = File.OpenRead(path);
return LoadFontCollection(fs);
@@ -791,8 +792,8 @@ public static StreamFontMetrics[] LoadFontCollection(string path)
/// Reads a from the specified stream.
///
/// The stream.
- /// a .
- public static StreamFontMetrics[] LoadFontCollection(Stream stream)
+ /// A read-only memory region containing the font metrics.
+ public static ReadOnlyMemory LoadFontCollection(Stream stream)
{
long startPos = stream.Position;
BigEndianBinaryReader reader = new(stream, true);
@@ -816,7 +817,7 @@ private static (int CodePoint, ushort Id, TextAttributes Attributes, ColorFontSu
LayoutMode layoutMode)
=> (codePoint.Value, glyphId, textAttributes, colorSupport, AdvancedTypographicUtils.IsVerticalGlyph(codePoint, layoutMode));
- private GlyphMetrics CreateGlyphMetrics(
+ private FontGlyphMetrics CreateGlyphMetrics(
in CodePoint codePoint,
ushort glyphId,
GlyphType glyphType,
diff --git a/src/SixLabors.Fonts/SystemFontCollection.cs b/src/SixLabors.Fonts/SystemFontCollection.cs
index ac54d28e..65bb26fd 100644
--- a/src/SixLabors.Fonts/SystemFontCollection.cs
+++ b/src/SixLabors.Fonts/SystemFontCollection.cs
@@ -135,7 +135,7 @@ IEnumerable IReadOnlyFontMetricsCollection.GetAllMetrics(string nam
=> ((IReadOnlyFontMetricsCollection)this.collection).GetAllMetrics(name, culture);
///
- IEnumerable IReadOnlyFontMetricsCollection.GetAllStyles(string name, CultureInfo culture)
+ ReadOnlyMemory IReadOnlyFontMetricsCollection.GetAllStyles(string name, CultureInfo culture)
=> ((IReadOnlyFontMetricsCollection)this.collection).GetAllStyles(name, culture);
///
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
index 1ea3f256..d10b49f4 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/AnchorTable.cs
@@ -160,7 +160,7 @@ public override AnchorXY GetAnchor(FontMetrics fontMetrics, GlyphShapingData dat
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 GlyphMetrics? metrics))
+ if (fontMetrics.TryGetGlyphMetrics(data.CodePoint, textAttributes, textDecorations, layoutMode, colorFontSupport, out FontGlyphMetrics? metrics))
{
if (metrics is TrueTypeGlyphMetrics ttmetric)
{
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs
index f6fc7a2a..38f02f16 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPos/LookupType3SubTable.cs
@@ -185,30 +185,19 @@ public override bool TryUpdatePosition(
}
else
{
- // Vertical : Top to bottom
- if (current.Direction == TextDirection.LeftToRight)
- {
- current.Bounds.Height = exitXY.YCoordinate + current.Bounds.Y;
+ // Vertical layout modes advance top-to-bottom; column progression is handled by layout.
+ current.Bounds.Height = exitXY.YCoordinate + current.Bounds.Y;
- int delta = entryXY.YCoordinate + next.Bounds.Y;
- next.Bounds.Height -= delta;
- next.Bounds.Y -= delta;
- }
- else
- {
- int delta = exitXY.YCoordinate + current.Bounds.Y;
- current.Bounds.Height -= delta;
- current.Bounds.Y -= delta;
-
- next.Bounds.Height = entryXY.YCoordinate + next.Bounds.Y;
- }
+ int delta = entryXY.YCoordinate + next.Bounds.Y;
+ next.Bounds.Height -= delta;
+ next.Bounds.Y -= delta;
}
int child = index;
int parent = nextIndex;
int xOffset = entryXY.XCoordinate - exitXY.XCoordinate;
int yOffset = entryXY.YCoordinate - exitXY.YCoordinate;
- if ((this.LookupFlags & LookupFlags.RightToLeft) == LookupFlags.RightToLeft)
+ if ((this.LookupFlags & LookupFlags.RightToLeft) != LookupFlags.RightToLeft)
{
(parent, child) = (child, parent);
@@ -235,10 +224,22 @@ public override bool TryUpdatePosition(
}
// If parent was attached to child, separate them.
+ // https://github.com/harfbuzz/harfbuzz/issues/2469
GlyphShapingData p = collection[parent];
if (p.CursiveAttachment == -c.CursiveAttachment)
{
p.CursiveAttachment = 0;
+
+ // Bounds.X/Y carry shaping placement offsets here, matching
+ // HarfBuzz x_offset/y_offset. Clear only the detached parent's minor axis.
+ if (horizontal)
+ {
+ p.Bounds.Y = 0;
+ }
+ else
+ {
+ p.Bounds.X = 0;
+ }
}
return true;
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
index 7aefb848..d2241c8d 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/GPosTable.cs
@@ -460,7 +460,7 @@ private static void FixCursiveAttachment(GlyphPositioningCollection collection,
if (data.CursiveAttachment != -1)
{
int j = data.CursiveAttachment + currentIndex;
- if (j > count)
+ if (j < index || j >= index + count)
{
return;
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs
index 999661e3..b447ba2b 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/ArabicShaper.cs
@@ -112,6 +112,17 @@ protected override void PlanFeatures(IGlyphShapingCollection collection, int ind
this.AddFeature(collection, index, count, MediTag, false);
this.AddFeature(collection, index, count, Med2Tag, false);
this.AddFeature(collection, index, count, InitTag, false);
+
+ // HarfBuzz plans these as Arabic-script features, independently of the
+ // generic horizontal feature list. Horizontal runs already get them from
+ // DefaultShaper; forced vertical Arabic needs them here as well.
+ if (collection.TextOptions.LayoutMode.IsVertical())
+ {
+ this.AddFeature(collection, index, count, CaltTag);
+ this.AddFeature(collection, index, count, LigaTag);
+ this.AddFeature(collection, index, count, CligTag);
+ }
+
this.AddFeature(collection, index, count, MsetTag);
}
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs
index 82b0a4df..6194f4b2 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/DefaultShaper.cs
@@ -81,7 +81,7 @@ internal class DefaultShaper : BaseShaper
private static readonly CodePoint Slash = new(0x002F);
/// The set of shaping stages accumulated during feature planning.
- private readonly HashSet shapingStages = new();
+ private readonly HashSet shapingStages = [];
/// The kerning mode from the text options.
private readonly KerningMode kerningMode;
@@ -125,7 +125,7 @@ protected override void PlanPreprocessingFeatures(IGlyphShapingCollection collec
this.AddFeature(collection, index, count, RvnrTag);
// Add directional features.
- for (int i = index; i < count; i++)
+ for (int i = index; i < index + count; i++)
{
GlyphShapingData shapingData = collection[i];
@@ -157,7 +157,7 @@ protected override void PlanPostprocessingFeatures(IGlyphShapingCollection colle
LayoutMode layoutMode = collection.TextOptions.LayoutMode;
bool isVerticalLayout = false;
- for (int i = index; i < count; i++)
+ for (int i = index; i < index + count; i++)
{
GlyphShapingData shapingData = collection[i];
isVerticalLayout |= AdvancedTypographicUtils.IsVerticalGlyph(shapingData.CodePoint, layoutMode);
diff --git a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs
index 41443077..c6453db0 100644
--- a/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs
+++ b/src/SixLabors.Fonts/Tables/AdvancedTypographic/Shapers/HangulShaper.cs
@@ -447,7 +447,7 @@ private void ReOrderToneMark(GlyphSubstitutionCollection collection, GlyphShapin
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 GlyphMetrics? metrics)
+ if (fontMetrics.TryGetGlyphMetrics(data.CodePoint, textAttributes, textDecorations, layoutMode, colorFontSupport, out FontGlyphMetrics? metrics)
&& metrics.AdvanceWidth == 0)
{
return;
@@ -477,7 +477,7 @@ private int InsertDottedCircle(GlyphSubstitutionCollection collection, GlyphShap
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 GlyphMetrics? metrics)
+ if (fontMetrics.TryGetGlyphMetrics(data.CodePoint, textAttributes, textDecorations, layoutMode, colorFontSupport, out FontGlyphMetrics? metrics)
&& metrics.AdvanceWidth != 0)
{
after = true;
diff --git a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
index f4ca7ff3..2b7e6898 100644
--- a/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Tables/Cff/CffGlyphMetrics.cs
@@ -10,7 +10,7 @@ namespace SixLabors.Fonts.Tables.Cff;
///
/// Represents a glyph metric from a particular Compact Font Face.
///
-internal class CffGlyphMetrics : GlyphMetrics
+internal class CffGlyphMetrics : FontGlyphMetrics
{
private CffGlyphData glyphData;
@@ -108,7 +108,7 @@ internal CffGlyphMetrics(
=> this.glyphData = glyphData;
///
- internal override GlyphMetrics CloneForRendering(TextRun textRun)
+ internal override FontGlyphMetrics CloneForRendering(TextRun textRun)
=> new CffGlyphMetrics(
this.FontMetrics,
this.GlyphId,
@@ -129,8 +129,8 @@ internal override GlyphMetrics CloneForRendering(TextRun textRun)
internal override void RenderTo(
IGlyphRenderer renderer,
int graphemeIndex,
- Vector2 location,
- Vector2 offset,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
GlyphLayoutMode mode,
TextOptions options)
{
@@ -143,16 +143,12 @@ internal override void RenderTo(
float pointSize = this.TextRun.Font?.Size ?? options.Font.Size;
float dpi = options.Dpi;
- // The glyph vector is rendered offset to the location.
- // For horizontal text, the offset is always zero but vertical or rotated text
- // will be offset against the location.
- location *= dpi;
- offset *= dpi;
- Vector2 renderLocation = location + offset;
+ glyphOrigin *= dpi;
+ decorationOrigin *= dpi;
float scaledPPEM = this.GetScaledSize(pointSize, dpi);
Matrix3x2 rotation = GetRotationMatrix(mode);
- FontRectangle box = this.GetBoundingBox(mode, renderLocation, scaledPPEM);
+ FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPPEM);
GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex);
if (renderer.BeginGlyph(in box, in parameters))
@@ -171,11 +167,11 @@ internal override void RenderTo(
}
Vector2 scaledOffset = this.Offset * scale;
- this.glyphData.RenderTo(renderer, renderLocation, scale, scaledOffset, rotation);
+ this.glyphData.RenderTo(renderer, glyphOrigin, scale, scaledOffset, rotation);
}
renderer.EndGlyph();
- this.RenderDecorationsTo(renderer, location, mode, rotation, scaledPPEM, options);
+ this.RenderDecorationsTo(renderer, decorationOrigin, mode, rotation, scaledPPEM, options);
}
}
}
diff --git a/src/SixLabors.Fonts/Tables/General/CMapTable.cs b/src/SixLabors.Fonts/Tables/General/CMapTable.cs
index 9dc3ce6f..a1713f0c 100644
--- a/src/SixLabors.Fonts/Tables/General/CMapTable.cs
+++ b/src/SixLabors.Fonts/Tables/General/CMapTable.cs
@@ -142,8 +142,8 @@ public bool TryGetCodePoint(ushort glyphId, out CodePoint codePoint)
///
/// Gets the unicode codepoints for which a glyph exists in the font.
///
- /// The .
- public IReadOnlyList GetAvailableCodePoints()
+ /// A read-only memory region containing the available codepoints.
+ public ReadOnlyMemory GetAvailableCodePoints()
{
if (this.codepoints is not null)
{
diff --git a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
index 15412eb7..257779c3 100644
--- a/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
+++ b/src/SixLabors.Fonts/Tables/TrueType/TrueTypeGlyphMetrics.cs
@@ -12,7 +12,7 @@ namespace SixLabors.Fonts.Tables.TrueType;
///
/// Represents a glyph metric from a particular TrueType font face.
///
-public partial class TrueTypeGlyphMetrics : GlyphMetrics
+public partial class TrueTypeGlyphMetrics : FontGlyphMetrics
{
private static readonly Vector2 YInverter = new(1, -1);
private readonly GlyphVector vector;
@@ -109,7 +109,7 @@ internal TrueTypeGlyphMetrics(
=> this.vector = vector;
///
- internal override GlyphMetrics CloneForRendering(TextRun textRun)
+ internal override FontGlyphMetrics CloneForRendering(TextRun textRun)
=> new TrueTypeGlyphMetrics(
this.FontMetrics,
this.GlyphId,
@@ -135,8 +135,8 @@ internal override GlyphMetrics CloneForRendering(TextRun textRun)
internal override void RenderTo(
IGlyphRenderer renderer,
int graphemeIndex,
- Vector2 location,
- Vector2 offset,
+ Vector2 glyphOrigin,
+ Vector2 decorationOrigin,
GlyphLayoutMode mode,
TextOptions options)
{
@@ -149,16 +149,12 @@ internal override void RenderTo(
float pointSize = this.TextRun.Font?.Size ?? options.Font.Size;
float dpi = options.Dpi;
- // The glyph vector is rendered offset to the location.
- // For horizontal text, the offset is always zero but vertical or rotated text
- // will be offset against the location.
- location *= dpi;
- offset *= dpi;
- Vector2 renderLocation = location + offset;
+ glyphOrigin *= dpi;
+ decorationOrigin *= dpi;
float scaledPPEM = this.GetScaledSize(pointSize, dpi);
Matrix3x2 rotation = GetRotationMatrix(mode);
- FontRectangle box = this.GetBoundingBox(mode, renderLocation, scaledPPEM);
+ FontRectangle box = this.GetBoundingBox(mode, glyphOrigin, scaledPPEM);
GlyphRendererParameters parameters = new(this, this.TextRun, pointSize, dpi, mode, graphemeIndex);
if (renderer.BeginGlyph(in box, in parameters))
@@ -195,8 +191,8 @@ internal override void RenderTo(
endOfContour = endPoints[i];
Vector2 prev;
- Vector2 curr = (YInverter * controlPoints[endOfContour].Point) + renderLocation;
- Vector2 next = (YInverter * controlPoints[startOfContour].Point) + renderLocation;
+ Vector2 curr = (YInverter * controlPoints[endOfContour].Point) + glyphOrigin;
+ Vector2 next = (YInverter * controlPoints[startOfContour].Point) + glyphOrigin;
if (controlPoints[endOfContour].OnCurve)
{
@@ -224,7 +220,7 @@ internal override void RenderTo(
int currentIndex = startOfContour + p;
int nextIndex = startOfContour + ((p + 1) % length);
int prevIndex = startOfContour + ((length + p - 1) % length);
- next = (YInverter * controlPoints[nextIndex].Point) + renderLocation;
+ next = (YInverter * controlPoints[nextIndex].Point) + glyphOrigin;
if (controlPoints[currentIndex].OnCurve)
{
@@ -257,7 +253,7 @@ internal override void RenderTo(
}
renderer.EndGlyph();
- this.RenderDecorationsTo(renderer, location, mode, rotation, scaledPPEM, options);
+ this.RenderDecorationsTo(renderer, decorationOrigin, mode, rotation, scaledPPEM, options);
}
}
}
diff --git a/src/SixLabors.Fonts/TextBidiMode.cs b/src/SixLabors.Fonts/TextBidiMode.cs
new file mode 100644
index 00000000..243e439b
--- /dev/null
+++ b/src/SixLabors.Fonts/TextBidiMode.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies how bidirectional text is resolved.
+///
+public enum TextBidiMode
+{
+ ///
+ /// Uses the Unicode Bidirectional Algorithm with each character's bidirectional class.
+ ///
+ Normal = 0,
+
+ ///
+ /// Lays out text in the resolved text direction, ignoring each character's normal bidirectional class.
+ ///
+ Override = 1,
+}
diff --git a/src/SixLabors.Fonts/TextBlock.Visitors.cs b/src/SixLabors.Fonts/TextBlock.Visitors.cs
new file mode 100644
index 00000000..15fc3f3c
--- /dev/null
+++ b/src/SixLabors.Fonts/TextBlock.Visitors.cs
@@ -0,0 +1,808 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.Fonts.Rendering;
+using SixLabors.Fonts.Unicode;
+
+namespace SixLabors.Fonts;
+
+///
+/// Visitor types for streaming laid-out glyphs into operations.
+///
+public sealed partial class TextBlock
+{
+ ///
+ /// Adds a flushed grapheme to its source-order word-boundary segment.
+ ///
+ /// The source-order word-boundary segments.
+ /// The word metrics array being accumulated.
+ /// The grapheme metrics just emitted.
+ private static void AccumulateWordMetrics(
+ List wordSegments,
+ WordMetrics[] wordMetrics,
+ in GraphemeMetrics grapheme)
+ => AccumulateWordMetrics(
+ wordSegments,
+ wordMetrics,
+ grapheme.GraphemeIndex,
+ grapheme.Advance,
+ grapheme.Bounds,
+ grapheme.RenderableBounds);
+
+ ///
+ /// Adds one coalesced grapheme rectangle set to its source-order word-boundary segment.
+ ///
+ /// The source-order word-boundary segments.
+ /// The word metrics array being accumulated.
+ /// The source grapheme index owning the rectangles.
+ /// The positioned logical advance rectangle for the grapheme.
+ /// The rendered glyph bounds for the grapheme.
+ /// The union of logical advance and rendered glyph bounds for the grapheme.
+ private static void AccumulateWordMetrics(
+ List wordSegments,
+ WordMetrics[] wordMetrics,
+ int graphemeIndex,
+ FontRectangle advance,
+ FontRectangle bounds,
+ FontRectangle renderableBounds)
+ {
+ int wordIndex = FindWordMetricIndex(wordSegments, graphemeIndex);
+ WordSegmentRun segment = wordSegments[wordIndex];
+ WordMetrics metrics = wordMetrics[wordIndex];
+
+ // WordMetrics is the final value type, but this array slot also acts as the running
+ // accumulator for its source segment. Once a slot has source ranges, subsequent
+ // graphemes in the same word segment union into the rectangles already stored there.
+ bool hasMetrics = HasWordMetrics(metrics);
+
+ wordMetrics[wordIndex] = new WordMetrics(
+ hasMetrics ? FontRectangle.Union(metrics.Advance, advance) : advance,
+ hasMetrics ? FontRectangle.Union(metrics.Bounds, bounds) : bounds,
+ hasMetrics ? FontRectangle.Union(metrics.RenderableBounds, renderableBounds) : renderableBounds,
+ segment.GraphemeStart,
+ segment.GraphemeEnd,
+ segment.StringStart,
+ segment.StringEnd);
+ }
+
+ ///
+ /// Coalesces consecutive laid-out glyph entries that belong to the same grapheme.
+ ///
+ private struct GraphemeMetricsAccumulator
+ {
+ private readonly GraphemeMetrics[] graphemes;
+ private readonly float dpi;
+ private int count;
+ private int graphemeIndex;
+ private int stringIndex;
+ private int bidiLevel;
+ private bool isLineBreak;
+ private FontRectangle advanceBounds;
+ private FontRectangle bounds;
+ private bool hasCurrent;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target grapheme array to fill.
+ /// The target DPI.
+ public GraphemeMetricsAccumulator(GraphemeMetrics[] graphemes, float dpi)
+ {
+ this.graphemes = graphemes;
+ this.dpi = dpi;
+ this.count = 0;
+ this.graphemeIndex = 0;
+ this.stringIndex = 0;
+ this.bidiLevel = 0;
+ this.isLineBreak = false;
+ this.advanceBounds = FontRectangle.Empty;
+ this.bounds = FontRectangle.Empty;
+ this.hasCurrent = false;
+ }
+
+ ///
+ /// Gets the number of graphemes emitted so far.
+ ///
+ public readonly int Count => this.count;
+
+ ///
+ /// Adds one laid-out glyph entry to the current grapheme, flushing the previous grapheme when needed.
+ ///
+ /// The laid-out glyph entry.
+ public void Visit(in GlyphLayout glyph)
+ => this.Visit(glyph, out _);
+
+ ///
+ /// Adds one laid-out glyph entry to the current grapheme, returning emitted metrics when the previous grapheme is flushed.
+ ///
+ /// The laid-out glyph entry.
+ /// The emitted grapheme metrics when this method returns .
+ /// when a grapheme was emitted.
+ public bool Visit(
+ in GlyphLayout glyph,
+ out GraphemeMetrics metrics)
+ {
+ FontRectangle advanceBounds = glyph.MeasureAdvance(this.dpi);
+ FontRectangle bounds = glyph.MeasureBounds(this.dpi);
+
+ if (!this.hasCurrent)
+ {
+ this.Start(glyph, advanceBounds, bounds);
+ metrics = default;
+ return false;
+ }
+
+ if (glyph.GraphemeIndex != this.graphemeIndex)
+ {
+ bool emitted = this.Flush(out metrics);
+ this.Start(glyph, advanceBounds, bounds);
+ return emitted;
+ }
+
+ this.advanceBounds = FontRectangle.Union(this.advanceBounds, advanceBounds);
+ this.bounds = FontRectangle.Union(this.bounds, bounds);
+ this.isLineBreak |= CodePoint.IsNewLine(glyph.CodePoint);
+ metrics = default;
+ return false;
+ }
+
+ ///
+ /// Flushes the current line's pending grapheme.
+ ///
+ public void EndLine() => this.Flush(out _);
+
+ ///
+ /// Flushes the current line's pending grapheme.
+ ///
+ /// The emitted grapheme metrics when this method returns .
+ /// when a grapheme was emitted.
+ public bool EndLine(out GraphemeMetrics metrics)
+ => this.Flush(out metrics);
+
+ ///
+ /// Starts a new grapheme from the first emitted glyph in a consecutive grapheme run.
+ ///
+ /// The first glyph in the grapheme.
+ /// The positioned logical advance bounds for .
+ /// The rendered bounds for .
+ private void Start(
+ in GlyphLayout glyph,
+ in FontRectangle advanceBounds,
+ in FontRectangle bounds)
+ {
+ this.graphemeIndex = glyph.GraphemeIndex;
+ this.stringIndex = glyph.StringIndex;
+ this.bidiLevel = glyph.BidiLevel;
+ this.isLineBreak = CodePoint.IsNewLine(glyph.CodePoint);
+ this.advanceBounds = advanceBounds;
+ this.bounds = bounds;
+ this.hasCurrent = true;
+ }
+
+ ///
+ /// Emits the current grapheme while preserving the visual order produced by text layout.
+ ///
+ /// The emitted grapheme metrics when this method returns .
+ /// when a grapheme was emitted.
+ private bool Flush(out GraphemeMetrics metrics)
+ {
+ if (!this.hasCurrent)
+ {
+ metrics = default;
+ return false;
+ }
+
+ FontRectangle renderableBounds = FontRectangle.Union(this.advanceBounds, this.bounds);
+ metrics = new GraphemeMetrics(
+ this.advanceBounds,
+ this.bounds,
+ renderableBounds,
+ this.graphemeIndex,
+ this.stringIndex,
+ this.bidiLevel,
+ this.isLineBreak);
+
+ this.graphemes[this.count] = metrics;
+ this.count++;
+ this.hasCurrent = false;
+ return true;
+ }
+ }
+
+ ///
+ /// Coalesces laid-out glyph entries into grapheme metrics and word metrics in the same stream.
+ ///
+ private struct GraphemeAndWordMetricsAccumulator
+ {
+ private readonly List wordSegments;
+ private readonly WordMetrics[] wordMetrics;
+ private GraphemeMetricsAccumulator graphemes;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target grapheme array to fill.
+ /// The target DPI.
+ /// The source-order word-boundary segments.
+ /// The target word metrics array to fill.
+ public GraphemeAndWordMetricsAccumulator(
+ GraphemeMetrics[] graphemes,
+ float dpi,
+ List wordSegments,
+ WordMetrics[] wordMetrics)
+ {
+ this.wordSegments = wordSegments;
+ this.wordMetrics = wordMetrics;
+ this.graphemes = new(graphemes, dpi);
+ }
+
+ ///
+ /// Gets the number of graphemes emitted so far.
+ ///
+ public readonly int Count => this.graphemes.Count;
+
+ ///
+ /// Adds one laid-out glyph entry to the current grapheme and updates word metrics when a grapheme is emitted.
+ ///
+ /// The laid-out glyph entry.
+ public void Visit(in GlyphLayout glyph)
+ {
+ if (this.graphemes.Visit(glyph, out GraphemeMetrics metrics))
+ {
+ AccumulateWordMetrics(this.wordSegments, this.wordMetrics, metrics);
+ }
+ }
+
+ ///
+ /// Flushes the current line's pending grapheme and updates word metrics when a grapheme is emitted.
+ ///
+ public void EndLine()
+ {
+ if (this.graphemes.EndLine(out GraphemeMetrics metrics))
+ {
+ AccumulateWordMetrics(this.wordSegments, this.wordMetrics, metrics);
+ }
+ }
+ }
+
+ ///
+ /// Coalesces laid-out glyph entries into word metrics without storing grapheme metrics.
+ ///
+ private struct WordMetricsVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly List wordSegments;
+ private readonly WordMetrics[] wordMetrics;
+ private readonly float dpi;
+ private int graphemeIndex;
+ private FontRectangle advanceBounds;
+ private FontRectangle bounds;
+ private bool hasCurrent;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The source-order word-boundary segments.
+ /// The target word metrics array to fill.
+ /// The target DPI.
+ public WordMetricsVisitor(
+ List wordSegments,
+ WordMetrics[] wordMetrics,
+ float dpi)
+ {
+ this.wordSegments = wordSegments;
+ this.wordMetrics = wordMetrics;
+ this.dpi = dpi;
+ this.graphemeIndex = 0;
+ this.advanceBounds = FontRectangle.Empty;
+ this.bounds = FontRectangle.Empty;
+ this.hasCurrent = false;
+ }
+
+ ///
+ public readonly void BeginLine(int lineIndex)
+ {
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ FontRectangle advanceBounds = glyph.MeasureAdvance(this.dpi);
+ FontRectangle bounds = glyph.MeasureBounds(this.dpi);
+
+ if (!this.hasCurrent)
+ {
+ this.Start(glyph, advanceBounds, bounds);
+ return;
+ }
+
+ if (glyph.GraphemeIndex != this.graphemeIndex)
+ {
+ this.Flush();
+ this.Start(glyph, advanceBounds, bounds);
+ return;
+ }
+
+ this.advanceBounds = FontRectangle.Union(this.advanceBounds, advanceBounds);
+ this.bounds = FontRectangle.Union(this.bounds, bounds);
+ }
+
+ ///
+ public void EndLine() => this.Flush();
+
+ ///
+ /// Starts a new word-metrics grapheme from the first emitted glyph in a consecutive grapheme run.
+ ///
+ /// The first glyph in the grapheme.
+ /// The positioned logical advance bounds for .
+ /// The rendered bounds for .
+ private void Start(
+ in GlyphLayout glyph,
+ in FontRectangle advanceBounds,
+ in FontRectangle bounds)
+ {
+ this.graphemeIndex = glyph.GraphemeIndex;
+ this.advanceBounds = advanceBounds;
+ this.bounds = bounds;
+ this.hasCurrent = true;
+ }
+
+ ///
+ /// Emits the current grapheme directly into its source-order word-boundary segment.
+ ///
+ private void Flush()
+ {
+ if (!this.hasCurrent)
+ {
+ return;
+ }
+
+ FontRectangle renderableBounds = FontRectangle.Union(this.advanceBounds, this.bounds);
+ AccumulateWordMetrics(
+ this.wordSegments,
+ this.wordMetrics,
+ this.graphemeIndex,
+ this.advanceBounds,
+ this.bounds,
+ renderableBounds);
+
+ this.hasCurrent = false;
+ }
+ }
+
+ ///
+ /// Accumulates the rendered rectangle as glyphs stream from layout.
+ ///
+ private struct RenderedRectangleAccumulator : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly float dpi;
+ private float left;
+ private float top;
+ private float right;
+ private float bottom;
+ private bool any;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target DPI.
+ public RenderedRectangleAccumulator(float dpi)
+ {
+ this.dpi = dpi;
+ this.left = float.MaxValue;
+ this.top = float.MaxValue;
+ this.right = float.MinValue;
+ this.bottom = float.MinValue;
+ this.any = false;
+ }
+
+ ///
+ public readonly void BeginLine(int lineIndex)
+ {
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ FontRectangle box = glyph.MeasureBounds(this.dpi);
+ if (box.Width <= 0 && box.Height <= 0)
+ {
+ return;
+ }
+
+ if (box.Left < this.left)
+ {
+ this.left = box.Left;
+ }
+
+ if (box.Top < this.top)
+ {
+ this.top = box.Top;
+ }
+
+ if (box.Right > this.right)
+ {
+ this.right = box.Right;
+ }
+
+ if (box.Bottom > this.bottom)
+ {
+ this.bottom = box.Bottom;
+ }
+
+ this.any = true;
+ }
+
+ ///
+ /// Returns the accumulated rendered bounds.
+ ///
+ /// The rendered bounds of all visited glyphs.
+ public readonly FontRectangle Result()
+ => this.any ? FontRectangle.FromLTRB(this.left, this.top, this.right, this.bottom) : FontRectangle.Empty;
+
+ ///
+ public readonly void EndLine()
+ {
+ }
+ }
+
+ ///
+ /// Builds the bounds and grapheme metrics array while glyphs stream from layout.
+ ///
+ private struct GraphemeMetricsVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly float dpi;
+ private GraphemeMetricsAccumulator graphemes;
+ private float left;
+ private float top;
+ private float right;
+ private float bottom;
+ private bool hasBounds;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target DPI.
+ /// The grapheme metrics array to fill.
+ public GraphemeMetricsVisitor(
+ float dpi,
+ GraphemeMetrics[] graphemes)
+ {
+ this.dpi = dpi;
+ this.graphemes = new(graphemes, dpi);
+ this.left = float.MaxValue;
+ this.top = float.MaxValue;
+ this.right = float.MinValue;
+ this.bottom = float.MinValue;
+ this.hasBounds = false;
+ }
+
+ ///
+ public readonly void BeginLine(int lineIndex)
+ {
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ FontRectangle glyphBox = glyph.MeasureBounds(this.dpi);
+ bool hasGlyphBox = glyphBox.Width > 0 || glyphBox.Height > 0;
+
+ if (hasGlyphBox && glyphBox.Left < this.left)
+ {
+ this.left = glyphBox.Left;
+ }
+
+ if (hasGlyphBox && glyphBox.Top < this.top)
+ {
+ this.top = glyphBox.Top;
+ }
+
+ if (hasGlyphBox && glyphBox.Right > this.right)
+ {
+ this.right = glyphBox.Right;
+ }
+
+ if (hasGlyphBox && glyphBox.Bottom > this.bottom)
+ {
+ this.bottom = glyphBox.Bottom;
+ }
+
+ this.hasBounds |= hasGlyphBox;
+ this.graphemes.Visit(glyph);
+ }
+
+ ///
+ /// Returns the accumulated rendered bounds.
+ ///
+ /// The rendered bounds of all visited glyphs.
+ public readonly FontRectangle Bounds()
+ => this.hasBounds ? FontRectangle.FromLTRB(this.left, this.top, this.right, this.bottom) : FontRectangle.Empty;
+
+ ///
+ public void EndLine() => this.graphemes.EndLine();
+ }
+
+ ///
+ /// Builds the bounds, grapheme metrics, and word metrics arrays while glyphs stream from layout.
+ ///
+ private struct GraphemeAndWordMetricsVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly float dpi;
+ private GraphemeAndWordMetricsAccumulator graphemes;
+ private float left;
+ private float top;
+ private float right;
+ private float bottom;
+ private bool hasBounds;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target DPI.
+ /// The grapheme metrics array to fill.
+ /// The source-order word-boundary segments.
+ /// The word metrics array to fill.
+ public GraphemeAndWordMetricsVisitor(
+ float dpi,
+ GraphemeMetrics[] graphemes,
+ List wordSegments,
+ WordMetrics[] wordMetrics)
+ {
+ this.dpi = dpi;
+ this.graphemes = new(graphemes, dpi, wordSegments, wordMetrics);
+ this.left = float.MaxValue;
+ this.top = float.MaxValue;
+ this.right = float.MinValue;
+ this.bottom = float.MinValue;
+ this.hasBounds = false;
+ }
+
+ ///
+ public readonly void BeginLine(int lineIndex)
+ {
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ FontRectangle glyphBox = glyph.MeasureBounds(this.dpi);
+ bool hasGlyphBox = glyphBox.Width > 0 || glyphBox.Height > 0;
+
+ if (hasGlyphBox && glyphBox.Left < this.left)
+ {
+ this.left = glyphBox.Left;
+ }
+
+ if (hasGlyphBox && glyphBox.Top < this.top)
+ {
+ this.top = glyphBox.Top;
+ }
+
+ if (hasGlyphBox && glyphBox.Right > this.right)
+ {
+ this.right = glyphBox.Right;
+ }
+
+ if (hasGlyphBox && glyphBox.Bottom > this.bottom)
+ {
+ this.bottom = glyphBox.Bottom;
+ }
+
+ this.hasBounds |= hasGlyphBox;
+ this.graphemes.Visit(glyph);
+ }
+
+ ///
+ /// Returns the accumulated rendered bounds.
+ ///
+ /// The rendered bounds of all visited glyphs.
+ public readonly FontRectangle Bounds()
+ => this.hasBounds ? FontRectangle.FromLTRB(this.left, this.top, this.right, this.bottom) : FontRectangle.Empty;
+
+ ///
+ public void EndLine() => this.graphemes.EndLine();
+ }
+
+ ///
+ /// Builds the per-line grapheme metrics results while glyphs stream from layout.
+ ///
+ private struct LineLayoutVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly TextBox textBox;
+ private readonly TextOptions options;
+ private readonly float wrappingLength;
+ private readonly LineMetrics[] metrics;
+ private readonly LineLayout[] lines;
+ private readonly GraphemeMetrics[] graphemes;
+ private readonly WordMetrics[] wordMetrics;
+ private GraphemeAndWordMetricsAccumulator graphemeAccumulator;
+ private int lineIndex;
+ private int lineGraphemeStart;
+ private int metricIndex;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The shaped and line-broken text box.
+ /// The text options used for layout.
+ /// The wrapping length in pixels.
+ /// The grapheme metrics array to fill.
+ /// The line metrics aligned with the line-broken text box.
+ /// The line layout array to fill.
+ /// The source-order word-boundary segments.
+ /// The word metrics for the source text.
+ /// The target DPI.
+ public LineLayoutVisitor(
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength,
+ GraphemeMetrics[] graphemes,
+ LineMetrics[] metrics,
+ LineLayout[] lines,
+ List wordSegments,
+ WordMetrics[] wordMetrics,
+ float dpi)
+ {
+ this.textBox = textBox;
+ this.options = options;
+ this.wrappingLength = wrappingLength;
+ this.metrics = metrics;
+ this.lines = lines;
+ this.graphemes = graphemes;
+ this.wordMetrics = wordMetrics;
+ this.graphemeAccumulator = new(graphemes, dpi, wordSegments, wordMetrics);
+ this.lineIndex = 0;
+ this.lineGraphemeStart = 0;
+ this.metricIndex = 0;
+ }
+
+ ///
+ public void BeginLine(int lineIndex)
+ {
+ this.lineGraphemeStart = this.graphemeAccumulator.Count;
+ this.metricIndex = lineIndex;
+ }
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ => this.graphemeAccumulator.Visit(glyph);
+
+ ///
+ public void EndLine()
+ {
+ this.graphemeAccumulator.EndLine();
+
+ // TextLayout owns the visual line loop, so the slice is recorded here instead of
+ // reconstructing line membership from metrics after glyph emission.
+ ReadOnlyMemory lineGraphemes = new(this.graphemes, this.lineGraphemeStart, this.graphemeAccumulator.Count - this.lineGraphemeStart);
+ this.lines[this.lineIndex] = new LineLayout(
+ this.textBox,
+ this.options,
+ this.wrappingLength,
+ this.metricIndex,
+ in this.metrics[this.metricIndex],
+ lineGraphemes,
+ this.wordMetrics);
+
+ this.lineIndex++;
+ }
+ }
+
+ ///
+ /// Builds one per-glyph metrics array while glyphs stream from layout.
+ ///
+ private struct GlyphMetricsVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly GlyphMetrics[] glyphMetrics;
+ private readonly float dpi;
+ private readonly int lineIndex;
+ private int count;
+ private int currentLineIndex;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target array to fill.
+ /// The target DPI.
+ public GlyphMetricsVisitor(
+ GlyphMetrics[] glyphMetrics,
+ float dpi)
+ : this(glyphMetrics, dpi, -1)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target array to fill.
+ /// The target DPI.
+ /// The line index to collect.
+ public GlyphMetricsVisitor(
+ GlyphMetrics[] glyphMetrics,
+ float dpi,
+ int lineIndex)
+ {
+ this.glyphMetrics = glyphMetrics;
+ this.dpi = dpi;
+ this.lineIndex = lineIndex;
+ this.count = 0;
+ this.currentLineIndex = -1;
+ }
+
+ ///
+ public void BeginLine(int lineIndex)
+ => this.currentLineIndex = lineIndex;
+
+ ///
+ public void Visit(in GlyphLayout glyph)
+ {
+ if (this.lineIndex >= 0 && this.currentLineIndex != this.lineIndex)
+ {
+ return;
+ }
+
+ FontRectangle advance = glyph.MeasureAdvance(this.dpi);
+ FontRectangle bounds = glyph.MeasureBounds(this.dpi);
+ FontRectangle renderableBounds = FontRectangle.Union(advance, bounds);
+
+ this.glyphMetrics[this.count] = new GlyphMetrics(
+ glyph.Glyph.GlyphMetrics.CodePoint,
+ advance,
+ bounds,
+ renderableBounds,
+ glyph.GraphemeIndex,
+ glyph.StringIndex);
+
+ this.count++;
+ }
+
+ ///
+ public readonly void EndLine()
+ {
+ }
+ }
+
+ ///
+ /// Renders glyphs as they stream from layout.
+ ///
+ private struct GlyphRendererVisitor : TextLayout.IGlyphLayoutVisitor
+ {
+ private readonly IGlyphRenderer renderer;
+ private readonly TextOptions options;
+ private readonly int lineIndex;
+ private int currentLineIndex;
+
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The target renderer.
+ /// The text options used for rendering.
+ /// The line index to render, or -1 to render every line.
+ public GlyphRendererVisitor(IGlyphRenderer renderer, TextOptions options, int lineIndex)
+ {
+ this.renderer = renderer;
+ this.options = options;
+ this.lineIndex = lineIndex;
+ this.currentLineIndex = -1;
+ }
+
+ ///
+ public void BeginLine(int lineIndex) => this.currentLineIndex = lineIndex;
+
+ ///
+ public readonly void Visit(in GlyphLayout glyph)
+ {
+ if (this.lineIndex > -1 && this.currentLineIndex != this.lineIndex)
+ {
+ return;
+ }
+
+ glyph.Glyph.RenderTo(this.renderer, glyph.GraphemeIndex, glyph.GlyphOrigin, glyph.DecorationOrigin, glyph.LayoutMode, this.options);
+ }
+
+ ///
+ public readonly void EndLine()
+ {
+ }
+ }
+}
diff --git a/src/SixLabors.Fonts/TextBlock.cs b/src/SixLabors.Fonts/TextBlock.cs
new file mode 100644
index 00000000..2c2b6aa5
--- /dev/null
+++ b/src/SixLabors.Fonts/TextBlock.cs
@@ -0,0 +1,583 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+using SixLabors.Fonts.Rendering;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents text prepared for repeated line layout, measurement, and rendering.
+///
+public sealed partial class TextBlock
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The text to prepare.
+ /// The text options used to prepare, measure, and render the block.
+ ///
+ /// is ignored while preparing the block; pass the wrapping length
+ /// to the measurement or rendering method. Use -1 there to disable wrapping.
+ ///
+ public TextBlock(string text, TextOptions options)
+ : this(text.AsSpan(), options)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The text to prepare.
+ /// The text options used to prepare, measure, and render the block.
+ ///
+ /// is ignored while preparing the block; pass the wrapping length
+ /// to the measurement or rendering method. Use -1 there to disable wrapping.
+ ///
+ public TextBlock(ReadOnlySpan text, TextOptions options)
+ {
+ this.Options = options;
+
+ if (text.IsEmpty)
+ {
+ this.LogicalLine = new(new TextLine(), [], [], []);
+ return;
+ }
+
+ ShapedText shaped = TextLayout.ShapeText(text, options);
+ this.LogicalLine = TextLayout.ComposeLogicalLine(shaped, text, options);
+ }
+
+ ///
+ /// Gets the text options used by this block.
+ ///
+ internal TextOptions Options { get; }
+
+ ///
+ /// Gets the prepared logical line and line break opportunities.
+ ///
+ internal LogicalTextLine LogicalLine { get; }
+
+ ///
+ /// Breaks this block into lines for the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The line-broken text box.
+ internal TextBox BreakLines(float wrappingLength)
+ => TextLayout.BreakLines(this.LogicalLine, this.Options, wrappingLength);
+
+ ///
+ /// Measures the full set of layout metrics for this block at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A instance containing every measurement for the laid-out text.
+ public TextMetrics Measure(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ float dpi = this.Options.Dpi;
+ bool isHorizontal = this.Options.LayoutMode.IsHorizontal();
+
+ FontRectangle advance = GetAdvance(textBox, dpi, isHorizontal);
+
+ GraphemeMetrics[] graphemes = new GraphemeMetrics[CountGraphemeMetrics(textBox)];
+ WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
+
+ GraphemeAndWordMetricsVisitor visitor = new(dpi, graphemes, this.LogicalLine.WordSegments, wordMetrics);
+ TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
+
+ FontRectangle bounds = visitor.Bounds();
+ FontRectangle absoluteAdvance = new(this.Options.Origin.X, this.Options.Origin.Y, advance.Width, advance.Height);
+ FontRectangle renderableBounds = FontRectangle.Union(absoluteAdvance, bounds);
+
+ LineMetrics[] lineMetrics = GetLineMetrics(textBox, this.Options, wrappingLength);
+
+ return new TextMetrics(
+ this,
+ textBox,
+ wrappingLength,
+ advance,
+ bounds,
+ renderableBounds,
+ textBox.TextLines.Count,
+ graphemes,
+ lineMetrics,
+ wordMetrics);
+ }
+
+ ///
+ /// Measures the logical advance of this block at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The logical advance rectangle.
+ public FontRectangle MeasureAdvance(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ return GetAdvance(textBox, this.Options.Dpi, this.Options.LayoutMode.IsHorizontal());
+ }
+
+ ///
+ /// Measures the rendered glyph bounds of this block at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The rendered glyph bounds.
+ public FontRectangle MeasureBounds(float wrappingLength)
+ => GetBounds(this.BreakLines(wrappingLength), this.Options, wrappingLength);
+
+ ///
+ /// Measures the union of logical advance and rendered glyph bounds at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The full renderable bounds.
+ public FontRectangle MeasureRenderableBounds(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ FontRectangle advance = GetAdvance(textBox, this.Options.Dpi, this.Options.LayoutMode.IsHorizontal());
+ FontRectangle absoluteAdvance = new(this.Options.Origin.X, this.Options.Origin.Y, advance.Width, advance.Height);
+ FontRectangle bounds = GetBounds(textBox, this.Options, wrappingLength);
+ return FontRectangle.Union(absoluteAdvance, bounds);
+ }
+
+ ///
+ /// Gets the positioned metrics of each laid-out glyph entry.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A read-only memory region containing per-glyph metrics entries.
+ public ReadOnlyMemory GetGlyphMetrics(float wrappingLength)
+ => this.GetGlyphMetricsArray(wrappingLength);
+
+ ///
+ /// Gets the positioned metrics of each laid-out grapheme.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A read-only memory region containing per-grapheme metrics entries.
+ public ReadOnlyMemory GetGraphemeMetrics(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ return GetGraphemeMetricsArray(textBox, this.Options, wrappingLength);
+ }
+
+ ///
+ /// Gets the positioned metrics of each Unicode word-boundary segment.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A read-only memory region containing per-word-boundary segment metrics entries.
+ public ReadOnlyMemory GetWordMetrics(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
+ WordMetricsVisitor visitor = new(this.LogicalLine.WordSegments, wordMetrics, this.Options.Dpi);
+ TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
+ return wordMetrics;
+ }
+
+ ///
+ /// Gets the number of laid-out lines at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The laid-out line count.
+ public int CountLines(float wrappingLength)
+ => this.BreakLines(wrappingLength).TextLines.Count;
+
+ ///
+ /// Gets per-line layout metrics at the supplied wrapping length.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A read-only memory region containing in pixel units.
+ public ReadOnlyMemory GetLineMetrics(float wrappingLength)
+ => GetLineMetrics(this.BreakLines(wrappingLength), this.Options, wrappingLength);
+
+ ///
+ /// Gets visual line layouts for this block at the supplied wrapping length.
+ ///
+ ///
+ /// The returned memory contains every laid-out line, including lines produced by hard line breaks.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// A read-only memory region containing entries in final layout order.
+ public ReadOnlyMemory GetLineLayouts(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ if (textBox.TextLines.Count == 0)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ return this.GetLineLayouts(textBox, wrappingLength);
+ }
+
+ ///
+ /// Creates an enumerator that lays out this block one line at a time.
+ ///
+ /// A line layout enumerator for this block.
+ public LineLayoutEnumerator EnumerateLineLayouts()
+ => new(this);
+
+ ///
+ /// Gets a single line layout for an already line-broken text line.
+ ///
+ /// The line to lay out.
+ /// The wrapping length in pixels.
+ /// The block-level text direction used for alignment.
+ /// The line layout for the supplied line.
+ internal LineLayout GetLineLayout(
+ TextLine textLine,
+ float wrappingLength,
+ TextDirection textDirection)
+ {
+ TextBox textBox = new([textLine], textDirection);
+
+ return this.GetLineLayouts(textBox, wrappingLength)[0];
+ }
+
+ ///
+ /// Gets visual line layouts for an already line-broken text box.
+ ///
+ /// The shaped and line-broken text box.
+ /// The wrapping length in pixels.
+ /// The line layouts for the supplied text box.
+ private LineLayout[] GetLineLayouts(TextBox textBox, float wrappingLength)
+ {
+ GraphemeMetrics[] graphemes = new GraphemeMetrics[CountGraphemeMetrics(textBox)];
+ LineMetrics[] metrics = GetLineMetrics(textBox, this.Options, wrappingLength);
+ LineLayout[] lines = new LineLayout[textBox.TextLines.Count];
+
+ WordMetrics[] wordMetrics = new WordMetrics[this.LogicalLine.WordSegments.Count];
+ LineLayoutVisitor visitor = new(textBox, this.Options, wrappingLength, graphemes, metrics, lines, this.LogicalLine.WordSegments, wordMetrics, this.Options.Dpi);
+ TextLayout.LayoutText(textBox, this.Options, wrappingLength, ref visitor);
+
+ return lines;
+ }
+
+ ///
+ /// Renders this block to the supplied glyph renderer at the supplied wrapping length.
+ ///
+ /// The target renderer.
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ public void RenderTo(IGlyphRenderer renderer, float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ FontRectangle rect = GetBounds(textBox, this.Options, wrappingLength);
+
+ RenderTo(renderer, textBox, this.Options, wrappingLength, rect);
+ }
+
+ ///
+ /// Renders an already line-broken text box to the supplied glyph renderer.
+ ///
+ /// The target renderer.
+ /// The shaped and line-broken text box.
+ /// The text options used for rendering.
+ /// The wrapping length in pixels.
+ /// The bounds passed to the renderer.
+ /// The line index to render, or -1 to render every line.
+ internal static void RenderTo(
+ IGlyphRenderer renderer,
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength,
+ in FontRectangle bounds,
+ int lineIndex = -1)
+ {
+ renderer.BeginText(in bounds);
+
+ GlyphRendererVisitor visitor = new(renderer, options, lineIndex);
+ TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
+
+ renderer.EndText();
+ }
+
+ ///
+ /// Measures the rendered glyph bounds of an already line-broken text box.
+ ///
+ /// The shaped and line-broken text box.
+ /// The text options used for layout.
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The union of the rendered glyph bounds.
+ private static FontRectangle GetBounds(TextBox textBox, TextOptions options, float wrappingLength)
+ {
+ if (textBox.TextLines.Count == 0)
+ {
+ return FontRectangle.Empty;
+ }
+
+ RenderedRectangleAccumulator visitor = new(options.Dpi);
+ TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
+ return visitor.Result();
+ }
+
+ ///
+ /// Gets per-line layout metrics for an already line-broken text box.
+ ///
+ /// The shaped and line-broken text box.
+ /// The text options used to calculate line metrics.
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// An array of in pixel units.
+ private static LineMetrics[] GetLineMetrics(TextBox textBox, TextOptions options, float wrappingLength)
+ {
+ if (textBox.TextLines.Count == 0)
+ {
+ return [];
+ }
+
+ LineMetrics[] metrics = new LineMetrics[textBox.TextLines.Count];
+
+ // Determine the line-box extent used for alignment within the flow direction.
+ float maxScaledAdvance = textBox.ScaledMaxAdvance();
+ if (options.TextAlignment != TextAlignment.Start && wrappingLength > 0)
+ {
+ maxScaledAdvance = MathF.Max(wrappingLength / options.Dpi, maxScaledAdvance);
+ }
+
+ TextDirection direction = textBox.TextDirection();
+ LayoutMode layoutMode = options.LayoutMode;
+
+ bool isHorizontalLayout = layoutMode.IsHorizontal();
+ float lineOffset = isHorizontalLayout ? options.Origin.Y : options.Origin.X;
+
+ bool reverseLineOrder = layoutMode is
+ LayoutMode.HorizontalBottomTop
+ or LayoutMode.VerticalRightLeft
+ or LayoutMode.VerticalMixedRightLeft;
+
+ int i = reverseLineOrder ? textBox.TextLines.Count - 1 : 0;
+ int step = reverseLineOrder ? -1 : 1;
+ int graphemeOffset = 0;
+
+ while (i >= 0 && i < textBox.TextLines.Count)
+ {
+ TextLine line = textBox.TextLines[i];
+
+ // Calculate the line start position in the current flow direction.
+ float offset = isHorizontalLayout
+ ? TextLayout.CalculateLineOffsetX(
+ line.ScaledLineAdvance,
+ maxScaledAdvance,
+ options.HorizontalAlignment,
+ options.TextAlignment,
+ direction)
+ : TextLayout.CalculateLineOffsetY(
+ line.ScaledLineAdvance,
+ maxScaledAdvance,
+ options.VerticalAlignment,
+ options.TextAlignment,
+ direction);
+
+ // Delta captured during layout when ascender/descender were symmetrically
+ // adjusted to match browser-like line-box behavior.
+ float delta = line.ScaledMaxDelta;
+
+ // Core typographic region within the line box.
+ // We add back 2*delta to recover the pre-adjustment ascender+descender span
+ // used for deriving guide positions.
+ float coreHeight = line.ScaledMaxAscender + line.ScaledMaxDescender + (2 * delta);
+
+ // Additional leading in the line box (for example from line spacing).
+ float extra = line.ScaledMaxLineHeight - coreHeight;
+
+ // Baseline position within the line box.
+ float baseline = (extra * 0.5f) + line.ScaledMaxAscender + delta;
+
+ // Ascender line position relative to the same origin.
+ float ascender = baseline - line.ScaledMaxAscender + delta;
+
+ // Descender line position relative to the same origin.
+ float descender = baseline + line.ScaledMaxDescender + delta;
+ Vector2 start = isHorizontalLayout
+ ? new(options.Origin.X + (offset * options.Dpi), lineOffset)
+ : new(lineOffset, options.Origin.Y + (offset * options.Dpi));
+
+ Vector2 extent = isHorizontalLayout
+ ? new(line.ScaledLineAdvance * options.Dpi, line.ScaledMaxLineHeight * options.Dpi)
+ : new(line.ScaledMaxLineHeight * options.Dpi, line.ScaledLineAdvance * options.Dpi);
+
+ // Bidi reordering mutates entries into visual order, so the source
+ // start is the minimum original source index rather than line[0].
+ int stringIndex = line[0].StringIndex;
+ int graphemeIndex = line[0].GraphemeIndex;
+ for (int j = 1; j < line.Count; j++)
+ {
+ stringIndex = Math.Min(stringIndex, line[j].StringIndex);
+ graphemeIndex = Math.Min(graphemeIndex, line[j].GraphemeIndex);
+ }
+
+ metrics[i] = new LineMetrics(
+ ascender * options.Dpi,
+ baseline * options.Dpi,
+ descender * options.Dpi,
+ line.ScaledMaxLineHeight * options.Dpi,
+ start,
+ extent,
+ stringIndex,
+ graphemeIndex,
+ line.GraphemeCount,
+ graphemeOffset);
+
+ graphemeOffset += line.GraphemeCount;
+ lineOffset += line.ScaledMaxLineHeight * options.Dpi;
+ i += step;
+ }
+
+ return metrics;
+ }
+
+ ///
+ /// Counts grapheme metrics entries across all lines in an already line-broken text box.
+ ///
+ /// The shaped and line-broken text box.
+ /// The number of grapheme metrics entries.
+ private static int CountGraphemeMetrics(TextBox textBox)
+ {
+ int count = 0;
+ for (int i = 0; i < textBox.TextLines.Count; i++)
+ {
+ count += textBox.TextLines[i].GraphemeCount;
+ }
+
+ return count;
+ }
+
+ ///
+ /// Gets grapheme metrics entries by streaming laid-out glyphs.
+ ///
+ /// The shaped and line-broken text box.
+ /// The text options used for layout.
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The grapheme metrics entries.
+ internal static GraphemeMetrics[] GetGraphemeMetricsArray(
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength)
+ {
+ int count = CountGraphemeMetrics(textBox);
+ if (count == 0)
+ {
+ return [];
+ }
+
+ GraphemeMetrics[] graphemes = new GraphemeMetrics[count];
+ GraphemeMetricsVisitor visitor = new(options.Dpi, graphemes);
+ TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
+ return graphemes;
+ }
+
+ ///
+ /// Finds the source-order word-boundary range containing the supplied grapheme index.
+ ///
+ /// The source-order word-boundary segments.
+ /// The grapheme index to locate.
+ /// The matching word metrics index, or -1 when no range contains the grapheme.
+ private static int FindWordMetricIndex(List wordSegments, int graphemeIndex)
+ {
+ int min = 0;
+ int max = wordSegments.Count - 1;
+ while (min <= max)
+ {
+ int mid = (min + max) >> 1;
+ WordSegmentRun segment = wordSegments[mid];
+ if (graphemeIndex < segment.GraphemeStart)
+ {
+ max = mid - 1;
+ continue;
+ }
+
+ if (graphemeIndex >= segment.GraphemeEnd)
+ {
+ min = mid + 1;
+ continue;
+ }
+
+ return mid;
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Gets a value indicating whether positioned metrics have been added to a word segment.
+ ///
+ /// The word metrics to inspect.
+ /// when a grapheme has been accumulated for the segment.
+ private static bool HasWordMetrics(in WordMetrics metrics)
+ {
+ // Default WordMetrics has no source range. Any real word segment has an exclusive end
+ // index, so the range is the sentinel that avoids treating FontRectangle.Empty as geometry.
+ return metrics.GraphemeEnd != 0 || metrics.StringEnd != 0;
+ }
+
+ ///
+ /// Gets one per-glyph metrics collection by streaming laid-out glyphs.
+ ///
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The positioned glyph metrics.
+ internal GlyphMetrics[] GetGlyphMetricsArray(float wrappingLength)
+ {
+ TextBox textBox = this.BreakLines(wrappingLength);
+ return GetGlyphMetricsArray(textBox, this.Options, wrappingLength);
+ }
+
+ ///
+ /// Gets one per-glyph metrics collection by streaming laid-out glyphs.
+ ///
+ /// The shaped and line-broken text box.
+ /// The text options used for layout.
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The line index to collect, or -1 to collect every line.
+ /// The positioned glyph metrics.
+ internal static GlyphMetrics[] GetGlyphMetricsArray(
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength,
+ int lineIndex = -1)
+ {
+ int count = lineIndex < 0 ? textBox.CountGlyphLayouts() : textBox.TextLines[lineIndex].CountGlyphLayouts();
+ if (count == 0)
+ {
+ return [];
+ }
+
+ GlyphMetrics[] result = new GlyphMetrics[count];
+ GlyphMetricsVisitor visitor = new(result, options.Dpi, lineIndex);
+ TextLayout.LayoutText(textBox, options, wrappingLength, ref visitor);
+ return result;
+ }
+
+ ///
+ /// Measures the logical advance of an already line-broken text box.
+ ///
+ /// The shaped and line-broken text box.
+ /// The target DPI.
+ /// Whether the layout direction is horizontal.
+ /// The logical advance rectangle.
+ private static FontRectangle GetAdvance(TextBox textBox, float dpi, bool isHorizontalLayout)
+ {
+ if (textBox.TextLines.Count == 0)
+ {
+ return FontRectangle.Empty;
+ }
+
+ if (isHorizontalLayout)
+ {
+ float width = 0;
+ float height = 0;
+ for (int i = 0; i < textBox.TextLines.Count; i++)
+ {
+ TextLine line = textBox.TextLines[i];
+ width = MathF.Max(width, line.ScaledLineAdvance);
+ height += line.ScaledMaxLineHeight;
+ }
+
+ return new FontRectangle(0, 0, width * dpi, height * dpi);
+ }
+
+ float verticalWidth = 0;
+ float verticalHeight = 0;
+ for (int i = 0; i < textBox.TextLines.Count; i++)
+ {
+ TextLine line = textBox.TextLines[i];
+ verticalWidth += line.ScaledMaxLineHeight;
+ verticalHeight = MathF.Max(verticalHeight, line.ScaledLineAdvance);
+ }
+
+ return new FontRectangle(0, 0, verticalWidth * dpi, verticalHeight * dpi);
+ }
+}
diff --git a/src/SixLabors.Fonts/TextBox.cs b/src/SixLabors.Fonts/TextBox.cs
new file mode 100644
index 00000000..e1800fae
--- /dev/null
+++ b/src/SixLabors.Fonts/TextBox.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Collections.Generic;
+using System.Linq;
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents a shaped and line-broken block of text.
+///
+internal sealed class TextBox
+{
+ private readonly TextDirection textDirection;
+
+ private float? scaledMaxAdvance;
+
+ private float? minY;
+
+ private int glyphLayoutCount;
+
+ private bool hasGlyphLayoutCounts;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The shaped, line-broken lines that make up this text box.
+ /// The block-level text direction.
+ public TextBox(IReadOnlyList textLines, TextDirection textDirection)
+ {
+ this.TextLines = textLines;
+ this.textDirection = textDirection;
+ }
+
+ ///
+ /// Gets the shaped and line-broken lines that make up the text.
+ ///
+ public IReadOnlyList TextLines { get; }
+
+ ///
+ /// Returns the widest scaled line advance across all lines. The result is memoized.
+ ///
+ /// The widest scaled line advance.
+ public float ScaledMaxAdvance()
+ => this.scaledMaxAdvance ??= this.TextLines.Max(x => x.ScaledLineAdvance);
+
+ ///
+ /// Returns the smallest (most negative) scaled Y position encountered across all lines.
+ /// Used to detect ink that extends above the typographic ascender (stacked marks in Tibetan etc.).
+ /// The result is memoized.
+ ///
+ /// The smallest scaled Y position in the text box.
+ public float ScaledMinY()
+ => this.minY ??= this.TextLines.Min(x => x.ScaledMinY);
+
+ ///
+ /// Counts all glyph entries emitted from this text box. The result is memoized.
+ ///
+ /// The number of glyph entries that layout will emit.
+ public int CountGlyphLayouts()
+ => this.hasGlyphLayoutCounts ? this.glyphLayoutCount : this.CountGlyphLayoutsCore();
+
+ ///
+ /// Computes the glyph-layout count in one pass.
+ ///
+ /// The number of glyph entries that layout will emit.
+ private int CountGlyphLayoutsCore()
+ {
+ int count = 0;
+ for (int i = 0; i < this.TextLines.Count; i++)
+ {
+ count += this.TextLines[i].CountGlyphLayouts();
+ }
+
+ this.glyphLayoutCount = count;
+ this.hasGlyphLayoutCounts = true;
+ return count;
+ }
+
+ ///
+ /// Returns the block-level text direction used for alignment calculations.
+ ///
+ /// The block-level text direction.
+ public TextDirection TextDirection() => this.textDirection;
+}
diff --git a/src/SixLabors.Fonts/TextEllipsis.cs b/src/SixLabors.Fonts/TextEllipsis.cs
new file mode 100644
index 00000000..3903e40f
--- /dev/null
+++ b/src/SixLabors.Fonts/TextEllipsis.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies ellipsis behavior when laid-out text is limited to a maximum number of lines.
+///
+public enum TextEllipsis
+{
+ ///
+ /// Do not insert an ellipsis marker.
+ ///
+ None = 0,
+
+ ///
+ /// Insert the standard ellipsis marker.
+ ///
+ Standard,
+
+ ///
+ /// Insert the marker specified by .
+ ///
+ Custom
+}
diff --git a/src/SixLabors.Fonts/TextHit.cs b/src/SixLabors.Fonts/TextHit.cs
new file mode 100644
index 00000000..6dfc0f50
--- /dev/null
+++ b/src/SixLabors.Fonts/TextHit.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Represents a hit-tested grapheme position in laid-out text.
+///
+public readonly struct TextHit
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The zero-based line index.
+ /// The grapheme index in the original text.
+ /// The UTF-16 index in the original text.
+ /// Whether the hit is on the trailing side of the grapheme.
+ internal TextHit(int lineIndex, int graphemeIndex, int stringIndex, bool isTrailing)
+ {
+ this.LineIndex = lineIndex;
+ this.GraphemeIndex = graphemeIndex;
+ this.StringIndex = stringIndex;
+ this.IsTrailing = isTrailing;
+ }
+
+ ///
+ /// Gets the zero-based line index.
+ ///
+ public int LineIndex { get; }
+
+ ///
+ /// Gets the zero-based grapheme index in the original text.
+ ///
+ public int GraphemeIndex { get; }
+
+ ///
+ /// Gets the zero-based UTF-16 code unit index in the original text.
+ ///
+ public int StringIndex { get; }
+
+ ///
+ /// Gets the grapheme insertion index represented by this hit.
+ ///
+ public int GraphemeInsertionIndex => this.GraphemeIndex + (this.IsTrailing ? 1 : 0);
+
+ ///
+ /// Gets a value indicating whether the hit is on the trailing side of the grapheme.
+ ///
+ public bool IsTrailing { get; }
+}
diff --git a/src/SixLabors.Fonts/TextHyphenation.cs b/src/SixLabors.Fonts/TextHyphenation.cs
new file mode 100644
index 00000000..f262801b
--- /dev/null
+++ b/src/SixLabors.Fonts/TextHyphenation.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies hyphenation marker behavior when text breaks at hyphenation opportunities.
+///
+public enum TextHyphenation
+{
+ ///
+ /// Do not insert a hyphenation marker.
+ ///
+ None = 0,
+
+ ///
+ /// Insert the standard hyphenation marker.
+ ///
+ Standard,
+
+ ///
+ /// Insert the marker specified by .
+ ///
+ Custom
+}
diff --git a/src/SixLabors.Fonts/TextInteraction.cs b/src/SixLabors.Fonts/TextInteraction.cs
new file mode 100644
index 00000000..2e4bf74f
--- /dev/null
+++ b/src/SixLabors.Fonts/TextInteraction.cs
@@ -0,0 +1,1445 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Numerics;
+
+namespace SixLabors.Fonts;
+
+///
+/// Provides shared helpers for text interaction metrics.
+///
+///
+/// Text interaction uses grapheme advance rectangles as the logical hit target. Ink bounds can be
+/// empty, overhang the advance, or exclude whitespace, which makes them unsuitable for caret
+/// positioning and selection highlighting.
+///
+internal static class TextInteraction
+{
+ ///
+ /// Hit tests a point against a complete laid-out text box.
+ ///
+ /// All laid-out lines ordered by their visual position.
+ /// The full grapheme metrics buffer flattened in visual order.
+ /// The text-space coordinate to resolve to a grapheme hit.
+ /// The orientation used to interpret the line and grapheme advances.
+ /// The nearest grapheme hit.
+ public static TextHit HitTest(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ Vector2 point,
+ LayoutMode layoutMode)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty)
+ {
+ return new(-1, -1, -1, false);
+ }
+
+ bool isHorizontal = layoutMode.IsHorizontal();
+ int lineIndex = FindLine(lines, point, isHorizontal);
+
+ // LineMetrics preserve their source line index, while grapheme metrics are emitted in
+ // visual line order. Locate the line slice by source range so reverse line-order modes
+ // pair the hit-tested line with its own graphemes.
+ int graphemeOffset = GetGraphemeOffset(lines[lineIndex]);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, lines[lineIndex].GraphemeCount);
+
+ return HitTestLine(lineIndex, lineGraphemes, point, isHorizontal);
+ }
+
+ ///
+ /// Hit tests a point against one laid-out line.
+ ///
+ /// The zero-based visual index of the line being hit tested.
+ /// Only the grapheme metrics belonging to the target line.
+ /// The coordinate to compare against the line's primary advance axis.
+ /// The line orientation that determines which axis is primary.
+ /// The nearest grapheme hit.
+ public static TextHit HitTestLine(
+ int lineIndex,
+ ReadOnlySpan graphemes,
+ Vector2 point,
+ LayoutMode layoutMode)
+ => HitTestLine(lineIndex, graphemes, point, layoutMode.IsHorizontal());
+
+ ///
+ /// Gets a caret position from a complete laid-out text box.
+ ///
+ /// All laid-out lines available for caret placement.
+ /// The flattened grapheme metrics that back the full text box.
+ /// The logical insertion position to convert into a visual caret.
+ /// The layout orientation used when the caret geometry was calculated.
+ /// The caret position in pixel units.
+ public static CaretPosition GetCaretPosition(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ int graphemeIndex,
+ LayoutMode layoutMode)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty)
+ {
+ return new(-1, -1, -1, default, default, false, default, default, 0);
+ }
+
+ int lineIndex = FindLineByGraphemeIndex(lines, graphemeIndex);
+ LineMetrics line = lines[lineIndex];
+
+ // See HitTest: line source indices and flattened storage offsets are deliberately
+ // separate because bidi reordering can make source order differ from visual order.
+ int graphemeOffset = GetGraphemeOffset(line);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
+
+ return GetCaretPositionLine(lineIndex, line, lineGraphemes, graphemeIndex, layoutMode);
+ }
+
+ ///
+ /// Gets a caret position from one laid-out line.
+ ///
+ /// The zero-based visual index of the supplied line.
+ /// The metrics for the single line that will host the caret.
+ /// The visual-order grapheme metrics for that one line.
+ /// The logical insertion position to place within the supplied line.
+ /// The orientation that determines the caret edge direction.
+ /// The caret position in pixel units.
+ public static CaretPosition GetCaretPositionLine(
+ int lineIndex,
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ int graphemeIndex,
+ LayoutMode layoutMode)
+ {
+ if (graphemes.IsEmpty)
+ {
+ return new(lineIndex, line.GraphemeIndex, line.StringIndex, default, default, false, default, default, 0);
+ }
+
+ return CreateCaret(lineIndex, line, graphemes, graphemeIndex, layoutMode.IsHorizontal());
+ }
+
+ ///
+ /// Gets an absolute caret position from a complete laid-out text box.
+ ///
+ /// All laid-out lines available for caret placement.
+ /// The flattened grapheme metrics that back the full text box.
+ /// The absolute placement within the text box.
+ /// The layout orientation used when the caret geometry was calculated.
+ /// The resolved text direction used to choose the visual start or end of the scope.
+ /// The caret position in pixel units.
+ public static CaretPosition GetCaret(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ CaretPlacement placement,
+ LayoutMode layoutMode,
+ TextDirection direction)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty)
+ {
+ return new(-1, -1, -1, default, default, false, default, default, 0);
+ }
+
+ int targetGraphemeIndex = placement == CaretPlacement.Start
+ ? GetSourceTextStart(graphemes)
+ : GetSourceTextEnd(graphemes);
+
+ int lineIndex = FindLineByGraphemeIndex(lines, targetGraphemeIndex);
+
+ LineMetrics line = lines[lineIndex];
+ int graphemeOffset = GetGraphemeOffset(line);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
+
+ return GetCaretLine(lineIndex, line, lineGraphemes, placement, layoutMode, direction);
+ }
+
+ ///
+ /// Gets an absolute caret position from one laid-out line.
+ ///
+ /// The zero-based visual index of the supplied line.
+ /// The metrics for the single line that will host the caret.
+ /// The visual-order grapheme metrics for that one line.
+ /// The absolute placement within the line.
+ /// The orientation that determines the caret edge direction.
+ /// The resolved text direction used to choose the visual start or end of the scope.
+ /// The caret position in pixel units.
+ public static CaretPosition GetCaretLine(
+ int lineIndex,
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ CaretPlacement placement,
+ LayoutMode layoutMode,
+ TextDirection direction)
+ {
+ if (graphemes.IsEmpty)
+ {
+ return new(lineIndex, line.GraphemeIndex, line.StringIndex, default, default, false, default, default, 0);
+ }
+
+ return CreateCaretAtVisualLineEdge(lineIndex, line, graphemes, placement, layoutMode.IsHorizontal(), direction);
+ }
+
+ ///
+ /// Moves a caret within a complete laid-out text box.
+ ///
+ /// The visual lines across which the caret may move.
+ /// The flattened grapheme metrics used to resolve movement targets.
+ /// The source-order word-boundary segment metrics used for word movement.
+ /// The starting caret location before applying the movement.
+ /// The requested caret navigation command.
+ /// The orientation rules that control horizontal versus vertical motion.
+ /// The resolved text direction used to choose line and text start/end.
+ /// The moved caret position in pixel units.
+ public static CaretPosition MoveCaret(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ ReadOnlySpan wordMetrics,
+ CaretPosition caret,
+ CaretMovement movement,
+ LayoutMode layoutMode,
+ TextDirection direction)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty)
+ {
+ return caret;
+ }
+
+ bool isHorizontal = layoutMode.IsHorizontal();
+ int lineIndex = GetCaretLineIndex(lines, graphemes, caret);
+ LineMetrics line = lines[lineIndex];
+ int graphemeOffset = GetGraphemeOffset(line);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
+ int target = caret.GraphemeIndex;
+ switch (movement)
+ {
+ case CaretMovement.Previous:
+ target = GetPreviousInsertionIndex(graphemes, caret.GraphemeIndex, GetSourceTextStart(graphemes));
+ break;
+
+ case CaretMovement.Next:
+ target = GetNextInsertionIndex(graphemes, caret.GraphemeIndex, GetSourceTextEnd(graphemes));
+ break;
+
+ case CaretMovement.PreviousWord:
+ target = GetPreviousWordBoundary(wordMetrics, caret.GraphemeIndex, GetSourceTextStart(graphemes));
+ break;
+
+ case CaretMovement.NextWord:
+ target = GetNextWordBoundary(wordMetrics, caret.GraphemeIndex, GetSourceTextEnd(graphemes));
+ break;
+
+ case CaretMovement.LineStart:
+ return GetCaretLine(lineIndex, line, lineGraphemes, CaretPlacement.Start, layoutMode, direction);
+
+ case CaretMovement.LineEnd:
+ return GetCaretLine(lineIndex, line, lineGraphemes, CaretPlacement.End, layoutMode, direction);
+
+ case CaretMovement.TextStart:
+ return GetCaret(lines, graphemes, CaretPlacement.Start, layoutMode, direction);
+
+ case CaretMovement.TextEnd:
+ return GetCaret(lines, graphemes, CaretPlacement.End, layoutMode, direction);
+
+ case CaretMovement.LineUp:
+ return MoveCaretToAdjacentLine(
+ lines,
+ graphemes,
+ caret,
+ lineIndex,
+ lineDown: false,
+ isHorizontal: isHorizontal,
+ layoutMode: layoutMode);
+
+ case CaretMovement.LineDown:
+ return MoveCaretToAdjacentLine(
+ lines,
+ graphemes,
+ caret,
+ lineIndex,
+ lineDown: true,
+ isHorizontal: isHorizontal,
+ layoutMode: layoutMode);
+ }
+
+ return GetCaretPosition(lines, graphemes, target, layoutMode);
+ }
+
+ ///
+ /// Moves a caret within one laid-out line.
+ ///
+ /// The zero-based visual index of the current line.
+ /// The line metrics that constrain the movement.
+ /// The grapheme metrics available within that line.
+ /// The source-order word-boundary segment metrics used for word movement.
+ /// The caret location to move inside the line.
+ /// The in-line caret navigation command to execute.
+ /// The orientation used to choose the caret axis within the line.
+ /// The resolved text direction used to choose line start/end.
+ /// The moved caret position in pixel units.
+ public static CaretPosition MoveCaretLine(
+ int lineIndex,
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ ReadOnlySpan wordMetrics,
+ CaretPosition caret,
+ CaretMovement movement,
+ LayoutMode layoutMode,
+ TextDirection direction)
+ {
+ if (graphemes.IsEmpty)
+ {
+ return caret;
+ }
+
+ int lineStart = GetSourceLineStart(graphemes);
+ int lineEnd = GetSourceLineEnd(graphemes);
+ int target = caret.GraphemeIndex;
+ switch (movement)
+ {
+ case CaretMovement.Previous:
+ target = GetPreviousInsertionIndex(graphemes, caret.GraphemeIndex, lineStart);
+ break;
+
+ case CaretMovement.Next:
+ target = GetNextInsertionIndex(graphemes, caret.GraphemeIndex, lineEnd);
+ break;
+
+ case CaretMovement.PreviousWord:
+ target = Math.Max(
+ lineStart,
+ GetPreviousWordBoundary(wordMetrics, caret.GraphemeIndex, lineStart));
+ break;
+
+ case CaretMovement.NextWord:
+ target = Math.Min(
+ lineEnd,
+ GetNextWordBoundary(wordMetrics, caret.GraphemeIndex, lineEnd));
+ break;
+
+ case CaretMovement.LineStart:
+ case CaretMovement.TextStart:
+ return GetCaretLine(lineIndex, line, graphemes, CaretPlacement.Start, layoutMode, direction);
+
+ case CaretMovement.LineEnd:
+ case CaretMovement.TextEnd:
+ return GetCaretLine(lineIndex, line, graphemes, CaretPlacement.End, layoutMode, direction);
+
+ case CaretMovement.LineUp:
+ case CaretMovement.LineDown:
+ return caret;
+ }
+
+ return GetCaretPositionLine(lineIndex, line, graphemes, target, layoutMode);
+ }
+
+ ///
+ /// Gets the word-boundary segment metrics containing the supplied grapheme insertion index.
+ ///
+ /// The source-order word metrics to search.
+ /// The grapheme insertion index to locate.
+ /// The matching word metrics.
+ public static WordMetrics GetWordMetrics(ReadOnlySpan wordMetrics, int graphemeIndex)
+ {
+ if (wordMetrics.IsEmpty)
+ {
+ return default;
+ }
+
+ for (int i = 0; i < wordMetrics.Length; i++)
+ {
+ WordMetrics metrics = wordMetrics[i];
+ if (graphemeIndex >= metrics.GraphemeStart && graphemeIndex < metrics.GraphemeEnd)
+ {
+ return metrics;
+ }
+
+ if (graphemeIndex < metrics.GraphemeStart)
+ {
+ return metrics;
+ }
+ }
+
+ return wordMetrics[^1];
+ }
+
+ ///
+ /// Gets selection rectangles from a complete laid-out text box.
+ ///
+ /// The visual lines that may contribute selection rectangles.
+ /// The flattened grapheme metrics scanned for the selected range.
+ /// The first source grapheme insertion boundary in the selection.
+ /// The final source grapheme insertion boundary in the selection.
+ /// The orientation used when converting ranges into rectangles.
+ /// A read-only memory region containing the selection rectangles in visual order.
+ public static ReadOnlyMemory GetSelectionBounds(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ int graphemeStart,
+ int graphemeEnd,
+ LayoutMode layoutMode)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty || graphemeStart == graphemeEnd)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ int selectionStart = Math.Min(graphemeStart, graphemeEnd);
+ int selectionEnd = Math.Max(graphemeStart, graphemeEnd);
+ int rectangleCount = CountSelectionBounds(lines, graphemes, selectionStart, selectionEnd);
+ if (rectangleCount == 0)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ FontRectangle[] result = new FontRectangle[rectangleCount];
+ int count = 0;
+ bool isHorizontal = layoutMode.IsHorizontal();
+
+ for (int i = 0; i < lines.Length; i++)
+ {
+ LineMetrics line = lines[i];
+ int graphemeOffset = GetGraphemeOffset(line);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
+ if (CountSelectionBoundsLine(lineGraphemes, selectionStart, selectionEnd) == 0)
+ {
+ continue;
+ }
+
+ count += FillSelectionBoundsLine(line, lineGraphemes, selectionStart, selectionEnd, isHorizontal, result.AsSpan(count));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Gets selection rectangles for one laid-out line.
+ ///
+ /// The single line for which selection rectangles are produced.
+ /// The line-local grapheme metrics scanned in visual order.
+ /// The first source grapheme insertion boundary applied to this line.
+ /// The final source grapheme insertion boundary applied to this line.
+ /// The orientation used to map the selected run onto the line box.
+ /// A read-only memory region containing the line selection rectangles in visual order.
+ public static ReadOnlyMemory GetSelectionBoundsLine(
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ int graphemeStart,
+ int graphemeEnd,
+ LayoutMode layoutMode)
+ {
+ if (graphemes.IsEmpty || graphemeStart == graphemeEnd)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ int selectionStart = Math.Min(graphemeStart, graphemeEnd);
+ int selectionEnd = Math.Max(graphemeStart, graphemeEnd);
+ int count = CountSelectionBoundsLine(graphemes, selectionStart, selectionEnd);
+ if (count == 0)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ FontRectangle[] result = new FontRectangle[count];
+ _ = FillSelectionBoundsLine(line, graphemes, selectionStart, selectionEnd, layoutMode.IsHorizontal(), result);
+ return result;
+ }
+
+ ///
+ /// Gets selection bounds for one measured grapheme.
+ ///
+ /// The visual lines used to find the grapheme's line box.
+ /// The flattened grapheme metrics that back the full text box.
+ /// The measured grapheme to select.
+ /// The orientation used to map the grapheme advance onto the line box.
+ /// A read-only memory region containing the grapheme selection bounds.
+ public static ReadOnlyMemory GetSelectionBounds(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ in GraphemeMetrics grapheme,
+ LayoutMode layoutMode)
+ {
+ if (lines.IsEmpty || graphemes.IsEmpty)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ int lineIndex = FindLineByGraphemeIndex(lines, grapheme.GraphemeIndex);
+ FontRectangle[] result = [CreateSelectionBounds(lines[lineIndex], grapheme, layoutMode.IsHorizontal())];
+ return result;
+ }
+
+ ///
+ /// Gets selection bounds for one measured grapheme within one laid-out line.
+ ///
+ /// The line that provides the cross-axis selection extent.
+ /// The measured grapheme to select.
+ /// The orientation used to map the grapheme advance onto the line box.
+ /// A read-only memory region containing the grapheme selection bounds.
+ public static ReadOnlyMemory GetSelectionBoundsLine(
+ in LineMetrics line,
+ in GraphemeMetrics grapheme,
+ LayoutMode layoutMode)
+ {
+ FontRectangle[] result = [CreateSelectionBounds(line, grapheme, layoutMode.IsHorizontal())];
+ return result;
+ }
+
+ ///
+ /// Finds the visual line nearest to a point.
+ ///
+ /// The candidate visual lines to compare with the point.
+ /// The coordinate whose cross-axis position selects the nearest line.
+ /// Indicates whether line advances are measured along the x-axis.
+ /// The nearest line index.
+ private static int FindLine(
+ ReadOnlySpan lines,
+ Vector2 point,
+ bool isHorizontal)
+ {
+ float cross = isHorizontal ? point.Y : point.X;
+ for (int i = 0; i < lines.Length; i++)
+ {
+ float lineStart = isHorizontal ? lines[i].Start.Y : lines[i].Start.X;
+ float lineEnd = isHorizontal ? lines[i].Start.Y + lines[i].Extent.Y : lines[i].Start.X + lines[i].Extent.X;
+ if (cross >= lineStart && cross < lineEnd)
+ {
+ return i;
+ }
+ }
+
+ float lineFirstStart = isHorizontal ? lines[0].Start.Y : lines[0].Start.X;
+ return cross < lineFirstStart ? 0 : lines.Length - 1;
+ }
+
+ ///
+ /// Finds the line that owns the supplied grapheme index.
+ ///
+ /// The visual lines whose source ranges are searched.
+ /// The source grapheme index to locate.
+ /// The nearest owning line index.
+ private static int FindLineByGraphemeIndex(
+ ReadOnlySpan lines,
+ int graphemeIndex)
+ {
+ for (int i = 0; i < lines.Length; i++)
+ {
+ LineMetrics line = lines[i];
+ int lineStart = line.GraphemeIndex;
+ int lineEnd = lineStart + line.GraphemeCount;
+ if (graphemeIndex >= lineStart && graphemeIndex <= lineEnd)
+ {
+ return i;
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Hit tests a point against one laid-out line after the layout mode has been normalized.
+ ///
+ /// The zero-based visual index of the normalized line.
+ /// The grapheme metrics already isolated for that line.
+ /// The coordinate to compare with each grapheme advance rectangle.
+ /// Indicates whether the primary hit-test axis is horizontal.
+ /// The nearest grapheme hit.
+ private static TextHit HitTestLine(
+ int lineIndex,
+ ReadOnlySpan graphemes,
+ Vector2 point,
+ bool isHorizontal)
+ {
+ int index = FindNearestGrapheme(graphemes, isHorizontal ? point.X : point.Y, isHorizontal);
+ GraphemeMetrics grapheme = graphemes[index];
+ FontRectangle advance = grapheme.Advance;
+ float midpoint = isHorizontal
+ ? advance.Left + (advance.Width * 0.5F)
+ : advance.Top + (advance.Height * 0.5F);
+ float primary = isHorizontal ? point.X : point.Y;
+ bool trailing = IsRightToLeft(grapheme)
+ ? primary < midpoint
+ : primary >= midpoint;
+
+ return new(lineIndex, grapheme.GraphemeIndex, grapheme.StringIndex, trailing);
+ }
+
+ ///
+ /// Creates a caret line for a grapheme insertion index.
+ ///
+ /// The zero-based visual index of the caret's line.
+ /// The line metrics used to size the caret segment.
+ /// The line-local grapheme metrics searched for neighboring edges.
+ /// The logical insertion position to materialize as a caret.
+ /// Indicates whether the caret spans vertically or horizontally.
+ /// The caret position in pixel units.
+ private static CaretPosition CreateCaret(
+ int lineIndex,
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ int graphemeIndex,
+ bool isHorizontal)
+ {
+ int previousIndex = FindGraphemeBySourceIndex(graphemes, graphemeIndex - 1);
+ int nextIndex = FindGraphemeBySourceIndex(graphemes, graphemeIndex);
+
+ if (nextIndex < 0 && previousIndex < 0)
+ {
+ int nearestIndex = FindNearestGraphemeIndex(graphemes, graphemeIndex);
+ GraphemeMetrics nearest = graphemes[nearestIndex];
+ bool trailing = graphemeIndex > nearest.GraphemeIndex;
+ CreateCaretEdge(line, nearest, trailing, isHorizontal, out Vector2 start, out Vector2 end);
+
+ return new(
+ lineIndex,
+ graphemeIndex,
+ nearest.StringIndex,
+ start,
+ end,
+ false,
+ default,
+ default,
+ GetLineNavigationPosition(start, isHorizontal));
+ }
+
+ if (nextIndex >= 0)
+ {
+ GraphemeMetrics next = graphemes[nextIndex];
+ CreateCaretEdge(line, next, trailing: false, isHorizontal, out Vector2 start, out Vector2 end);
+
+ if (previousIndex >= 0)
+ {
+ GraphemeMetrics previous = graphemes[previousIndex];
+ CreateCaretEdge(line, previous, trailing: true, isHorizontal, out Vector2 secondaryStart, out Vector2 secondaryEnd);
+
+ // At a bidi boundary the same logical insertion point has one visual edge on
+ // each neighboring run. Return both instead of asking callers to choose affinity.
+ if (start != secondaryStart || end != secondaryEnd)
+ {
+ return new(
+ lineIndex,
+ graphemeIndex,
+ next.StringIndex,
+ start,
+ end,
+ true,
+ secondaryStart,
+ secondaryEnd,
+ GetLineNavigationPosition(start, isHorizontal));
+ }
+ }
+
+ return new(
+ lineIndex,
+ graphemeIndex,
+ next.StringIndex,
+ start,
+ end,
+ false,
+ default,
+ default,
+ GetLineNavigationPosition(start, isHorizontal));
+ }
+
+ GraphemeMetrics previousOnly = graphemes[previousIndex];
+
+ // Editor-mode hard breaks can create a blank visual line whose only source
+ // ownership is the preceding newline grapheme. A caret requested immediately
+ // after that grapheme should sit at the start of the blank line, not after
+ // the newline marker's trimmed layout box.
+ if (previousOnly.IsLineBreak && graphemeIndex == previousOnly.GraphemeIndex + 1)
+ {
+ Vector2 start;
+ Vector2 end;
+ if (isHorizontal)
+ {
+ float x = IsRightToLeft(previousOnly) ? line.Start.X + line.Extent.X : line.Start.X;
+ start = new Vector2(x, line.Start.Y);
+ end = new Vector2(x, line.Start.Y + line.Extent.Y);
+ }
+ else
+ {
+ float y = IsRightToLeft(previousOnly) ? line.Start.Y + line.Extent.Y : line.Start.Y;
+ start = new Vector2(line.Start.X, y);
+ end = new Vector2(line.Start.X + line.Extent.X, y);
+ }
+
+ // The newline grapheme gives the blank line source ownership, but the
+ // editable insertion point after Enter belongs at the new line start.
+ return new(
+ lineIndex,
+ graphemeIndex,
+ previousOnly.StringIndex,
+ start,
+ end,
+ false,
+ default,
+ default,
+ GetLineNavigationPosition(start, isHorizontal));
+ }
+
+ CreateCaretEdge(line, previousOnly, trailing: true, isHorizontal, out Vector2 primaryStart, out Vector2 primaryEnd);
+
+ return new(
+ lineIndex,
+ graphemeIndex,
+ previousOnly.StringIndex,
+ primaryStart,
+ primaryEnd,
+ false,
+ default,
+ default,
+ GetLineNavigationPosition(primaryStart, isHorizontal));
+ }
+
+ ///
+ /// Creates one visual caret edge for a grapheme.
+ ///
+ /// The containing line that defines the caret span.
+ /// The grapheme whose leading or trailing edge is used.
+ /// Specifies whether the logical trailing side should be chosen.
+ /// Indicates whether caret edges vary along the x-axis.
+ /// Receives the first endpoint of the caret segment.
+ /// Receives the second endpoint of the caret segment.
+ private static void CreateCaretEdge(
+ in LineMetrics line,
+ in GraphemeMetrics grapheme,
+ bool trailing,
+ bool isHorizontal,
+ out Vector2 start,
+ out Vector2 end)
+ {
+ FontRectangle advance = grapheme.Advance;
+ bool useEnd = IsRightToLeft(grapheme) ? !trailing : trailing;
+
+ if (isHorizontal)
+ {
+ // Bidi layout can produce negative advance widths. Left/Right are
+ // rectangle construction edges in that case, so choose the physical
+ // min/max x edge after logical leading/trailing has been resolved.
+ float physicalStart = MathF.Min(advance.Left, advance.Right);
+ float physicalEnd = MathF.Max(advance.Left, advance.Right);
+ float x = useEnd ? physicalEnd : physicalStart;
+
+ start = new Vector2(x, line.Start.Y);
+ end = new Vector2(x, line.Start.Y + line.Extent.Y);
+ return;
+ }
+
+ float physicalTop = MathF.Min(advance.Top, advance.Bottom);
+ float physicalBottom = MathF.Max(advance.Top, advance.Bottom);
+ float y = useEnd ? physicalBottom : physicalTop;
+
+ start = new Vector2(line.Start.X, y);
+ end = new Vector2(line.Start.X + line.Extent.X, y);
+ }
+
+ ///
+ /// Creates a caret at the source start or end boundary of a laid-out line.
+ ///
+ /// The zero-based visual index of the line.
+ /// The line metrics used to size the caret segment.
+ /// The line-local grapheme metrics in visual order.
+ /// The source boundary to place within the line.
+ /// Indicates whether the caret spans vertically or horizontally.
+ /// The resolved text direction used to choose the visual start or end of the scope.
+ /// The caret position at the requested line boundary.
+ private static CaretPosition CreateCaretAtVisualLineEdge(
+ int lineIndex,
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ CaretPlacement placement,
+ bool isHorizontal,
+ TextDirection direction)
+ {
+ bool isStart = placement == CaretPlacement.Start;
+ int insertionIndex = isStart ? GetSourceLineStart(graphemes) : GetSourceLineEnd(graphemes);
+ int visualIndex = FindGraphemeBySourceIndex(graphemes, isStart ? insertionIndex : insertionIndex - 1);
+ GraphemeMetrics grapheme = graphemes[visualIndex];
+ bool isRightToLeft = direction == TextDirection.RightToLeft;
+ bool useEnd = isStart == isRightToLeft;
+
+ // Start/end placement is anchored to the source boundary grapheme for
+ // the returned insertion index, but the visible caret sits on the line
+ // box edge. The resolved paragraph direction chooses which physical
+ // line edge represents start or end.
+ Vector2 start;
+ Vector2 end;
+ if (isHorizontal)
+ {
+ float x = useEnd ? line.Start.X + line.Extent.X : line.Start.X;
+ start = new Vector2(x, line.Start.Y);
+ end = new Vector2(x, line.Start.Y + line.Extent.Y);
+ }
+ else
+ {
+ float y = useEnd ? line.Start.Y + line.Extent.Y : line.Start.Y;
+ start = new Vector2(line.Start.X, y);
+ end = new Vector2(line.Start.X + line.Extent.X, y);
+ }
+
+ return new(
+ lineIndex,
+ insertionIndex,
+ grapheme.StringIndex,
+ start,
+ end,
+ false,
+ default,
+ default,
+ GetLineNavigationPosition(start, isHorizontal));
+ }
+
+ ///
+ /// Moves the caret to the nearest matching position on an adjacent visual line.
+ ///
+ /// The set of visual lines available for adjacent-line navigation.
+ /// The flattened grapheme metrics used to resolve the new caret target.
+ /// The caret location before moving to the neighbor line.
+ /// The visual index of the line that currently contains the caret.
+ /// Specifies whether movement is toward the next visual line.
+ /// Indicates whether preserved column data uses the x-axis.
+ /// The orientation used when reconstructing the destination caret.
+ /// The moved caret position in pixel units.
+ private static CaretPosition MoveCaretToAdjacentLine(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ CaretPosition caret,
+ int lineIndex,
+ bool lineDown,
+ bool isHorizontal,
+ LayoutMode layoutMode)
+ {
+ int targetLineIndex = FindAdjacentLine(lines, lineIndex, lineDown, isHorizontal);
+ if (targetLineIndex == lineIndex)
+ {
+ return caret;
+ }
+
+ LineMetrics targetLine = lines[targetLineIndex];
+ int graphemeOffset = GetGraphemeOffset(targetLine);
+ ReadOnlySpan targetGraphemes = graphemes.Slice(graphemeOffset, targetLine.GraphemeCount);
+
+ Vector2 hitPoint = isHorizontal
+ ? new(caret.LineNavigationPosition, targetLine.Start.Y + (targetLine.Extent.Y * 0.5F))
+ : new(targetLine.Start.X + (targetLine.Extent.X * 0.5F), caret.LineNavigationPosition);
+
+ TextHit hit = HitTestLineForCaretNavigation(targetLineIndex, targetGraphemes, hitPoint, isHorizontal);
+ CaretPosition moved = GetCaretPositionLine(
+ targetLineIndex,
+ targetLine,
+ targetGraphemes,
+ hit.GraphemeInsertionIndex,
+ layoutMode);
+
+ // Preserve the original requested line position so repeated LineUp/LineDown movement
+ // returns to the same visual column after passing through shorter lines.
+ return WithLineNavigationPosition(moved, caret.LineNavigationPosition);
+ }
+
+ ///
+ /// Hit tests a line for keyboard caret navigation.
+ ///
+ /// The zero-based visual index of the line being navigated.
+ /// The line-local grapheme metrics considered as navigation targets.
+ /// The projected point used to preserve visual column alignment.
+ /// Indicates whether navigation compares x coordinates first.
+ /// The nearest grapheme hit.
+ private static TextHit HitTestLineForCaretNavigation(
+ int lineIndex,
+ ReadOnlySpan graphemes,
+ Vector2 point,
+ bool isHorizontal)
+ {
+ int index = FindNearestCaretNavigationGrapheme(graphemes, isHorizontal ? point.X : point.Y, isHorizontal);
+ GraphemeMetrics grapheme = graphemes[index];
+ FontRectangle advance = grapheme.Advance;
+ float midpoint = isHorizontal
+ ? advance.Left + (advance.Width * 0.5F)
+ : advance.Top + (advance.Height * 0.5F);
+ float primary = isHorizontal ? point.X : point.Y;
+ bool trailing = IsRightToLeft(grapheme)
+ ? primary < midpoint
+ : primary >= midpoint;
+
+ return new(lineIndex, grapheme.GraphemeIndex, grapheme.StringIndex, trailing);
+ }
+
+ ///
+ /// Finds the nearest grapheme that should participate in keyboard caret navigation.
+ ///
+ /// The visual-order graphemes filtered for caret navigation.
+ /// The coordinate on the primary advance axis to compare.
+ /// Indicates whether the primary axis maps to horizontal movement.
+ /// The nearest grapheme metrics index within .
+ private static int FindNearestCaretNavigationGrapheme(
+ ReadOnlySpan graphemes,
+ float primary,
+ bool isHorizontal)
+ {
+ int first = -1;
+ int last = -1;
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ first = first < 0 ? i : first;
+ last = i;
+
+ FontRectangle advance = graphemes[i].Advance;
+ float start = isHorizontal ? advance.Left : advance.Top;
+ float end = isHorizontal ? advance.Right : advance.Bottom;
+ if (primary >= start && primary < end)
+ {
+ return i;
+ }
+ }
+
+ FontRectangle firstAdvance = graphemes[first].Advance;
+ float firstStart = isHorizontal ? firstAdvance.Left : firstAdvance.Top;
+ return primary < firstStart ? first : last;
+ }
+
+ ///
+ /// Finds the adjacent visual line in the requested direction.
+ ///
+ /// The visual lines among which an adjacent line is searched.
+ /// The current visual line index.
+ /// Specifies whether the search moves forward in visual order.
+ /// Indicates whether cross-axis distances are measured vertically.
+ /// The adjacent line index, or when no line exists in that direction.
+ private static int FindAdjacentLine(
+ ReadOnlySpan lines,
+ int lineIndex,
+ bool lineDown,
+ bool isHorizontal)
+ {
+ float currentStart = GetLineCrossStart(lines[lineIndex], isHorizontal);
+ float currentEnd = GetLineCrossEnd(lines[lineIndex], isHorizontal);
+ int targetLineIndex = lineIndex;
+ float bestDistance = float.MaxValue;
+ for (int i = 0; i < lines.Length; i++)
+ {
+ if (i == lineIndex)
+ {
+ continue;
+ }
+
+ float distance = lineDown
+ ? GetLineCrossStart(lines[i], isHorizontal) - currentEnd
+ : currentStart - GetLineCrossEnd(lines[i], isHorizontal);
+
+ if (distance >= 0 && distance < bestDistance)
+ {
+ targetLineIndex = i;
+ bestDistance = distance;
+ }
+ }
+
+ return targetLineIndex;
+ }
+
+ ///
+ /// Gets a valid line index for the supplied caret.
+ ///
+ /// The laid-out lines used to validate the caret's stored line index.
+ /// The flattened grapheme metrics used to resolve the caret when its line index is stale.
+ /// The caret whose associated visual line must be resolved.
+ /// The line index.
+ private static int GetCaretLineIndex(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ in CaretPosition caret)
+ {
+ if ((uint)caret.LineIndex < (uint)lines.Length)
+ {
+ return caret.LineIndex;
+ }
+
+ return FindLineByGraphemeIndex(lines, caret.GraphemeIndex);
+ }
+
+ ///
+ /// Gets the nearest Unicode word boundary before the supplied grapheme insertion index.
+ ///
+ /// The source-order word metrics to search.
+ /// The grapheme insertion index to move from.
+ /// The minimum grapheme insertion index that can be returned.
+ /// The previous word boundary.
+ private static int GetPreviousWordBoundary(
+ ReadOnlySpan wordMetrics,
+ int graphemeIndex,
+ int limit)
+ {
+ int target = limit;
+ for (int i = 0; i < wordMetrics.Length; i++)
+ {
+ WordMetrics metrics = wordMetrics[i];
+ if (metrics.GraphemeStart >= graphemeIndex)
+ {
+ break;
+ }
+
+ target = Math.Max(target, metrics.GraphemeStart);
+ if (metrics.GraphemeEnd < graphemeIndex)
+ {
+ target = Math.Max(target, metrics.GraphemeEnd);
+ }
+ }
+
+ return target;
+ }
+
+ ///
+ /// Gets the nearest Unicode word boundary after the supplied grapheme insertion index.
+ ///
+ /// The source-order word metrics to search.
+ /// The grapheme insertion index to move from.
+ /// The maximum grapheme insertion index that can be returned.
+ /// The next word boundary.
+ private static int GetNextWordBoundary(
+ ReadOnlySpan wordMetrics,
+ int graphemeIndex,
+ int limit)
+ {
+ for (int i = 0; i < wordMetrics.Length; i++)
+ {
+ WordMetrics metrics = wordMetrics[i];
+ if (metrics.GraphemeStart > graphemeIndex)
+ {
+ return Math.Min(limit, metrics.GraphemeStart);
+ }
+
+ if (metrics.GraphemeEnd > graphemeIndex)
+ {
+ return Math.Min(limit, metrics.GraphemeEnd);
+ }
+ }
+
+ return limit;
+ }
+
+ ///
+ /// Gets the previous measured grapheme insertion index.
+ ///
+ /// The grapheme metrics that define valid caret stops.
+ /// The caret insertion index to move from.
+ /// The minimum grapheme insertion index that can be returned.
+ /// The previous measured grapheme insertion index.
+ private static int GetPreviousInsertionIndex(
+ ReadOnlySpan graphemes,
+ int graphemeIndex,
+ int limit)
+ {
+ int target = limit;
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ int start = graphemes[i].GraphemeIndex;
+ if (start < graphemeIndex)
+ {
+ target = Math.Max(target, start);
+ }
+
+ // The trailing boundary is derived only from an actual measured grapheme.
+ // This avoids walking through sparse source indices left by trimmed text.
+ int end = start + 1;
+ if (end < graphemeIndex)
+ {
+ target = Math.Max(target, end);
+ }
+ }
+
+ return target;
+ }
+
+ ///
+ /// Gets the next measured grapheme insertion index.
+ ///
+ /// The grapheme metrics that define valid caret stops.
+ /// The caret insertion index to move from.
+ /// The maximum grapheme insertion index that can be returned.
+ /// The next measured grapheme insertion index.
+ private static int GetNextInsertionIndex(
+ ReadOnlySpan graphemes,
+ int graphemeIndex,
+ int limit)
+ {
+ int target = limit;
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ int start = graphemes[i].GraphemeIndex;
+ if (start > graphemeIndex)
+ {
+ target = Math.Min(target, start);
+ }
+
+ // The trailing boundary is derived only from an actual measured grapheme.
+ // This avoids walking through sparse source indices left by trimmed text.
+ int end = start + 1;
+ if (end > graphemeIndex)
+ {
+ target = Math.Min(target, end);
+ }
+ }
+
+ return target;
+ }
+
+ ///
+ /// Gets the first source grapheme insertion index in the laid-out text.
+ ///
+ /// The laid-out grapheme metrics searched for the earliest source insertion point.
+ /// The source text start insertion index.
+ private static int GetSourceTextStart(ReadOnlySpan graphemes)
+ {
+ int start = graphemes[0].GraphemeIndex;
+ for (int i = 1; i < graphemes.Length; i++)
+ {
+ start = Math.Min(start, graphemes[i].GraphemeIndex);
+ }
+
+ return start;
+ }
+
+ ///
+ /// Gets the final source grapheme insertion index in the laid-out text.
+ ///
+ /// The laid-out grapheme metrics searched for the final source insertion point.
+ /// The source text end insertion index.
+ private static int GetSourceTextEnd(ReadOnlySpan graphemes)
+ {
+ int end = graphemes[0].GraphemeIndex + 1;
+ for (int i = 1; i < graphemes.Length; i++)
+ {
+ end = Math.Max(end, graphemes[i].GraphemeIndex + 1);
+ }
+
+ return end;
+ }
+
+ ///
+ /// Gets the first source grapheme insertion index for a line.
+ ///
+ /// The line-local grapheme metrics.
+ /// The source line start insertion index.
+ private static int GetSourceLineStart(ReadOnlySpan graphemes)
+ {
+ int start = graphemes[0].GraphemeIndex;
+ for (int i = 1; i < graphemes.Length; i++)
+ {
+ start = Math.Min(start, graphemes[i].GraphemeIndex);
+ }
+
+ return start;
+ }
+
+ ///
+ /// Gets the final source grapheme insertion index for a line.
+ ///
+ /// The line-local grapheme metrics.
+ /// The source line end insertion index.
+ private static int GetSourceLineEnd(ReadOnlySpan graphemes)
+ {
+ int end = graphemes[0].GraphemeIndex + 1;
+ for (int i = 1; i < graphemes.Length; i++)
+ {
+ end = Math.Max(end, graphemes[i].GraphemeIndex + 1);
+ }
+
+ return end;
+ }
+
+ ///
+ /// Gets the cross-axis start of a line.
+ ///
+ /// The line whose cross-axis origin is requested.
+ /// Indicates whether the cross axis corresponds to y coordinates.
+ /// The cross-axis start.
+ private static float GetLineCrossStart(in LineMetrics line, bool isHorizontal)
+ => isHorizontal ? line.Start.Y : line.Start.X;
+
+ ///
+ /// Gets the cross-axis end of a line.
+ ///
+ /// The line whose cross-axis limit is requested.
+ /// Indicates whether the cross axis corresponds to y coordinates.
+ /// The cross-axis end.
+ private static float GetLineCrossEnd(in LineMetrics line, bool isHorizontal)
+ => isHorizontal ? line.Start.Y + line.Extent.Y : line.Start.X + line.Extent.X;
+
+ ///
+ /// Gets the coordinate to preserve for repeated visual line movement.
+ ///
+ /// The primary caret endpoint used to preserve visual column movement.
+ /// Indicates whether the preserved coordinate is taken from x.
+ /// The line navigation position.
+ private static float GetLineNavigationPosition(Vector2 start, bool isHorizontal)
+ => isHorizontal ? start.X : start.Y;
+
+ ///
+ /// Creates a copy of the caret with a specific preserved line navigation position.
+ ///
+ /// The caret value to clone with updated navigation metadata.
+ /// The preserved visual column or row coordinate.
+ /// The caret position.
+ private static CaretPosition WithLineNavigationPosition(
+ in CaretPosition caret,
+ float lineNavigationPosition)
+ => new(
+ caret.LineIndex,
+ caret.GraphemeIndex,
+ caret.StringIndex,
+ caret.Start,
+ caret.End,
+ caret.HasSecondary,
+ caret.SecondaryStart,
+ caret.SecondaryEnd,
+ lineNavigationPosition);
+
+ ///
+ /// Fills one line's selection rectangles from visually contiguous selected grapheme advances.
+ ///
+ /// The line that will receive one or more selection rectangles.
+ /// The line-local grapheme metrics grouped into visual runs.
+ /// The first source grapheme insertion boundary in the selected range.
+ /// The final source grapheme insertion boundary in the selected range.
+ /// Indicates whether rectangles expand primarily along x.
+ /// The destination span that receives the generated rectangles.
+ /// The number of selection rectangles written.
+ private static int FillSelectionBoundsLine(
+ in LineMetrics line,
+ ReadOnlySpan graphemes,
+ int selectionStart,
+ int selectionEnd,
+ bool isHorizontal,
+ Span result)
+ {
+ int count = 0;
+ bool hasSelection = false;
+ float start = 0;
+ float end = 0;
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ GraphemeMetrics grapheme = graphemes[i];
+
+ // Selections are caret boundary ranges: [start, end). A grapheme is selected
+ // when its source start sits inside that boundary span.
+ int graphemeStart = grapheme.GraphemeIndex;
+ bool isSelected = graphemeStart >= selectionStart && graphemeStart < selectionEnd;
+ if (!isSelected)
+ {
+ // A logical range can be visually discontinuous after bidi reordering. Flush at
+ // the first unselected visual grapheme so selection never covers that gap.
+ if (hasSelection)
+ {
+ result[count++] = CreateSelectionBounds(line, start, end, isHorizontal);
+ hasSelection = false;
+ }
+
+ continue;
+ }
+
+ FontRectangle advance = grapheme.Advance;
+ float currentStart = isHorizontal ? advance.Left : advance.Top;
+ float currentEnd = isHorizontal ? advance.Right : advance.Bottom;
+ if (!hasSelection)
+ {
+ start = currentStart;
+ end = currentEnd;
+ hasSelection = true;
+ continue;
+ }
+
+ start = Math.Min(start, currentStart);
+ end = Math.Max(end, currentEnd);
+ }
+
+ if (hasSelection)
+ {
+ result[count++] = CreateSelectionBounds(line, start, end, isHorizontal);
+ }
+
+ return count;
+ }
+
+ ///
+ /// Creates a selection rectangle for a contiguous visual run.
+ ///
+ /// The containing line used to fill the rectangle on the secondary axis.
+ /// The first selected coordinate along the primary layout axis.
+ /// The last selected coordinate along the primary layout axis.
+ /// Indicates whether the primary axis runs left to right.
+ /// The selection rectangle in pixel units.
+ private static FontRectangle CreateSelectionBounds(
+ in LineMetrics line,
+ float start,
+ float end,
+ bool isHorizontal)
+ =>
+ isHorizontal
+ ? FontRectangle.FromLTRB(start, line.Start.Y, end, line.Start.Y + line.Extent.Y)
+ : FontRectangle.FromLTRB(line.Start.X, start, line.Start.X + line.Extent.X, end);
+
+ ///
+ /// Creates a selection rectangle for one measured grapheme.
+ ///
+ /// The containing line used to fill the rectangle on the secondary axis.
+ /// The grapheme whose advance defines the primary-axis selection extent.
+ /// Indicates whether the primary axis runs left to right.
+ /// The selection rectangle in pixel units.
+ private static FontRectangle CreateSelectionBounds(
+ in LineMetrics line,
+ in GraphemeMetrics grapheme,
+ bool isHorizontal)
+ {
+ FontRectangle advance = grapheme.Advance;
+ float start = isHorizontal ? advance.Left : advance.Top;
+ float end = isHorizontal ? advance.Right : advance.Bottom;
+ return CreateSelectionBounds(line, start, end, isHorizontal);
+ }
+
+ ///
+ /// Counts how many selection rectangles are required for a grapheme range.
+ ///
+ /// The visual lines searched for selected graphemes.
+ /// The flattened grapheme metrics used to count visual runs.
+ /// The first source grapheme insertion boundary used for counting.
+ /// The final source grapheme insertion boundary used for counting.
+ /// The number of selection rectangles.
+ private static int CountSelectionBounds(
+ ReadOnlySpan lines,
+ ReadOnlySpan graphemes,
+ int selectionStart,
+ int selectionEnd)
+ {
+ int count = 0;
+ for (int i = 0; i < lines.Length; i++)
+ {
+ LineMetrics line = lines[i];
+ int graphemeOffset = GetGraphemeOffset(line);
+ ReadOnlySpan lineGraphemes = graphemes.Slice(graphemeOffset, line.GraphemeCount);
+
+ // Source grapheme indices can have gaps because trailing whitespace is trimmed.
+ // Count actual measured graphemes instead of deriving a dense range from the line.
+ count += CountSelectionBoundsLine(lineGraphemes, selectionStart, selectionEnd);
+ }
+
+ return count;
+ }
+
+ ///
+ /// Counts visually contiguous selected grapheme runs in one line.
+ ///
+ /// The visual-order grapheme metrics for the current line.
+ /// The first source grapheme insertion boundary applied to that line.
+ /// The final source grapheme insertion boundary applied to that line.
+ /// The number of selected visual runs.
+ private static int CountSelectionBoundsLine(
+ ReadOnlySpan graphemes,
+ int selectionStart,
+ int selectionEnd)
+ {
+ int count = 0;
+ bool hasSelection = false;
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ GraphemeMetrics grapheme = graphemes[i];
+
+ // Selections are caret boundary ranges: [start, end). A grapheme is selected
+ // when its source start sits inside that boundary span.
+ int graphemeStart = grapheme.GraphemeIndex;
+ bool isSelected = graphemeStart >= selectionStart && graphemeStart < selectionEnd;
+
+ if (!isSelected)
+ {
+ hasSelection = false;
+ continue;
+ }
+
+ if (!hasSelection)
+ {
+ count++;
+ hasSelection = true;
+ }
+ }
+
+ return count;
+ }
+
+ ///
+ /// Finds the grapheme whose advance contains the primary coordinate, or the nearest edge grapheme.
+ ///
+ /// The visual-order grapheme metrics searched for a hit target.
+ /// The coordinate along the primary layout axis.
+ /// Indicates whether the primary axis is horizontal.
+ /// The nearest grapheme metrics index within .
+ private static int FindNearestGrapheme(ReadOnlySpan graphemes, float primary, bool isHorizontal)
+ {
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ FontRectangle advance = graphemes[i].Advance;
+ float start = isHorizontal ? advance.Left : advance.Top;
+ float end = isHorizontal ? advance.Right : advance.Bottom;
+ if (primary >= start && primary < end)
+ {
+ return i;
+ }
+ }
+
+ FontRectangle first = graphemes[0].Advance;
+ float firstStart = isHorizontal ? first.Left : first.Top;
+ return primary < firstStart ? 0 : graphemes.Length - 1;
+ }
+
+ ///
+ /// Finds the metrics entry for a source grapheme index within one visual line.
+ ///
+ /// The visual-order grapheme metrics belonging to one line.
+ /// The logical grapheme index to look up directly.
+ /// The grapheme metrics index, or -1 when the grapheme is not in the line.
+ private static int FindGraphemeBySourceIndex(ReadOnlySpan graphemes, int graphemeIndex)
+ {
+ for (int i = 0; i < graphemes.Length; i++)
+ {
+ if (graphemes[i].GraphemeIndex == graphemeIndex)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Finds the nearest metrics entry for a source grapheme index within one visual line.
+ ///
+ /// The visual-order grapheme metrics used for nearest-index matching.
+ /// The logical grapheme index whose closest visual entry is needed.
+ /// The nearest grapheme metrics index within .
+ private static int FindNearestGraphemeIndex(ReadOnlySpan graphemes, int graphemeIndex)
+ {
+ int nearest = 0;
+ int distance = Math.Abs(graphemes[0].GraphemeIndex - graphemeIndex);
+ for (int i = 1; i < graphemes.Length; i++)
+ {
+ int currentDistance = Math.Abs(graphemes[i].GraphemeIndex - graphemeIndex);
+ if (currentDistance < distance)
+ {
+ nearest = i;
+ distance = currentDistance;
+ }
+ }
+
+ return nearest;
+ }
+
+ ///
+ /// Gets a value indicating whether the grapheme advances right-to-left in source order.
+ ///
+ /// The grapheme whose resolved bidi level is inspected.
+ /// when the resolved bidi level is odd.
+ private static bool IsRightToLeft(in GraphemeMetrics grapheme)
+ => (grapheme.BidiLevel & 1) != 0;
+
+ ///
+ /// Gets the offset of a line's graphemes within the flattened metrics array.
+ ///
+ /// The line whose stored grapheme offset identifies the desired slice.
+ /// The flattened grapheme metrics offset.
+ private static int GetGraphemeOffset(in LineMetrics line)
+ => line.GraphemeOffset;
+}
diff --git a/src/SixLabors.Fonts/TextInteractionMode.cs b/src/SixLabors.Fonts/TextInteractionMode.cs
new file mode 100644
index 00000000..5f01fdd8
--- /dev/null
+++ b/src/SixLabors.Fonts/TextInteractionMode.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.Fonts;
+
+///
+/// Specifies how text interaction positions are modeled for laid-out text.
+///
+public enum TextInteractionMode
+{
+ ///
+ /// Uses paragraph-style interaction where trailing breaking whitespace at line ends does not create additional caret stops.
+ ///
+ Paragraph,
+
+ ///
+ /// Uses editor-style interaction where ordinary trailing breaking whitespace at line ends remains addressable by caret movement and selection.
+ ///
+ Editor
+}
diff --git a/src/SixLabors.Fonts/TextLayout.LineBreaking.cs b/src/SixLabors.Fonts/TextLayout.LineBreaking.cs
index 0476f56c..b6344041 100644
--- a/src/SixLabors.Fonts/TextLayout.LineBreaking.cs
+++ b/src/SixLabors.Fonts/TextLayout.LineBreaking.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System.Numerics;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts;
@@ -10,462 +11,476 @@ namespace SixLabors.Fonts;
///
internal static partial class TextLayout
{
+ private const int SoftHyphen = 0x00AD;
+ private const int StandardHyphen = 0x2010;
+ private const int StandardEllipsis = 0x2026;
+
///
- /// Assembles shaped glyphs into instances, applying line-break
- /// opportunities derived from and the configured
- /// / settings.
- /// Finalizes each line (trimming trailing whitespace and applying bidi reordering) and applies
- /// justification where requested.
+ /// Composes the logical from shaped glyph data before width-dependent line breaking.
///
+ /// The width-independent shaping state.
/// The original source text.
/// The text shaping and layout options.
- /// The resolved bidi runs covering the whole input.
- /// The code point to bidi-run mapping built during shaping.
- /// The GPOS positioning collection containing the shaped entries.
- /// The active layout mode (drives horizontal vs vertical metrics selection).
- /// The shaped, line-broken, finalized text box ready for glyph placement.
- private static TextBox BreakLines(
+ /// The logical text line and line break opportunities before line breaking.
+ public static LogicalTextLine ComposeLogicalLine(
+ in ShapedText shapedText,
ReadOnlySpan text,
- TextOptions options,
- BidiRun[] bidiRuns,
- Dictionary bidiMap,
- GlyphPositioningCollection positionings,
- LayoutMode layoutMode)
+ TextOptions options)
{
- bool shouldWrap = options.WrappingLength > 0;
-
- // Wrapping length is always provided in pixels. Convert to inches for comparison.
- float wrappingLength = shouldWrap ? options.WrappingLength / options.Dpi : float.MaxValue;
- bool breakAll = options.WordBreaking == WordBreaking.BreakAll;
- bool keepAll = options.WordBreaking == WordBreaking.KeepAll;
- bool breakWord = options.WordBreaking == WordBreaking.BreakWord;
- bool isHorizontalLayout = layoutMode.IsHorizontal();
- bool isVerticalLayout = layoutMode.IsVertical();
- bool isVerticalMixedLayout = layoutMode.IsVerticalMixed();
+ bool isHorizontalLayout = shapedText.LayoutMode.IsHorizontal();
+ bool isVerticalLayout = shapedText.LayoutMode.IsVertical();
+ bool isVerticalMixedLayout = shapedText.LayoutMode.IsVerticalMixed();
- int graphemeIndex;
+ int graphemeIndex = 0;
int codePointIndex = 0;
int glyphSearchIndex = 0;
- List textLines = [];
TextLine textLine = new();
int stringIndex = 0;
+ List wordSegments = [];
+ List hyphenationMarkers = [];
+ CodePoint? hyphenationMarkerCodePoint = GetHyphenationMarkerCodePoint(options);
// No glyph should contain more than 64 metrics.
// We do a sanity check below just in case.
Span decomposedAdvancesBuffer = stackalloc float[64];
- // Enumerate through each grapheme in the text.
- SpanGraphemeEnumerator graphemeEnumerator = new(text);
- for (graphemeIndex = 0; graphemeEnumerator.MoveNext(); graphemeIndex++)
+ // Word-boundary segments are prepared with the logical line, while grapheme
+ // and codepoint enumeration still own shaping data creation.
+ SpanWordEnumerator wordEnumerator = new(text);
+ while (wordEnumerator.MoveNext())
{
- // Now enumerate through each codepoint in the grapheme.
- ReadOnlySpan grapheme = graphemeEnumerator.Current.Span;
- int graphemeCodePointIndex = 0;
- SpanCodePointEnumerator codePointEnumerator = new(grapheme);
- while (codePointEnumerator.MoveNext())
+ WordSegment wordSegment = wordEnumerator.Current;
+ int wordSegmentGraphemeStart = graphemeIndex;
+
+ SpanGraphemeEnumerator graphemeEnumerator = new(wordSegment.Span);
+ while (graphemeEnumerator.MoveNext())
{
- if (!positionings.TryGetGlyphMetricsAtOffset(
- codePointIndex,
- ref glyphSearchIndex,
- out float pointSize,
- out bool isSubstituted,
- out bool isVerticalSubstitution,
- out bool isDecomposed,
- out IReadOnlyList? metrics))
+ // Now enumerate through each codepoint in the grapheme.
+ ReadOnlySpan grapheme = graphemeEnumerator.Current.Span;
+ int graphemeCodePointIndex = 0;
+ SpanCodePointEnumerator codePointEnumerator = new(grapheme);
+ while (codePointEnumerator.MoveNext())
{
- // Codepoint was skipped during original enumeration.
- codePointIndex++;
- graphemeCodePointIndex++;
- continue;
- }
+ if (!shapedText.Positionings.TryGetGlyphMetricsAtOffset(
+ codePointIndex,
+ ref glyphSearchIndex,
+ out float pointSize,
+ out bool isSubstituted,
+ out bool isVerticalSubstitution,
+ out bool isDecomposed,
+ out IReadOnlyList? glyphData))
+ {
+ // Codepoint was skipped during original enumeration.
+ codePointIndex++;
+ graphemeCodePointIndex++;
+ continue;
+ }
- GlyphMetrics glyph = metrics[0];
-
- // Retrieve the current codepoint from the enumerator.
- // If the glyph represents a substituted codepoint and the substitution is a single codepoint substitution,
- // or composite glyph, then the codepoint should be updated to the substitution value so we can read its properties.
- // Substitutions that are decomposed glyphs will have multiple metrics and any layout should be based on the
- // original codepoint.
- //
- // Note: Not all glyphs in a font will have a codepoint associated with them. e.g. most compositions, ligatures, etc.
- CodePoint codePoint = codePointEnumerator.Current;
- if (isSubstituted && metrics.Count == 1)
- {
- codePoint = glyph.CodePoint;
- }
+ List metrics = [];
+ for (int i = 0; i < glyphData.Count; i++)
+ {
+ GlyphPositioningCollection.GlyphPositioningData data = glyphData[i];
+ if (data.Data.IsPlaceholder)
+ {
+ textLine.AddPlaceholder(
+ data,
+ graphemeIndex,
+ stringIndex,
+ isHorizontalLayout,
+ isVerticalMixedLayout,
+ options.LineSpacing);
+
+ continue;
+ }
- // Determine whether the glyph advance should be calculated using vertical or horizontal metrics
- // For vertical mixed layout we will rotate glyphs with the vertical orientation type R or TR
- // which do not already have a vertical substitution.
- bool shouldRotate = isVerticalMixedLayout &&
- !isVerticalSubstitution &&
- CodePoint.GetVerticalOrientationType(codePoint) is
- VerticalOrientationType.Rotate or
- VerticalOrientationType.TransformRotate;
-
- // Determine whether the glyph advance should be offset for vertical layout.
- bool shouldOffset = isVerticalLayout &&
- !isVerticalSubstitution &&
- CodePoint.GetVerticalOrientationType(codePoint) is
- VerticalOrientationType.Rotate or
- VerticalOrientationType.TransformRotate;
-
- if (CodePoint.IsVariationSelector(codePoint))
- {
- codePointIndex++;
- graphemeCodePointIndex++;
- continue;
- }
+ metrics.Add(data.Metrics);
+ }
- // Calculate the advance for the current codepoint.
+ if (metrics.Count == 0)
+ {
+ // This source codepoint was skipped during shaping; any placeholder
+ // sharing the same source offset has already been added above.
+ codePointIndex++;
+ graphemeCodePointIndex++;
+ continue;
+ }
- // This should never happen, but we need to ensure that the buffer is large enough
- // if, for some crazy reason, a glyph does contain more than 64 metrics.
- Span decomposedAdvances = metrics.Count > decomposedAdvancesBuffer.Length
- ? new float[metrics.Count]
- : decomposedAdvancesBuffer[..(isDecomposed ? metrics.Count : 1)];
+ FontGlyphMetrics glyph = metrics[0];
+
+ // Retrieve the current codepoint from the enumerator.
+ // If the glyph represents a substituted codepoint and the substitution is a single codepoint substitution,
+ // or composite glyph, then the codepoint should be updated to the substitution value so we can read its properties.
+ // Substitutions that are decomposed glyphs will have multiple metrics and any layout should be based on the
+ // original codepoint.
+ //
+ // Note: Not all glyphs in a font will have a codepoint associated with them. e.g. most compositions, ligatures, etc.
+ CodePoint codePoint = codePointEnumerator.Current;
+ if (isSubstituted && metrics.Count == 1)
+ {
+ codePoint = glyph.CodePoint;
+ }
- float glyphAdvance;
- if (isHorizontalLayout || shouldRotate)
- {
- glyphAdvance = glyph.AdvanceWidth;
- }
- else
- {
- glyphAdvance = glyph.AdvanceHeight;
- }
+ // Determine whether the glyph advance should be calculated using vertical or horizontal metrics
+ // For vertical mixed layout we will rotate glyphs with the vertical orientation type R or TR
+ // which do not already have a vertical substitution.
+ bool shouldRotate = isVerticalMixedLayout &&
+ !isVerticalSubstitution &&
+ CodePoint.GetVerticalOrientationType(codePoint) is
+ VerticalOrientationType.Rotate or
+ VerticalOrientationType.TransformRotate;
+
+ // Determine whether the glyph advance should be offset for vertical layout.
+ bool shouldOffset = isVerticalLayout &&
+ !isVerticalSubstitution &&
+ CodePoint.GetVerticalOrientationType(codePoint) is
+ VerticalOrientationType.Rotate or
+ VerticalOrientationType.TransformRotate;
+
+ if (CodePoint.IsVariationSelector(codePoint))
+ {
+ codePointIndex++;
+ graphemeCodePointIndex++;
+ continue;
+ }
- decomposedAdvances[0] = glyphAdvance;
+ // Calculate the advance for the current codepoint.
- if (CodePoint.IsTabulation(codePoint))
- {
- if (options.TabWidth > -1F)
+ // This should never happen, but we need to ensure that the buffer is large enough
+ // if, for some crazy reason, a glyph does contain more than 64 metrics.
+ Span decomposedAdvances = metrics.Count > decomposedAdvancesBuffer.Length
+ ? new float[metrics.Count]
+ : decomposedAdvancesBuffer[..(isDecomposed ? metrics.Count : 1)];
+
+ float glyphAdvance;
+ if (isHorizontalLayout || shouldRotate)
{
- // Do not use the default font tab width. Instead find the advance for the space glyph
- // and multiply that by the options value.
- CodePoint space = new(0x0020);
- if (glyph.FontMetrics.TryGetGlyphId(space, out ushort spaceGlyphId))
- {
- GlyphMetrics spaceMetrics = glyph.FontMetrics.GetGlyphMetrics(
- space,
- spaceGlyphId,
- glyph.TextAttributes,
- glyph.TextDecorations,
- layoutMode,
- options.ColorFontSupport);
-
- if (isHorizontalLayout || shouldRotate)
- {
- glyphAdvance = spaceMetrics.AdvanceWidth * options.TabWidth;
- glyph.SetAdvanceWidth((ushort)glyphAdvance);
- }
- else
- {
- glyphAdvance = spaceMetrics.AdvanceHeight * options.TabWidth;
- glyph.SetAdvanceHeight((ushort)glyphAdvance);
- }
- }
+ glyphAdvance = glyph.AdvanceWidth;
}
- }
- 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
- // unless multiple metrics are associated with code point. In this case they are most likely the result
- // of a substitution and shouldn't be ignored.
- glyphAdvance = 0;
- decomposedAdvances[0] = 0;
- }
- else if (!CodePoint.IsNewLine(codePoint))
- {
- // Standard text.
- // If decomposed we need to add the advance; otherwise, use the largest advance for the metrics.
- if (isHorizontalLayout || shouldRotate)
+ else
+ {
+ glyphAdvance = glyph.AdvanceHeight;
+ }
+
+ decomposedAdvances[0] = glyphAdvance;
+
+ bool isSoftHyphen = codePoint.Value == SoftHyphen;
+ if (isSoftHyphen)
+ {
+ glyphAdvance = 0;
+ decomposedAdvances[0] = 0;
+ }
+ else if (CodePoint.IsTabulation(codePoint))
{
- for (int i = 1; i < metrics.Count; i++)
+ if (options.TabWidth > -1F)
{
- float a = metrics[i].AdvanceWidth;
- if (isDecomposed)
- {
- glyphAdvance += a;
- decomposedAdvances[i] = a;
- }
- else if (a > glyphAdvance)
+ // Do not use the default font tab width. Instead find the advance for the space glyph
+ // and multiply that by the options value.
+ CodePoint space = new(0x0020);
+ if (glyph.FontMetrics.TryGetGlyphId(space, out ushort spaceGlyphId))
{
- glyphAdvance = a;
+ FontGlyphMetrics spaceMetrics = glyph.FontMetrics.GetGlyphMetrics(
+ space,
+ spaceGlyphId,
+ glyph.TextAttributes,
+ glyph.TextDecorations,
+ shapedText.LayoutMode,
+ options.ColorFontSupport);
+
+ if (isHorizontalLayout || shouldRotate)
+ {
+ glyphAdvance = spaceMetrics.AdvanceWidth * options.TabWidth;
+ glyph.SetAdvanceWidth((ushort)glyphAdvance);
+ }
+ else
+ {
+ glyphAdvance = spaceMetrics.AdvanceHeight * options.TabWidth;
+ glyph.SetAdvanceHeight((ushort)glyphAdvance);
+ }
}
}
}
- else
+ 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
+ // unless multiple metrics are associated with code point. In this case they are most likely the result
+ // of a substitution and shouldn't be ignored.
+ glyphAdvance = 0;
+ decomposedAdvances[0] = 0;
+ }
+ else if (!CodePoint.IsNewLine(codePoint))
{
- for (int i = 1; i < metrics.Count; i++)
+ // Standard text.
+ // If decomposed we need to add the advance; otherwise, use the largest advance for the metrics.
+ if (isHorizontalLayout || shouldRotate)
{
- float a = metrics[i].AdvanceHeight;
- if (isDecomposed)
+ for (int i = 1; i < metrics.Count; i++)
{
- glyphAdvance += a;
- decomposedAdvances[i] = a;
+ float a = metrics[i].AdvanceWidth;
+ if (isDecomposed)
+ {
+ glyphAdvance += a;
+ decomposedAdvances[i] = a;
+ }
+ else if (a > glyphAdvance)
+ {
+ glyphAdvance = a;
+ }
}
- else if (a > glyphAdvance)
+ }
+ else
+ {
+ for (int i = 1; i < metrics.Count; i++)
{
- glyphAdvance = a;
+ float a = metrics[i].AdvanceHeight;
+ if (isDecomposed)
+ {
+ glyphAdvance += a;
+ decomposedAdvances[i] = a;
+ }
+ else if (a > glyphAdvance)
+ {
+ glyphAdvance = a;
+ }
}
}
}
- }
- // Now scale the advance. We use inches for comparison.
- if (isHorizontalLayout || shouldRotate)
- {
- float scaleAX = pointSize / glyph.ScaleFactor.X;
- glyphAdvance *= scaleAX;
- for (int i = 0; i < decomposedAdvances.Length; i++)
+ // Now scale the advance. We use inches for comparison.
+ if (isHorizontalLayout || shouldRotate)
{
- decomposedAdvances[i] *= scaleAX;
+ float scaleAX = pointSize / glyph.ScaleFactor.X;
+ glyphAdvance *= scaleAX;
+ for (int i = 0; i < decomposedAdvances.Length; i++)
+ {
+ decomposedAdvances[i] *= scaleAX;
+ }
}
- }
- else
- {
- float scaleAY = pointSize / glyph.ScaleFactor.Y;
- glyphAdvance *= scaleAY;
- for (int i = 0; i < decomposedAdvances.Length; i++)
+ else
{
- decomposedAdvances[i] *= scaleAY;
+ float scaleAY = pointSize / glyph.ScaleFactor.Y;
+ glyphAdvance *= scaleAY;
+ for (int i = 0; i < decomposedAdvances.Length; i++)
+ {
+ decomposedAdvances[i] *= scaleAY;
+ }
}
- }
- int graphemeCodePointMax = CodePoint.GetCodePointCount(grapheme) - 1;
+ int graphemeCodePointMax = CodePoint.GetCodePointCount(grapheme) - 1;
- // For non-decomposed glyphs the length is always 1.
- for (int i = 0; i < decomposedAdvances.Length; i++)
- {
- // Determine if this is the last codepoint in the grapheme.
- bool isLastInGrapheme = graphemeCodePointIndex == graphemeCodePointMax && i == decomposedAdvances.Length - 1;
+ // For non-decomposed glyphs the length is always 1.
+ for (int i = 0; i < decomposedAdvances.Length; i++)
+ {
+ // Determine if this is the last codepoint in the grapheme.
+ bool isLastInGrapheme = graphemeCodePointIndex == graphemeCodePointMax && i == decomposedAdvances.Length - 1;
- float decomposedAdvance = decomposedAdvances[i];
+ float decomposedAdvance = decomposedAdvances[i];
- // Work out the scaled metrics for the glyph.
- GlyphMetrics metric = metrics[i];
+ // Work out the scaled metrics for the glyph.
+ FontGlyphMetrics metric = metrics[i];
- // Adjust the advance for the last decomposed glyph to add tracking if applicable.
- // Tracking should only be added once per grapheme, so only on the last codepoint of the grapheme.
- if (isLastInGrapheme && options.Tracking != 0 && i == decomposedAdvances.Length - 1)
- {
- // Tracking should not be applied to tab characters or non-rendered codepoints.
- if (!CodePoint.IsTabulation(codePoint) && !UnicodeUtility.ShouldNotBeRendered(codePoint))
+ // Adjust the advance for the last decomposed glyph to add tracking if applicable.
+ // Tracking should only be added once per grapheme, so only on the last codepoint of the grapheme.
+ if (isLastInGrapheme && options.Tracking != 0 && i == decomposedAdvances.Length - 1)
{
- if (isHorizontalLayout || shouldRotate)
- {
- float scaleAX = pointSize / glyph.ScaleFactor.X;
- decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAX;
- }
- else
+ // Tracking should not be applied to tab characters or non-rendered codepoints.
+ if (!CodePoint.IsTabulation(codePoint) && !UnicodeUtility.ShouldNotBeRendered(codePoint))
{
- float scaleAY = pointSize / glyph.ScaleFactor.Y;
- decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAY;
+ if (isHorizontalLayout || shouldRotate)
+ {
+ float scaleAX = pointSize / glyph.ScaleFactor.X;
+ decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAX;
+ }
+ else
+ {
+ float scaleAY = pointSize / glyph.ScaleFactor.Y;
+ decomposedAdvance += options.Tracking * metric.FontMetrics.UnitsPerEm * scaleAY;
+ }
}
}
- }
- // Convert design-space units to pixels based on the target point size.
- // ScaleFactor.Y represents the vertical UPEM scaling factor for this glyph.
- float scaleY = pointSize / metric.ScaleFactor.Y;
+ // Convert design-space units to pixels based on the target point size.
+ // ScaleFactor.Y represents the vertical UPEM scaling factor for this glyph.
+ float scaleY = pointSize / metric.ScaleFactor.Y;
- // Choose which metrics table to use based on layout orientation.
- // Horizontal is the default; vertical fonts use VMTX if available.
- IMetricsHeader metricsHeader = isHorizontalLayout || shouldRotate
- ? metric.FontMetrics.HorizontalMetrics
- : metric.FontMetrics.VerticalMetrics;
+ // Choose which metrics table to use based on layout orientation.
+ // Horizontal is the default; vertical fonts use VMTX if available.
+ IMetricsHeader metricsHeader = isHorizontalLayout || shouldRotate
+ ? metric.FontMetrics.HorizontalMetrics
+ : metric.FontMetrics.VerticalMetrics;
- // Ascender and descender are stored in font design units, so scale them to pixels.
- float ascender = metricsHeader.Ascender * scaleY;
+ // Ascender and descender are stored in font design units, so scale them to pixels.
+ float ascender = metricsHeader.Ascender * scaleY;
- // Match browser line-height calculation logic.
- // Reference: https://www.w3.org/TR/CSS2/visudet.html#propdef-line-height
- // The line height in CSS is based on a multiple of the font-size (pointSize),
- // but fonts may define a custom LineHeight in their metrics that differs from UPEM.
- float descender = Math.Abs(metricsHeader.Descender * scaleY);
- float lineHeight = metric.UnitsPerEm * scaleY;
+ // Match browser line-height calculation logic.
+ // Reference: https://www.w3.org/TR/CSS2/visudet.html#propdef-line-height
+ // The line height in CSS is based on a multiple of the font-size (pointSize),
+ // but fonts may define a custom LineHeight in their metrics that differs from UPEM.
+ float descender = Math.Abs(metricsHeader.Descender * scaleY);
+ float lineHeight = metric.UnitsPerEm * scaleY;
- // The delta centers the font's line box within the CSS line box when
- // LineHeight differs from the nominal font size.
- float delta = ((metricsHeader.LineHeight * scaleY) - lineHeight) * 0.5F;
+ // The delta centers the font's line box within the CSS line box when
+ // LineHeight differs from the nominal font size.
+ float delta = ((metricsHeader.LineHeight * scaleY) - lineHeight) * 0.5F;
- // Adjust ascender and descender symmetrically by delta to preserve visual balance.
- ascender -= delta;
- descender -= delta;
+ // Adjust ascender and descender symmetrically by delta to preserve visual balance.
+ ascender -= delta;
+ descender -= delta;
- GlyphLayoutMode mode = GlyphLayoutMode.Horizontal;
- if (isVerticalLayout)
- {
- mode = GlyphLayoutMode.Vertical;
- }
- else if (isVerticalMixedLayout)
- {
- mode = shouldRotate ? GlyphLayoutMode.VerticalRotated : GlyphLayoutMode.Vertical;
+ GlyphLayoutMode mode = GlyphLayoutMode.Horizontal;
+ if (isVerticalLayout)
+ {
+ mode = GlyphLayoutMode.Vertical;
+ }
+ else if (isVerticalMixedLayout)
+ {
+ mode = shouldRotate ? GlyphLayoutMode.VerticalRotated : GlyphLayoutMode.Vertical;
+ }
+
+ int hyphenationMarkerIndex = -1;
+ if (isSoftHyphen && hyphenationMarkerCodePoint.HasValue)
+ {
+ // U+00AD is shaped as an invisible source entry, but if this exact
+ // discretionary break is later selected we need a visible marker with
+ // the same run, font attributes, bidi mapping, and source mapping. Build
+ // that marker here while those values are already in hand; BreakLines can
+ // then account for its advance without rescanning or reshaping the line.
+ hyphenationMarkerIndex = hyphenationMarkers.Count;
+ hyphenationMarkers.Add(CreateGeneratedMarker(
+ glyph,
+ pointSize,
+ shapedText.BidiRuns[shapedText.BidiMap[codePointIndex]],
+ graphemeIndex,
+ isLastInGrapheme,
+ codePointIndex,
+ graphemeCodePointIndex,
+ stringIndex,
+ hyphenationMarkerCodePoint.Value,
+ shapedText.LayoutMode,
+ options));
+ }
+
+ // Add our metrics to the line.
+ textLine.Add(
+ isDecomposed ? new FontGlyphMetrics[] { metric } : metrics,
+ pointSize,
+ decomposedAdvance,
+ lineHeight,
+ ascender,
+ descender,
+ delta,
+ shapedText.BidiRuns[shapedText.BidiMap[codePointIndex]],
+ graphemeIndex,
+ isLastInGrapheme,
+ codePointIndex,
+ graphemeCodePointIndex,
+ shouldRotate || shouldOffset,
+ isDecomposed,
+ stringIndex,
+ mode,
+ options.LineSpacing,
+ hyphenationMarkerIndex);
}
- // Add our metrics to the line.
- textLine.Add(
- isDecomposed ? new GlyphMetrics[] { metric } : metrics,
- pointSize,
- decomposedAdvance,
- lineHeight,
- ascender,
- descender,
- delta,
- bidiRuns[bidiMap[codePointIndex]],
- graphemeIndex,
- isLastInGrapheme,
- codePointIndex,
- graphemeCodePointIndex,
- shouldRotate || shouldOffset,
- isDecomposed,
- stringIndex,
- mode,
- options.LineSpacing);
+ codePointIndex++;
+ graphemeCodePointIndex++;
}
- codePointIndex++;
- graphemeCodePointIndex++;
+ stringIndex += grapheme.Length;
+ graphemeIndex++;
}
- stringIndex += grapheme.Length;
+ wordSegments.Add(new WordSegmentRun(
+ wordSegmentGraphemeStart,
+ graphemeIndex,
+ wordSegment.Utf16Offset,
+ wordSegment.Utf16Offset + wordSegment.Utf16Length));
}
- // Calculate the break opportunities once. The wrapping loop below may scan them
- // repeatedly as each finalized line is split off from the remaining text.
- List lineBreaks = CollectLineBreaks(text);
-
- int processed = 0;
- while (textLine.Count > 0)
+ // Placeholders do not consume source text. A placeholder inserted at
+ // the final source position has no following codepoint to visit in
+ // the main loop, so we add those trailing placeholder entries here.
+ if (shapedText.Positionings.TryGetGlyphMetricsAtOffset(
+ codePointIndex,
+ ref glyphSearchIndex,
+ out _,
+ out _,
+ out _,
+ out _,
+ out IReadOnlyList? endGlyphData))
{
- LineBreak? bestBreak = null;
- foreach (LineBreak lineBreak in lineBreaks)
+ for (int i = 0; i < endGlyphData.Count; i++)
{
- // Skip breaks that are already behind the processed portion
- if (lineBreak.PositionWrap <= processed)
- {
- continue;
- }
-
- // Measure the text up to the adjusted break point
- float advance = textLine.MeasureAt(lineBreak.PositionMeasure - processed);
- if (advance >= wrappingLength)
- {
- bestBreak ??= lineBreak;
- break;
- }
-
- // If it's a mandatory break, stop immediately
- if (lineBreak.Required)
+ GlyphPositioningCollection.GlyphPositioningData data = endGlyphData[i];
+ if (data.Data.IsPlaceholder)
{
- bestBreak = lineBreak;
- break;
+ textLine.AddPlaceholder(
+ data,
+ graphemeIndex,
+ stringIndex,
+ isHorizontalLayout,
+ isVerticalMixedLayout,
+ options.LineSpacing);
}
-
- // Update the best break
- bestBreak = lineBreak;
}
+ }
- if (bestBreak != null)
- {
- LineBreak breakAt = bestBreak.Value;
- if (breakAll)
- {
- // Break-all works differently to the other modes.
- // It will break at any character so we simply toggle the breaking operation depending
- // on whether the break is required.
- TextLine? remaining;
- if (bestBreak.Value.Required)
- {
- if (textLine.TrySplitAt(breakAt, keepAll, out remaining))
- {
- processed = breakAt.PositionWrap;
- textLines.Add(textLine.Finalize(true));
- textLine = remaining;
- }
- }
- else if (textLine.TrySplitAt(wrappingLength, out remaining))
- {
- processed += textLine.Count;
- textLines.Add(textLine.Finalize());
- textLine = remaining;
- }
- else
- {
- processed += textLine.Count;
- }
- }
- else
- {
- // Split the current line at the adjusted break index
- if (textLine.TrySplitAt(breakAt, keepAll, out TextLine? remaining))
- {
- // If 'keepAll' is true then the break could be later than expected.
- processed = keepAll
- ? processed + Math.Max(textLine.Count, breakAt.PositionWrap - processed)
- : breakAt.PositionWrap;
+ // Line break candidates are width-independent and belong with the composed logical line.
+ List lineBreaks = CollectLineBreaks(text, hyphenationMarkerCodePoint.HasValue);
- if (breakWord)
- {
- // A break was found, but we need to check if the line is too long
- // and break if required.
- if (textLine.ScaledLineAdvance > wrappingLength &&
- textLine.TrySplitAt(wrappingLength, out TextLine? overflow))
- {
- // Reinsert the overflow at the beginning of the remaining line
- processed -= overflow.Count;
- remaining.InsertAt(0, overflow);
- }
- }
+ return new LogicalTextLine(textLine, lineBreaks, wordSegments, hyphenationMarkers);
+ }
- // Add the split part to the list and continue processing.
- textLines.Add(textLine.Finalize(breakAt.Required));
- textLine = remaining;
- }
- else
- {
- processed += textLine.Count;
- }
- }
- }
- else
- {
- // We're at the last line break which should be at the end of the
- // text. We can break here and finalize the line.
- if (breakWord || breakAll)
- {
- while (textLine.ScaledLineAdvance > wrappingLength)
- {
- if (!textLine.TrySplitAt(wrappingLength, out TextLine? overflow))
- {
- break;
- }
+ ///
+ /// Applies line-break opportunities to a shaped using the configured
+ /// behavior and supplied wrapping length.
+ /// Finalizes each line (trimming trailing whitespace and applying bidi reordering) and applies
+ /// justification where requested.
+ ///
+ /// The logical text line and line break opportunities to break.
+ /// The text shaping and layout options.
+ /// The wrapping length in pixels.
+ /// The shaped, line-broken, finalized text box ready for glyph placement.
+ public static TextBox BreakLines(
+ in LogicalTextLine logicalLine,
+ TextOptions options,
+ float wrappingLength)
+ {
+ int maxLines = options.MaxLines;
- textLines.Add(textLine.Finalize());
- textLine = overflow;
- }
- }
+ if (maxLines == 0)
+ {
+ TextDirection emptyTextDirection = options.TextDirection == TextDirection.RightToLeft
+ ? TextDirection.RightToLeft
+ : TextDirection.LeftToRight;
- textLines.Add(textLine.Finalize(true));
- break;
- }
+ return new TextBox([], emptyTextDirection);
}
- // Finally we justify each line that does not end a paragraph.
- for (int i = 0; i < textLines.Count; i++)
+ TextDirection textDirection = GetTextDirection(logicalLine, options);
+
+ List textLines = [];
+ TextLineBreakEnumerator lineEnumerator = new(logicalLine, options);
+
+ while (lineEnumerator.MoveNext(wrappingLength))
{
- TextLine line = textLines[i];
- if (!line.SkipJustification)
- {
- line.Justify(options);
- }
+ textLines.Add(lineEnumerator.Current);
}
- return new TextBox(textLines);
+ return new TextBox(textLines, textDirection);
}
+ ///
+ /// Gets the block-level text direction for a prepared logical line.
+ ///
+ /// The prepared logical line.
+ /// The text options used for layout.
+ /// The block-level text direction.
+ public static TextDirection GetTextDirection(in LogicalTextLine logicalLine, TextOptions options)
+ => options.TextDirection == TextDirection.Auto && logicalLine.TextLine.Count > 0
+ ? logicalLine.TextLine[0].TextDirection
+ : options.TextDirection;
+
///
/// Collects the line break opportunities used by the wrapping loop.
///
@@ -492,16 +507,150 @@ VerticalOrientationType.Rotate or
///
///
/// The original source text being laid out.
+ /// Whether soft-hyphen break opportunities should be included.
/// The ordered line break opportunities after layout-level tailoring.
- private static List CollectLineBreaks(ReadOnlySpan text)
+ private static List CollectLineBreaks(ReadOnlySpan text, bool includeHyphenationBreaks)
{
LineBreakEnumerator lineBreakEnumerator = new(text, tailorUrls: true);
List lineBreaks = [];
while (lineBreakEnumerator.MoveNext())
{
- lineBreaks.Add(lineBreakEnumerator.Current);
+ LineBreak lineBreak = lineBreakEnumerator.Current;
+ if (lineBreak.IsHyphenationBreak && !includeHyphenationBreaks)
+ {
+ continue;
+ }
+
+ lineBreaks.Add(lineBreak);
}
return lineBreaks;
}
+
+ private static CodePoint? GetHyphenationMarkerCodePoint(TextOptions options)
+ => options.TextHyphenation switch
+ {
+ TextHyphenation.Standard => new CodePoint(StandardHyphen),
+ TextHyphenation.Custom => options.CustomHyphen,
+ _ => null
+ };
+
+ ///
+ /// Creates a visible generated marker that matches the layout style of the anchor entry.
+ ///
+ /// The glyph metric that supplies font, run, attributes, and decorations.
+ /// The point size at which the marker is rendered.
+ /// The bidi run that the marker belongs to.
+ /// The source grapheme index to map the marker to.
+ /// Whether the marker maps to the last entry in its grapheme.
+ /// The source codepoint index to map the marker to.
+ /// The source codepoint-in-grapheme index to map the marker to.
+ /// The UTF-16 source index to map the marker to.
+ /// The marker codepoint to create.
+ /// The layout mode used to calculate marker orientation.
+ /// The text options used for layout.
+ /// The generated marker entry.
+ internal static GlyphLayoutData CreateGeneratedMarker(
+ FontGlyphMetrics anchorMetric,
+ float pointSize,
+ BidiRun bidiRun,
+ int graphemeIndex,
+ bool isLastInGrapheme,
+ int codePointIndex,
+ int graphemeCodePointIndex,
+ int stringIndex,
+ CodePoint markerCodePoint,
+ LayoutMode layoutMode,
+ TextOptions options)
+ {
+ anchorMetric.FontMetrics.TryGetGlyphId(markerCodePoint, out ushort markerGlyphId);
+
+ FontGlyphMetrics markerMetric = anchorMetric.FontMetrics.GetGlyphMetrics(
+ markerCodePoint,
+ markerGlyphId,
+ anchorMetric.TextAttributes,
+ anchorMetric.TextDecorations,
+ layoutMode,
+ options.ColorFontSupport);
+
+ markerMetric = markerMetric.CloneForRendering(anchorMetric.TextRun);
+
+ bool isHorizontalLayout = layoutMode.IsHorizontal();
+ bool isVerticalLayout = layoutMode.IsVertical();
+ bool isVerticalMixedLayout = layoutMode.IsVerticalMixed();
+ bool shouldRotate = isVerticalMixedLayout &&
+ CodePoint.GetVerticalOrientationType(markerCodePoint) is
+ VerticalOrientationType.Rotate or
+ VerticalOrientationType.TransformRotate;
+
+ bool shouldOffset = isVerticalLayout &&
+ CodePoint.GetVerticalOrientationType(markerCodePoint) is
+ VerticalOrientationType.Rotate or
+ VerticalOrientationType.TransformRotate;
+
+ GlyphLayoutMode markerMode = GlyphLayoutMode.Horizontal;
+ if (isVerticalLayout)
+ {
+ markerMode = GlyphLayoutMode.Vertical;
+ }
+ else if (isVerticalMixedLayout)
+ {
+ markerMode = shouldRotate ? GlyphLayoutMode.VerticalRotated : GlyphLayoutMode.Vertical;
+ }
+
+ float markerAdvance = isHorizontalLayout || shouldRotate
+ ? markerMetric.AdvanceWidth * (pointSize / markerMetric.ScaleFactor.X)
+ : markerMetric.AdvanceHeight * (pointSize / markerMetric.ScaleFactor.Y);
+
+ // Generated markers must reserve the same CSS line box as ordinary glyphs
+ // from the same run so truncation and discretionary hyphens do not collapse
+ // or expand line spacing.
+ float markerScaleY = pointSize / markerMetric.ScaleFactor.Y;
+ IMetricsHeader markerMetricsHeader = isHorizontalLayout || shouldRotate
+ ? markerMetric.FontMetrics.HorizontalMetrics
+ : markerMetric.FontMetrics.VerticalMetrics;
+
+ float markerAscender = markerMetricsHeader.Ascender * markerScaleY;
+ float markerDescender = Math.Abs(markerMetricsHeader.Descender * markerScaleY);
+ float markerLineHeight = markerMetric.UnitsPerEm * markerScaleY;
+ float markerDelta = ((markerMetricsHeader.LineHeight * markerScaleY) - markerLineHeight) * 0.5F;
+
+ markerAscender -= markerDelta;
+ markerDescender -= markerDelta;
+
+ FontRectangle markerBox = FontGlyphMetrics.ShouldSkipGlyphRendering(markerMetric.CodePoint)
+ ? FontRectangle.Empty
+ : markerMetric.GetBoundingBox(markerMode, Vector2.Zero, pointSize);
+
+ return new GlyphLayoutData(
+ new FontGlyphMetrics[] { markerMetric },
+ pointSize,
+ markerAdvance,
+ markerLineHeight * options.LineSpacing,
+ markerAscender,
+ markerDescender,
+ markerDelta,
+ MathF.Min(0, markerBox.Y),
+ bidiRun,
+ graphemeIndex,
+ isLastInGrapheme,
+ codePointIndex,
+ graphemeCodePointIndex,
+ shouldRotate || shouldOffset,
+ false,
+ stringIndex);
+ }
+
+ ///
+ /// Gets the configured ellipsis marker codepoint.
+ ///
+ /// The text options used for layout.
+ /// The configured ellipsis marker codepoint, or when ellipsis is disabled.
+ public static CodePoint? GetEllipsisMarkerCodePoint(TextOptions options)
+ => options.TextEllipsis switch
+ {
+ TextEllipsis.Standard => new CodePoint(StandardEllipsis),
+ TextEllipsis.Custom => options.CustomEllipsis,
+ _ => null
+ };
}
diff --git a/src/SixLabors.Fonts/TextLayout.Visitors.cs b/src/SixLabors.Fonts/TextLayout.Visitors.cs
index f2678587..749c8ff7 100644
--- a/src/SixLabors.Fonts/TextLayout.Visitors.cs
+++ b/src/SixLabors.Fonts/TextLayout.Visitors.cs
@@ -4,104 +4,32 @@
namespace SixLabors.Fonts;
///
-/// Visitor types for streaming laid-out glyphs through .
+/// Visitor types for streaming laid-out glyphs through the layout pipeline.
///
internal static partial class TextLayout
{
///
- /// Receives laid-out glyphs streamed from .
+ /// Receives laid-out glyphs streamed from the layout pipeline.
/// Implementations are value types so the generic dispatch is specialized by the JIT and no boxing or
/// delegate allocation is required.
///
internal interface IGlyphLayoutVisitor
{
///
- /// Invoked once for each laid-out glyph in layout order.
- ///
- /// The laid-out glyph.
- public void Visit(in GlyphLayout glyph);
- }
-
- ///
- /// Collects streamed glyphs into a .
- ///
- internal readonly struct GlyphLayoutCollector : IGlyphLayoutVisitor
- {
- ///
- /// Initializes a new instance of the struct.
+ /// Invoked before glyphs are streamed for a laid-out line.
///
- /// The list to collect streamed glyphs into.
- public GlyphLayoutCollector(List glyphs) => this.Glyphs = glyphs;
-
- ///
- /// Gets the accumulated glyphs.
- ///
- public List Glyphs { get; }
-
- ///
- public readonly void Visit(in GlyphLayout glyph) => this.Glyphs.Add(glyph);
- }
-
- ///
- /// Accumulates the union of glyph ink bounds as glyphs are streamed, avoiding the allocation
- /// of a and a second iteration pass.
- ///
- internal struct GlyphBoundsAccumulator : IGlyphLayoutVisitor
- {
- private readonly float dpi;
- private float left;
- private float top;
- private float right;
- private float bottom;
- private bool any;
+ /// The zero-based index of the line in the line-broken text box.
+ public void BeginLine(int lineIndex);
///
- /// Initializes a new instance of the struct.
+ /// Invoked once for each laid-out glyph in layout order.
///
- /// The device-independent pixels per unit for the containing .
- public GlyphBoundsAccumulator(float dpi)
- {
- this.dpi = dpi;
- this.left = float.MaxValue;
- this.top = float.MaxValue;
- this.right = float.MinValue;
- this.bottom = float.MinValue;
- this.any = false;
- }
-
- ///
- public void Visit(in GlyphLayout glyph)
- {
- FontRectangle box = glyph.BoundingBox(this.dpi);
-
- if (box.Left < this.left)
- {
- this.left = box.Left;
- }
-
- if (box.Top < this.top)
- {
- this.top = box.Top;
- }
-
- if (box.Right > this.right)
- {
- this.right = box.Right;
- }
-
- if (box.Bottom > this.bottom)
- {
- this.bottom = box.Bottom;
- }
-
- this.any = true;
- }
+ /// The laid-out glyph.
+ public void Visit(in GlyphLayout glyph);
///
- /// Returns the accumulated ink bounds, or if no glyphs were visited.
+ /// Invoked after glyphs have been streamed for a laid-out line.
///
- /// The union of the ink bounds of all visited glyphs.
- public readonly FontRectangle Result()
- => this.any ? FontRectangle.FromLTRB(this.left, this.top, this.right, this.bottom) : FontRectangle.Empty;
+ public void EndLine();
}
}
diff --git a/src/SixLabors.Fonts/TextLayout.cs b/src/SixLabors.Fonts/TextLayout.cs
index ff6a131b..f6bf442c 100644
--- a/src/SixLabors.Fonts/TextLayout.cs
+++ b/src/SixLabors.Fonts/TextLayout.cs
@@ -1,8 +1,6 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
-using System.Diagnostics;
-using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using SixLabors.Fonts.Tables.AdvancedTypographic;
using SixLabors.Fonts.Unicode;
@@ -10,32 +8,10 @@
namespace SixLabors.Fonts;
///
-/// Encapsulated logic or laying out text.
+/// Encapsulates logic for laying out text.
///
internal static partial class TextLayout
{
- ///
- /// Shapes the supplied text and returns every laid-out glyph in layout order.
- ///
- ///
- /// Equivalent to followed by .
- /// Prefer for callers that only need aggregate ink bounds,
- /// or the streaming overload to avoid materializing the glyph list.
- ///
- /// The text to lay out.
- /// The text shaping and layout options.
- /// The laid-out glyphs in layout order, or an empty list when is empty.
- public static IReadOnlyList GenerateLayout(ReadOnlySpan text, TextOptions options)
- {
- if (text.IsEmpty)
- {
- return Array.Empty();
- }
-
- TextBox textBox = ProcessText(text, options);
- return LayoutText(textBox, options);
- }
-
///
/// Resolves the ordered sequence of instances that cover .
///
@@ -45,10 +21,17 @@ public static IReadOnlyList GenerateLayout(ReadOnlySpan text,
/// supplied runs are ordered, gaps are filled with default-font runs, and overlapping ranges are trimmed.
///
/// The text to partition into runs.
- /// The text shaping options supplying the default font and optional user-defined runs.
+ /// The text options supplying the default font and optional user-defined runs.
/// The resolved runs that together cover the entire grapheme range of .
public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, TextOptions options)
{
+ int start = 0;
+ int end = text.GetGraphemeCount();
+ if (end == 0)
+ {
+ return [];
+ }
+
if (options.TextRuns is null || options.TextRuns.Count == 0)
{
return new TextRun[]
@@ -62,8 +45,6 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text
};
}
- int start = 0;
- int end = text.GetGraphemeCount();
List textRuns = [];
foreach (TextRun textRun in options.TextRuns.OrderBy(x => x.Start))
{
@@ -81,6 +62,11 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text
// Add the current run, ensuring the font is not null.
textRun.Font ??= options.Font;
+ if (textRun.Placeholder.HasValue && textRun.End != textRun.Start)
+ {
+ throw new ArgumentException("Placeholder text runs must be zero-length insertion runs.", nameof(options));
+ }
+
// Ensure that the previous run does not overlap the current.
if (textRuns.Count > 0)
{
@@ -108,18 +94,17 @@ public static IReadOnlyList BuildTextRuns(ReadOnlySpan text, Text
}
///
- /// Shapes and line-breaks into a ready for layout.
+ /// Shapes into shaping state that is independent of the wrapping length.
///
///
/// Performs the font-run build, bidi analysis, GSUB/GPOS shaping (including fallback font
- /// resolution for unmapped codepoints), and line breaking. The result is a sequence of
- /// entries with resolved glyph metrics but no pen positioning — positioning
- /// is applied later by .
+ /// resolution for unmapped codepoints). The result contains the positioned glyph collection
+ /// and bidi state used by logical line composition.
///
/// The text to process.
- /// The text shaping options.
- /// The shaped, line-broken text ready for glyph positioning.
- internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options)
+ /// The text options used while shaping.
+ /// The wrapping-independent shaping state.
+ public static ShapedText ShapeText(ReadOnlySpan text, TextOptions options)
{
// Gather the font and fallbacks.
Font[] fallbackFonts = (options.FallbackFontFamilies?.Count > 0)
@@ -135,13 +120,33 @@ internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options
BidiData bidiData = new();
bidiData.Init(text, (sbyte)options.TextDirection);
- // If we have embedded directional overrides then change those
- // ranges to neutral.
- if (options.TextDirection != TextDirection.Auto)
+ if (options.TextBidiMode == TextBidiMode.Override)
{
- bidiData.SaveTypes();
- bidiData.Types.Span.Fill(BidiCharacterType.OtherNeutral);
- bidiData.PairedBracketTypes.Span.Clear();
+ BidiCharacterType overrideType = options.TextDirection == TextDirection.Auto
+ ? (bidi.ResolveEmbeddingLevel(bidiData.Types) == 1 ? BidiCharacterType.RightToLeft : BidiCharacterType.LeftToRight)
+ : (options.TextDirection == TextDirection.RightToLeft ? BidiCharacterType.RightToLeft : BidiCharacterType.LeftToRight);
+
+ for (int i = 0; i < bidiData.Types.Length; i++)
+ {
+ // Bidi override is a higher-level protocol override: real text behaves as the requested
+ // strong direction, while separators and explicit bidi controls keep their structural role.
+ bidiData.Types[i] = bidiData.Types[i] switch
+ {
+ BidiCharacterType.ParagraphSeparator
+ or BidiCharacterType.SegmentSeparator
+ or BidiCharacterType.BoundaryNeutral
+ or BidiCharacterType.LeftToRightEmbedding
+ or BidiCharacterType.RightToLeftEmbedding
+ or BidiCharacterType.LeftToRightOverride
+ or BidiCharacterType.RightToLeftOverride
+ or BidiCharacterType.PopDirectionalFormat
+ or BidiCharacterType.LeftToRightIsolate
+ or BidiCharacterType.RightToLeftIsolate
+ or BidiCharacterType.FirstStrongIsolate
+ or BidiCharacterType.PopDirectionalIsolate => bidiData.Types[i],
+ _ => overrideType,
+ };
+ }
}
bidi.Process(bidiData);
@@ -160,6 +165,35 @@ internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options
int bidiRunIndex = 0;
foreach (TextRun textRun in textRuns)
{
+ if (textRun.Placeholder.HasValue)
+ {
+ substitutions.Clear();
+
+ while (bidiRunIndex < bidiRuns.Length && codePointIndex == bidiRuns[bidiRunIndex].End)
+ {
+ bidiRunIndex++;
+ }
+
+ // Placeholder direction comes from the bidi region at the insertion
+ // point. If the insertion point is after all source text, use the
+ // default even/LTR embedding level.
+ BidiRun placeholderBidiRun = bidiRunIndex < bidiRuns.Length
+ ? bidiRuns[bidiRunIndex]
+ : new(BidiCharacterType.LeftToRight, 2, codePointIndex, 0);
+
+ // Placeholder runs are inserted into the layout stream and do not consume
+ // source graphemes, source codepoints, or bidi runs.
+ substitutions.AddPlaceholder(
+ CodePoint.ObjectReplacementChar,
+ placeholderBidiRun,
+ textRun,
+ codePointIndex);
+
+ complete &= positionings.TryAdd(textRun.Font!, substitutions);
+ textRunIndex++;
+ continue;
+ }
+
if (!DoFontRun(
textRun.Slice(text),
textRun.Start,
@@ -228,52 +262,12 @@ internal static TextBox ProcessText(ReadOnlySpan text, TextOptions options
font.FontMetrics.UpdatePositions(positionings);
}
- return BreakLines(text, options, bidiRuns, bidiMap, positionings, layoutMode);
- }
-
- ///
- /// Lays out the supplied and materializes every glyph into a
- /// .
- ///
- ///
- /// Prefer the streaming overload when the caller only needs
- /// aggregated state (for example ink bounds), to avoid allocating the list.
- ///
- /// The shaped and line-broken text.
- /// The text shaping options used to shape .
- /// The laid-out glyphs in layout order.
- internal static List LayoutText(TextBox textBox, TextOptions options)
- {
- GlyphLayoutCollector visitor = new([]);
- LayoutText(textBox, options, ref visitor);
- return visitor.Glyphs;
- }
-
- ///
- /// Lays out the supplied and returns the union of the ink bounds of
- /// every emitted glyph in a single streaming pass.
- ///
- ///
- /// Equivalent to iterating and unioning each
- /// glyph's , but avoids materializing the glyph list and
- /// the second iteration pass.
- ///
- /// The shaped and line-broken text.
- /// The text shaping options used to shape .
- ///
- /// The union of the ink bounds of every laid-out glyph, or
- /// if no glyphs were emitted.
- ///
- internal static FontRectangle GetBounds(TextBox textBox, TextOptions options)
- {
- GlyphBoundsAccumulator visitor = new(options.Dpi);
- LayoutText(textBox, options, ref visitor);
- return visitor.Result();
+ return new ShapedText(positionings, bidiRuns, bidiMap, layoutMode);
}
///
/// Lays out the supplied , streaming each laid-out glyph through the
- /// supplied in layout order.
+ /// supplied in layout order using the supplied wrapping length for alignment.
///
///
/// The visitor type is constrained to a struct implementing
@@ -281,22 +275,32 @@ internal static FontRectangle GetBounds(TextBox textBox, TextOptions options)
///
/// The concrete visitor struct type.
/// The shaped and line-broken text.
- /// The text shaping options used to shape .
- /// The visitor that receives each laid-out glyph.
- internal static void LayoutText(TextBox textBox, TextOptions options, ref TVisitor visitor)
+ /// The text options used to lay out .
+ /// The wrapping length in pixels. Use -1 to disable wrapping.
+ /// The visitor that receives each positioned glyph.
+ public static void LayoutText(
+ TextBox textBox,
+ TextOptions options,
+ float wrappingLength,
+ ref TVisitor visitor)
where TVisitor : struct, IGlyphLayoutVisitor
{
+ if (textBox.TextLines.Count == 0)
+ {
+ return;
+ }
+
LayoutMode layoutMode = options.LayoutMode;
Vector2 boxLocation = options.Origin / options.Dpi;
Vector2 penLocation = boxLocation;
- // If a wrapping length is specified that should be used to determine the
- // box size to align text within.
+ // When wrapping is enabled, the wrapping length defines the minimum line-box
+ // extent used by alignment.
float maxScaledAdvance = textBox.ScaledMaxAdvance();
- if (options.TextAlignment != TextAlignment.Start && options.WrappingLength > 0)
+ if (options.TextAlignment != TextAlignment.Start && wrappingLength > 0)
{
- maxScaledAdvance = Math.Max(options.WrappingLength / options.Dpi, maxScaledAdvance);
+ maxScaledAdvance = Math.Max(wrappingLength / options.Dpi, maxScaledAdvance);
}
TextDirection direction = textBox.TextDirection();
@@ -305,6 +309,7 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
+ visitor.BeginLine(i);
LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
@@ -315,6 +320,8 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
else if (layoutMode == LayoutMode.HorizontalBottomTop)
@@ -322,6 +329,7 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
+ visitor.BeginLine(i);
LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
@@ -332,12 +340,15 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
else if (layoutMode is LayoutMode.VerticalLeftRight)
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
+ visitor.BeginLine(i);
LayoutLineVertical(
textBox,
textBox.TextLines[i],
@@ -348,6 +359,8 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
else if (layoutMode is LayoutMode.VerticalRightLeft)
@@ -355,6 +368,7 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
+ visitor.BeginLine(i);
LayoutLineVertical(
textBox,
textBox.TextLines[i],
@@ -365,12 +379,15 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
else if (layoutMode is LayoutMode.VerticalMixedLeftRight)
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
+ visitor.BeginLine(i);
LayoutLineVerticalMixed(
textBox,
textBox.TextLines[i],
@@ -381,6 +398,8 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
else
@@ -388,6 +407,7 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
+ visitor.BeginLine(i);
LayoutLineVerticalMixed(
textBox,
textBox.TextLines[i],
@@ -398,6 +418,8 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
ref boxLocation,
ref penLocation,
ref visitor);
+
+ visitor.EndLine();
}
}
}
@@ -412,7 +434,7 @@ internal static void LayoutText(TextBox textBox, TextOptions options,
/// The line being laid out.
/// The resolved text direction for this line.
/// The widest scaled line advance in the block (or wrapping length).
- /// The text shaping and layout options.
+ /// The text options used to position the line.
/// The zero-based visual index of this line within the block.
/// The running top-left position of the glyph boxes; advanced by this method.
/// The running pen position used for glyph placement; advanced by this method.
@@ -521,21 +543,32 @@ private static void LayoutLineHorizontal(
}
penLocation.X += offsetX;
+ Vector2 boundsLocation = boxLocation;
bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
- TextLine.GlyphLayoutData data = textLine[i];
+ GlyphLayoutData data = textLine[i];
+ float layoutAdvance = data.ScaledAdvance;
+
if (data.IsNewLine)
{
- visitor.Visit(new GlyphLayout(
- new Glyph(data.Metrics[0], data.PointSize),
- boxLocation,
+ FontGlyphMetrics metric = data.Metrics[0];
+
+ // Hard breaks bypass the normal glyph loop, but still need the
+ // current pen position plus the same baseline origin used by glyphs.
+ Vector2 hardBreakGlyphOrigin = penLocation + new Vector2(0, textLine.ScaledMaxAscender);
+
+ visitor.Visit(
+ new GlyphLayout(
+ new Glyph(metric, data.PointSize),
+ boundsLocation,
+ hardBreakGlyphOrigin,
penLocation,
- Vector2.Zero,
data.ScaledAdvance,
yLineAdvance,
GlyphLayoutMode.Horizontal,
+ data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
@@ -544,20 +577,26 @@ private static void LayoutLineHorizontal(
penLocation.Y += yLineAdvance;
boxLocation.X = originX;
boxLocation.Y += advanceY;
+ boundsLocation.X = originX;
+ boundsLocation.Y += advanceY;
return;
}
int j = 0;
- foreach (GlyphMetrics metric in data.Metrics)
+ foreach (FontGlyphMetrics metric in data.Metrics)
{
- visitor.Visit(new GlyphLayout(
+ Vector2 glyphOrigin = penLocation + new Vector2(0, textLine.ScaledMaxAscender);
+
+ visitor.Visit(
+ new GlyphLayout(
new Glyph(metric, data.PointSize),
- boxLocation,
- penLocation + new Vector2(0, textLine.ScaledMaxAscender),
- Vector2.Zero,
+ boundsLocation,
+ glyphOrigin,
+ glyphOrigin,
data.ScaledAdvance,
advanceY,
GlyphLayoutMode.Horizontal,
+ data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
@@ -566,8 +605,9 @@ private static void LayoutLineHorizontal(
j++;
}
- boxLocation.X += data.ScaledAdvance;
- penLocation.X += data.ScaledAdvance;
+ boxLocation.X += layoutAdvance;
+ penLocation.X += layoutAdvance;
+ boundsLocation.X += data.ScaledAdvance;
}
boxLocation.X = originX;
@@ -590,7 +630,7 @@ private static void LayoutLineHorizontal(
/// The line being laid out.
/// The resolved text direction for this line.
/// The longest scaled line advance in the block (or wrapping length).
- /// The text shaping and layout options.
+ /// The text options used to position the line.
/// The zero-based visual index of this line within the block.
/// The running top-left position of the glyph boxes; advanced by this method.
/// The running pen position used for glyph placement; advanced by this method.
@@ -607,7 +647,6 @@ private static void LayoutLineVertical(
ref TVisitor visitor)
where TVisitor : struct, IGlyphLayoutVisitor
{
- float originX = penLocation.X;
float originY = penLocation.Y;
float offsetY = 0;
@@ -693,12 +732,14 @@ private static void LayoutLineVertical(
penLocation.X += offsetX;
float lineOriginX = penLocation.X;
+ Vector2 boundsLocation = boxLocation;
+ float boundsLineOriginX = boundsLocation.X;
bool emitted = false;
// Grapheme-scoped state for transformed glyph alignment.
//
- // IMPORTANT: TextLine.GlyphLayoutData is per-codepoint, not per-grapheme.
+ // IMPORTANT: GlyphLayoutData is per-codepoint, not per-grapheme.
// Complex scripts can therefore produce multiple entries for a single grapheme.
// For example Devanagari "र्कि" can end up as two entries ("र्" and "कि") even though it
// visually shapes as a single cluster.
@@ -712,17 +753,30 @@ private static void LayoutLineVertical(
for (int i = 0; i < textLine.Count; i++)
{
- TextLine.GlyphLayoutData data = textLine[i];
+ GlyphLayoutData data = textLine[i];
+ float layoutAdvance = data.ScaledAdvance;
+ float scaledLineHeight = data.ScaledLineHeight / options.LineSpacing;
+
if (data.IsNewLine)
{
- visitor.Visit(new GlyphLayout(
- new Glyph(data.Metrics[0], data.PointSize),
- boxLocation,
- penLocation,
- Vector2.Zero,
+ FontGlyphMetrics metric = data.Metrics[0];
+ Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
+
+ // Hard breaks bypass the normal glyph loop, but still need the
+ // current pen position plus the same vertical glyph origin adjustment.
+ Vector2 hardBreakDecorationOrigin = penLocation + new Vector2((unscaledLineHeight - scaledLineHeight) * .5F, 0);
+ Vector2 hardBreakGlyphOrigin = hardBreakDecorationOrigin + new Vector2(0, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
+
+ visitor.Visit(
+ new GlyphLayout(
+ new Glyph(metric, data.PointSize),
+ boundsLocation,
+ hardBreakGlyphOrigin,
+ hardBreakDecorationOrigin,
xLineAdvance,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
+ data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
@@ -731,6 +785,8 @@ private static void LayoutLineVertical(
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
+ boundsLocation.X += advanceX;
+ boundsLocation.Y = originY;
return;
}
@@ -752,7 +808,7 @@ private static void LayoutLineVertical(
for (int k = i; k < textLine.Count; k++)
{
- TextLine.GlyphLayoutData g = textLine[k];
+ GlyphLayoutData g = textLine[k];
if (g.GraphemeIndex != graphemeIndex)
{
@@ -782,14 +838,14 @@ private static void LayoutLineVertical(
for (int k = i; k < textLine.Count; k++)
{
- TextLine.GlyphLayoutData g = textLine[k];
+ GlyphLayoutData g = textLine[k];
if (g.GraphemeIndex != graphemeIndex)
{
break;
}
- foreach (GlyphMetrics m in g.Metrics)
+ foreach (FontGlyphMetrics m in g.Metrics)
{
Vector2 s = new Vector2(g.PointSize) / m.ScaleFactor;
@@ -810,10 +866,13 @@ private static void LayoutLineVertical(
float inkWidth = maxX - minX;
- // Normalize ink minX to 0 and center within the column width.
+ // Normalize ink minX to 0 and center within the entry's own line box.
+ // The decoration origin has already centered that entry line box within
+ // the widest line box, so using the widest line box here would apply the
+ // mixed-size offset twice.
// This is grapheme-correct and avoids centering based only on the "first" entry,
// which is not representative for marks like reph in Devanagari.
- currentGraphemeAlignX = -minX + ((unscaledLineHeight - inkWidth) * .5F);
+ currentGraphemeAlignX = -minX + ((scaledLineHeight - inkWidth) * .5F);
}
}
@@ -826,20 +885,31 @@ private static void LayoutLineVertical(
// Transformed glyphs are still positioned using horizontal metrics (`AdvanceWidth`) even though
// they participate in a vertical flow. `AdvanceWidth` gives us the horizontal pen advance we must
// apply between entries inside the transformed grapheme.
- foreach (GlyphMetrics m in data.Metrics)
+ foreach (FontGlyphMetrics m in data.Metrics)
{
Vector2 s = new Vector2(data.PointSize) / m.ScaleFactor;
entryScaledAdvanceWidth += m.AdvanceWidth * s.X;
}
}
- foreach (GlyphMetrics metric in data.Metrics)
+ foreach (FontGlyphMetrics metric in data.Metrics)
{
// Align the glyph horizontally and vertically centering vertically around the baseline.
Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
+ float glyphAlignX = alignX;
+
+ if (!currentGraphemeIsTransformed)
+ {
+ // Vertical origin fallback places the vertical origin at half the
+ // horizontal advance. The decoration origin has already centered this
+ // entry's line box in the column, so center the glyph advance inside it.
+ glyphAlignX = (scaledLineHeight - (metric.AdvanceWidth * scale.X)) * .5F;
+ }
- // Offset our in both directions to account for horizontal ink centering and vertical baseline centering.
- Vector2 offset = new(alignX, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
+ // Move the glyph origin without changing the advance or decoration origin.
+ Vector2 glyphOffset = new(glyphAlignX, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
+ Vector2 decorationOrigin = penLocation + new Vector2((unscaledLineHeight - scaledLineHeight) * .5F, 0);
+ Vector2 glyphOrigin = decorationOrigin + glyphOffset;
float advanceW = advanceX;
@@ -851,14 +921,16 @@ private static void LayoutLineVertical(
advanceW = scale.X * metric.AdvanceWidth;
}
- visitor.Visit(new GlyphLayout(
+ visitor.Visit(
+ new GlyphLayout(
new Glyph(metric, data.PointSize),
- boxLocation,
- penLocation + new Vector2((unscaledLineHeight - (data.ScaledLineHeight / options.LineSpacing)) * .5F, 0),
- offset,
+ boundsLocation,
+ glyphOrigin,
+ decorationOrigin,
advanceW,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
+ data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
@@ -874,11 +946,18 @@ private static void LayoutLineVertical(
penLocation.X += entryScaledAdvanceWidth;
}
+ if (currentGraphemeIsTransformed)
+ {
+ boundsLocation.X += entryScaledAdvanceWidth;
+ }
+
if (data.IsLastInGrapheme)
{
- penLocation.Y += data.ScaledAdvance;
+ penLocation.Y += layoutAdvance;
boxLocation.X = lineOriginX;
penLocation.X = lineOriginX;
+ boundsLocation.Y += data.ScaledAdvance;
+ boundsLocation.X = boundsLineOriginX;
}
}
@@ -902,7 +981,7 @@ private static void LayoutLineVertical(
/// The line being laid out.
/// The resolved text direction for this line.
/// The longest scaled line advance in the block (or wrapping length).
- /// The text shaping and layout options.
+ /// The text options used to position the line.
/// The zero-based visual index of this line within the block.
/// The running top-left position of the glyph boxes; advanced by this method.
/// The running pen position used for glyph placement; advanced by this method.
@@ -1002,21 +1081,35 @@ private static void LayoutLineVerticalMixed(
penLocation.Y += offsetY;
penLocation.X += offsetX;
+ Vector2 boundsLocation = boxLocation;
bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
- TextLine.GlyphLayoutData data = textLine[i];
+ GlyphLayoutData data = textLine[i];
+ float layoutAdvance = data.ScaledAdvance;
+ float scaledLineHeight = data.ScaledLineHeight / options.LineSpacing;
+
if (data.IsNewLine)
{
- visitor.Visit(new GlyphLayout(
- new Glyph(data.Metrics[0], data.PointSize),
- boxLocation,
- penLocation,
- Vector2.Zero,
+ FontGlyphMetrics metric = data.Metrics[0];
+ Vector2 scale = new Vector2(data.PointSize) / metric.ScaleFactor;
+
+ // Hard breaks bypass the normal glyph loop, but still need the
+ // current pen position plus the same vertical glyph origin adjustment.
+ Vector2 hardBreakDecorationOrigin = penLocation + new Vector2((unscaledLineHeight - scaledLineHeight) * .5F, 0);
+ Vector2 hardBreakGlyphOrigin = hardBreakDecorationOrigin + new Vector2(0, (metric.Bounds.Max.Y + metric.TopSideBearing) * scale.Y);
+
+ visitor.Visit(
+ new GlyphLayout(
+ new Glyph(metric, data.PointSize),
+ boundsLocation,
+ hardBreakGlyphOrigin,
+ hardBreakDecorationOrigin,
xLineAdvance,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
+ data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
@@ -1025,13 +1118,15 @@ private static void LayoutLineVerticalMixed(
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
+ boundsLocation.X += advanceX;
+ boundsLocation.Y = originY;
return;
}
if (data.IsTransformed)
{
int j = 0;
- foreach (GlyphMetrics metric in data.Metrics)
+ foreach (FontGlyphMetrics metric in data.Metrics)
{
// The glyph will be rotated 90 degrees for vertical mixed layout.
// We still advance along Y, but the glyphs are laid out sideways in X.
@@ -1040,7 +1135,7 @@ private static void LayoutLineVerticalMixed(
// - Take half the difference between the max line height (scaledMaxLineHeight)
// and the current glyph's line height (data.ScaledLineHeight).
// - The line height includes both ascender and descender metrics.
- float baselineDelta = (unscaledLineHeight - (data.ScaledLineHeight / options.LineSpacing)) * .5F;
+ float baselineDelta = (unscaledLineHeight - scaledLineHeight) * .5F;
// Adjust the horizontal offset further by considering the descender differences:
// - Subtract the current glyph's descender (data.ScaledDescender) to align it properly.
@@ -1048,15 +1143,18 @@ private static void LayoutLineVerticalMixed(
float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - descenderAbs) * .5F;
float centerOffsetX = baselineDelta + descenderAbs + descenderDelta;
+ Vector2 glyphOrigin = penLocation + new Vector2(centerOffsetX, 0);
- visitor.Visit(new GlyphLayout(
+ visitor.Visit(
+ new GlyphLayout(
new Glyph(metric, data.PointSize),
- boxLocation,
- penLocation + new Vector2(centerOffsetX, 0),
- Vector2.Zero,
+ boundsLocation,
+ glyphOrigin,
+ glyphOrigin,
advanceX,
data.ScaledAdvance,
GlyphLayoutMode.VerticalRotated,
+ data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
@@ -1068,20 +1166,29 @@ private static void LayoutLineVerticalMixed