ImageSharp/SixLabors.Fonts/TextLayout.cs
2026-08-03 22:31:27 +02:00

1531 lines
64 KiB
C#

// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using SixLabors.Fonts.Tables.AdvancedTypographic;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts {
/// <summary>
/// Encapsulates logic for laying out text.
/// </summary>
internal static partial class TextLayout
{
/// <summary>
/// Resolves the ordered sequence of <see cref="TextRun"/> instances that cover <paramref name="text"/>.
/// </summary>
/// <remarks>
/// If <see cref="TextOptions.TextRuns"/> is <see langword="null"/> or empty, a single run covering the entire
/// grapheme range of <paramref name="text"/> using <see cref="TextOptions.Font"/> is returned. Otherwise the
/// supplied runs are ordered, gaps are filled with default-font runs, and overlapping ranges are trimmed.
/// </remarks>
/// <param name="text">The text to partition into runs.</param>
/// <param name="options">The text options supplying the default font and optional user-defined runs.</param>
/// <returns>The resolved runs that together cover the entire grapheme range of <paramref name="text"/>.</returns>
public static IReadOnlyList<TextRun> BuildTextRuns(ReadOnlySpan<char> 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[]
{
new()
{
Start = 0,
End = text.GetGraphemeCount(),
Font = options.Font
}
};
}
List<TextRun> textRuns = [];
foreach (TextRun textRun in options.TextRuns.OrderBy(x => x.Start))
{
// Fill gaps within runs.
if (textRun.Start > start)
{
textRuns.Add(new()
{
Start = start,
End = textRun.Start,
Font = options.Font
});
}
// 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)
{
int prevIndex = textRuns.Count - 1;
TextRun previous = textRuns[prevIndex];
previous.End = Math.Min(previous.End, textRun.Start);
}
textRuns.Add(textRun);
start = textRun.End;
}
// Add a final run if required.
if (start < end)
{
textRuns.Add(new()
{
Start = start,
End = end,
Font = options.Font
});
}
return textRuns;
}
/// <summary>
/// Shapes <paramref name="text"/> into shaping state that is independent of the wrapping length.
/// </summary>
/// <remarks>
/// Performs the font-run build, bidi analysis, GSUB/GPOS shaping (including fallback font
/// resolution for unmapped codepoints). The result contains the positioned glyph collection
/// and bidi state used by logical line composition.
/// </remarks>
/// <param name="text">The text to process.</param>
/// <param name="options">The text options used while shaping.</param>
/// <returns>The wrapping-independent shaping state.</returns>
public static ShapedText ShapeText(ReadOnlySpan<char> text, TextOptions options)
{
// Gather the font and fallbacks.
Font[] fallbackFonts = (options.FallbackFontFamilies?.Count > 0)
? [.. options.FallbackFontFamilies.Select(x => new Font(x, options.Font.Size, options.Font.RequestedStyle))]
: [];
LayoutMode layoutMode = options.LayoutMode;
GlyphSubstitutionCollection substitutions = new(options);
GlyphPositioningCollection positionings = new(options);
// Analyse the text for bidi directional runs.
BidiAlgorithm bidi = BidiAlgorithm.Instance.Value!;
BidiData bidiData = new();
bidiData.Init(text, (sbyte)options.TextDirection);
if (options.TextBidiMode == TextBidiMode.Override)
{
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);
// Get the list of directional runs
BidiRun[] bidiRuns = [.. BidiRun.CoalesceLevels(bidi.ResolvedLevels)];
Dictionary<int, int> bidiMap = [];
// Incrementally build out collection of glyphs.
IReadOnlyList<TextRun> textRuns = BuildTextRuns(text, options);
// First do multiple font runs using the individual text runs.
bool complete = true;
int textRunIndex = 0;
int codePointIndex = 0;
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,
textRuns,
ref textRunIndex,
ref codePointIndex,
ref bidiRunIndex,
false,
textRun.Font!,
bidiRuns,
bidiMap,
substitutions,
positionings))
{
complete = false;
}
}
if (!complete)
{
// Finally try our fallback fonts.
// We do a complete run here across the whole collection.
foreach (Font font in fallbackFonts)
{
textRunIndex = 0;
codePointIndex = 0;
bidiRunIndex = 0;
if (DoFontRun(
text,
0,
textRuns,
ref textRunIndex,
ref codePointIndex,
ref bidiRunIndex,
true,
font,
bidiRuns,
bidiMap,
substitutions,
positionings))
{
break;
}
}
}
// Update the positions of the glyphs in the completed collection.
// Each set of metrics is associated with single font and will only be updated
// by that font so it's safe to use a single collection.
Font? lastFont = null;
for (int i = 0; i < textRuns.Count; i++)
{
TextRun textRun = textRuns[i];
if (textRun.Font == lastFont)
{
continue;
}
textRun.Font!.FontMetrics.UpdatePositions(positionings);
lastFont = textRun.Font;
}
foreach (Font font in fallbackFonts)
{
font.FontMetrics.UpdatePositions(positionings);
}
return new ShapedText(positionings, bidiRuns, bidiMap, layoutMode);
}
/// <summary>
/// Lays out the supplied <see cref="TextBox"/>, streaming each laid-out glyph through the
/// supplied <paramref name="visitor"/> in layout order using the supplied wrapping length for alignment.
/// </summary>
/// <remarks>
/// The visitor type is constrained to a struct implementing <see cref="IGlyphLayoutVisitor"/>
/// so the JIT specializes dispatch per visitor — no boxing or delegate allocation.
/// </remarks>
/// <typeparam name="TVisitor">The concrete visitor struct type.</typeparam>
/// <param name="textBox">The shaped and line-broken text.</param>
/// <param name="options">The text options used to lay out <paramref name="textBox"/>.</param>
/// <param name="wrappingLength">The wrapping length in pixels. Use <c>-1</c> to disable wrapping.</param>
/// <param name="visitor">The visitor that receives each positioned glyph.</param>
public static void LayoutText<TVisitor>(
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;
// 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 && wrappingLength > 0)
{
maxScaledAdvance = Math.Max(wrappingLength / options.Dpi, maxScaledAdvance);
}
TextDirection direction = textBox.TextDirection();
if (layoutMode == LayoutMode.HorizontalTopBottom)
{
for (int i = 0; i < textBox.TextLines.Count; i++)
{
visitor.BeginLine(i);
LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
direction,
maxScaledAdvance,
options,
i,
ref boxLocation,
ref penLocation,
ref visitor);
visitor.EndLine();
}
}
else if (layoutMode == LayoutMode.HorizontalBottomTop)
{
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
visitor.BeginLine(i);
LayoutLineHorizontal(
textBox,
textBox.TextLines[i],
direction,
maxScaledAdvance,
options,
index++,
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],
direction,
maxScaledAdvance,
options,
i,
ref boxLocation,
ref penLocation,
ref visitor);
visitor.EndLine();
}
}
else if (layoutMode is LayoutMode.VerticalRightLeft)
{
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
visitor.BeginLine(i);
LayoutLineVertical(
textBox,
textBox.TextLines[i],
direction,
maxScaledAdvance,
options,
index++,
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],
direction,
maxScaledAdvance,
options,
i,
ref boxLocation,
ref penLocation,
ref visitor);
visitor.EndLine();
}
}
else
{
int index = 0;
for (int i = textBox.TextLines.Count - 1; i >= 0; i--)
{
visitor.BeginLine(i);
LayoutLineVerticalMixed(
textBox,
textBox.TextLines[i],
direction,
maxScaledAdvance,
options,
index++,
ref boxLocation,
ref penLocation,
ref visitor);
visitor.EndLine();
}
}
}
/// <summary>
/// Positions one line of horizontal text. Applies vertical-block alignment (on the first line),
/// horizontal-block alignment, per-line text alignment, and any first-line ink-overshoot
/// compensation, then streams each positioned glyph through <paramref name="visitor"/>.
/// </summary>
/// <typeparam name="TVisitor">The concrete visitor struct type.</typeparam>
/// <param name="textBox">The containing text box (used to look up sibling lines for block alignment).</param>
/// <param name="textLine">The line being laid out.</param>
/// <param name="direction">The resolved text direction for this line.</param>
/// <param name="maxScaledAdvance">The widest scaled line advance in the block (or wrapping length).</param>
/// <param name="options">The text options used to position the line.</param>
/// <param name="index">The zero-based visual index of this line within the block.</param>
/// <param name="boxLocation">The running top-left position of the glyph boxes; advanced by this method.</param>
/// <param name="penLocation">The running pen position used for glyph placement; advanced by this method.</param>
/// <param name="visitor">The visitor that receives each positioned glyph.</param>
private static void LayoutLineHorizontal<TVisitor>(
TextBox textBox,
TextLine textLine,
TextDirection direction,
float maxScaledAdvance,
TextOptions options,
int index,
ref Vector2 boxLocation,
ref Vector2 penLocation,
ref TVisitor visitor)
where TVisitor : struct, IGlyphLayoutVisitor
{
// Offset the location to center the line vertically.
bool isFirstLine = index == 0;
float scaledLineHeight = textLine.ScaledMaxLineHeight;
// Recover the unscaled line height to calculate proper centering
float unscaledLineHeight = scaledLineHeight / options.LineSpacing;
float advanceY = scaledLineHeight;
// Center the glyphs within the extra space created by LineSpacing
float offsetY = (advanceY - unscaledLineHeight) * .5F;
float yLineAdvance = advanceY - offsetY;
float originX = penLocation.X;
float offsetX = 0;
// Set the Y origin for the first horizontal line and account for tall stacks.
if (isFirstLine)
{
// ScaledMinY is the minimum ink Y for this line in Y down (baseline at 0).
// -ScaledMinY is the actual ascent required to contain the ink.
// ScaledMaxAscender is the typographic ascent we already used to build the line box.
float requiredAscent = -textLine.ScaledMinY;
float extraAscent = requiredAscent - textLine.ScaledMaxAscender;
if (extraAscent > 0)
{
// Shift the baseline down only by the extra ascent needed so that
// stacked glyphs (Tibetan, etc) fit inside the bitmap. For Latin,
// requiredAscent ~= ScaledMaxAscender and extraAscent is zero.
offsetY += extraAscent;
advanceY += extraAscent;
}
switch (options.VerticalAlignment)
{
case VerticalAlignment.Center:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetY -= textBox.TextLines[i].ScaledMaxLineHeight * .5F;
}
break;
case VerticalAlignment.Bottom:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetY -= textBox.TextLines[i].ScaledMaxLineHeight;
}
break;
}
}
penLocation.Y += offsetY;
// Set the X-Origin for horizontal alignment.
switch (options.HorizontalAlignment)
{
case HorizontalAlignment.Right:
offsetX = -maxScaledAdvance;
break;
case HorizontalAlignment.Center:
offsetX = -(maxScaledAdvance * .5F);
break;
}
// Set the alignment of lines within the text.
if (direction == TextDirection.LeftToRight)
{
switch (options.TextAlignment)
{
case TextAlignment.End:
offsetX += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetX += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
else
{
switch (options.TextAlignment)
{
case TextAlignment.Start:
offsetX += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetX += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
penLocation.X += offsetX;
Vector2 boundsLocation = boxLocation;
bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
GlyphLayoutData data = textLine[i];
float layoutAdvance = data.ScaledAdvance;
if (data.IsNewLine)
{
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),
data.Font,
boundsLocation,
hardBreakGlyphOrigin,
penLocation,
data.ScaledAdvance,
yLineAdvance,
GlyphLayoutMode.Horizontal,
data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
penLocation.X = originX;
penLocation.Y += yLineAdvance;
boxLocation.X = originX;
boxLocation.Y += advanceY;
boundsLocation.X = originX;
boundsLocation.Y += advanceY;
return;
}
int j = 0;
foreach (FontGlyphMetrics metric in data.Metrics)
{
Vector2 glyphOrigin = penLocation + new Vector2(0, textLine.ScaledMaxAscender);
visitor.Visit(
new GlyphLayout(
new Glyph(metric, data.PointSize),
data.Font,
boundsLocation,
glyphOrigin,
glyphOrigin,
data.ScaledAdvance,
advanceY,
GlyphLayoutMode.Horizontal,
data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
emitted = true;
j++;
}
boxLocation.X += layoutAdvance;
penLocation.X += layoutAdvance;
boundsLocation.X += data.ScaledAdvance;
}
boxLocation.X = originX;
penLocation.X = originX;
if (emitted)
{
penLocation.Y += yLineAdvance;
boxLocation.Y += advanceY;
}
}
/// <summary>
/// Positions one line of vertical text (<see cref="LayoutMode.VerticalLeftRight"/> and
/// <see cref="LayoutMode.VerticalRightLeft"/>). All glyphs are treated as naturally vertical —
/// transformed (rotated) graphemes receive grapheme-level horizontal centering based on the
/// collective ink width of every entry sharing a grapheme index.
/// </summary>
/// <typeparam name="TVisitor">The concrete visitor struct type.</typeparam>
/// <param name="textBox">The containing text box (used to look up sibling lines for block alignment).</param>
/// <param name="textLine">The line being laid out.</param>
/// <param name="direction">The resolved text direction for this line.</param>
/// <param name="maxScaledAdvance">The longest scaled line advance in the block (or wrapping length).</param>
/// <param name="options">The text options used to position the line.</param>
/// <param name="index">The zero-based visual index of this line within the block.</param>
/// <param name="boxLocation">The running top-left position of the glyph boxes; advanced by this method.</param>
/// <param name="penLocation">The running pen position used for glyph placement; advanced by this method.</param>
/// <param name="visitor">The visitor that receives each positioned glyph.</param>
private static void LayoutLineVertical<TVisitor>(
TextBox textBox,
TextLine textLine,
TextDirection direction,
float maxScaledAdvance,
TextOptions options,
int index,
ref Vector2 boxLocation,
ref Vector2 penLocation,
ref TVisitor visitor)
where TVisitor : struct, IGlyphLayoutVisitor
{
float originY = penLocation.Y;
float offsetY = 0;
// Offset the location to center the line horizontally.
float scaledMaxLineHeight = textLine.ScaledMaxLineHeight;
// Recover the unscaled line height to calculate proper centering
float unscaledLineHeight = scaledMaxLineHeight / options.LineSpacing;
float advanceX = scaledMaxLineHeight;
// Center the glyphs within the extra space created by LineSpacing
float offsetX = (advanceX - unscaledLineHeight) * .5F;
float xLineAdvance = advanceX - offsetX;
// Set the Y-Origin for the line.
switch (options.VerticalAlignment)
{
case VerticalAlignment.Top:
offsetY = 0;
break;
case VerticalAlignment.Center:
offsetY -= maxScaledAdvance * .5F;
break;
case VerticalAlignment.Bottom:
offsetY -= maxScaledAdvance;
break;
}
// Set the alignment of lines within the text.
if (direction == TextDirection.LeftToRight)
{
switch (options.TextAlignment)
{
case TextAlignment.End:
offsetY += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
else
{
switch (options.TextAlignment)
{
case TextAlignment.Start:
offsetY += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
bool isFirstLine = index == 0;
if (isFirstLine)
{
// In vertical layout, first-line Y ascent compensation introduces unwanted
// leading space before the first glyph. Keep first-line handling limited
// to X-origin block alignment only.
// Set the X-Origin for horizontal alignment.
switch (options.HorizontalAlignment)
{
case HorizontalAlignment.Right:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetX -= textBox.TextLines[i].ScaledMaxLineHeight;
}
break;
case HorizontalAlignment.Center:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F;
}
break;
}
}
penLocation.Y += offsetY;
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: 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.
//
// - Compute a single alignX for the whole grapheme (across all entries with the same GraphemeIndex).
// - Apply that alignX as a positional offset only, never as part of pen/box advance.
// - Transformed entries still advance along X within the grapheme (horizontal glyphs inside a vertical flow),
// then X is reset at the end of the grapheme.
float currentGraphemeAlignX = 0;
bool currentGraphemeIsTransformed = false;
for (int i = 0; i < textLine.Count; i++)
{
GlyphLayoutData data = textLine[i];
float layoutAdvance = data.ScaledAdvance;
float scaledLineHeight = data.ScaledLineHeight / options.LineSpacing;
if (data.IsNewLine)
{
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),
data.Font,
boundsLocation,
hardBreakGlyphOrigin,
hardBreakDecorationOrigin,
xLineAdvance,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
boxLocation.X += advanceX;
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
boundsLocation.X += advanceX;
boundsLocation.Y = originY;
return;
}
int j = 0;
bool isFirstInGrapheme = data.GraphemeCodePointIndex == 0;
float alignX = 0;
float entryScaledAdvanceWidth = 0;
if (isFirstInGrapheme)
{
// Reset grapheme-scoped state at the start of each grapheme.
currentGraphemeAlignX = 0;
currentGraphemeIsTransformed = false;
// Determine whether this grapheme contains any transformed entries.
// This is intentionally done at grapheme scope because individual entries can differ.
int graphemeIndex = data.GraphemeIndex;
for (int k = i; k < textLine.Count; k++)
{
GlyphLayoutData g = textLine[k];
if (g.GraphemeIndex != graphemeIndex)
{
break;
}
if (g.IsTransformed)
{
currentGraphemeIsTransformed = true;
break;
}
}
if (currentGraphemeIsTransformed)
{
// In vertical layout, glyphs with a vertical orientation of TransformRotate/TransformUpright are
// rendered as "horizontal" glyphs inside a vertical flow.
//
// Their horizontal metrics (including LSB) are still expressed in the font's horizontal writing mode,
// so without an adjustment these glyphs appear shifted within the column.
//
// To make transformed glyphs align visually with naturally-vertical glyphs, we center the ink bounds
// of the ENTIRE grapheme (across all entries with the same GraphemeIndex) within the column width
// (`scaledMaxLineHeight`).
float minX = float.PositiveInfinity;
float maxX = float.NegativeInfinity;
for (int k = i; k < textLine.Count; k++)
{
GlyphLayoutData g = textLine[k];
if (g.GraphemeIndex != graphemeIndex)
{
break;
}
foreach (FontGlyphMetrics m in g.Metrics)
{
Vector2 s = new Vector2(g.PointSize) / m.ScaleFactor;
float glyphMinX = m.Bounds.Min.X * s.X;
float glyphMaxX = m.Bounds.Max.X * s.X;
if (glyphMinX < minX)
{
minX = glyphMinX;
}
if (glyphMaxX > maxX)
{
maxX = glyphMaxX;
}
}
}
float inkWidth = maxX - minX;
// 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 + ((scaledLineHeight - inkWidth) * .5F);
}
}
if (currentGraphemeIsTransformed)
{
// Apply the grapheme-level horizontal centering offset to every entry in the grapheme.
// This is positional only and must never be folded into any advance.
alignX = currentGraphemeAlignX;
// 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 (FontGlyphMetrics m in data.Metrics)
{
Vector2 s = new Vector2(data.PointSize) / m.ScaleFactor;
entryScaledAdvanceWidth += m.AdvanceWidth * s.X;
}
}
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;
}
// 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;
if (currentGraphemeIsTransformed && !isFirstInGrapheme)
{
// For transformed glyphs after the first in the grapheme we advance
// horizontally using the horizontal advance not the line height.
// This gives us the correct total advance across the grapheme.
advanceW = scale.X * metric.AdvanceWidth;
}
visitor.Visit(
new GlyphLayout(
new Glyph(metric, data.PointSize),
data.Font,
boundsLocation,
glyphOrigin,
decorationOrigin,
advanceW,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
emitted = true;
j++;
}
if (currentGraphemeIsTransformed)
{
// Advance horizontally between entries inside the transformed grapheme.
boxLocation.X += entryScaledAdvanceWidth;
penLocation.X += entryScaledAdvanceWidth;
}
if (currentGraphemeIsTransformed)
{
boundsLocation.X += entryScaledAdvanceWidth;
}
if (data.IsLastInGrapheme)
{
penLocation.Y += layoutAdvance;
boxLocation.X = lineOriginX;
penLocation.X = lineOriginX;
boundsLocation.Y += data.ScaledAdvance;
boundsLocation.X = boundsLineOriginX;
}
}
boxLocation.Y = originY;
penLocation.Y = originY;
if (emitted)
{
boxLocation.X += advanceX;
penLocation.X += xLineAdvance;
}
}
/// <summary>
/// Positions one line of vertical-mixed text (<see cref="LayoutMode.VerticalMixedLeftRight"/>
/// and <see cref="LayoutMode.VerticalMixedRightLeft"/>). Transformed entries are rotated 90°
/// and laid out sideways using the font's horizontal metrics while the pen still advances
/// along Y; naturally-vertical entries are positioned using their vertical metrics.
/// </summary>
/// <typeparam name="TVisitor">The concrete visitor struct type.</typeparam>
/// <param name="textBox">The containing text box (used to look up sibling lines for block alignment).</param>
/// <param name="textLine">The line being laid out.</param>
/// <param name="direction">The resolved text direction for this line.</param>
/// <param name="maxScaledAdvance">The longest scaled line advance in the block (or wrapping length).</param>
/// <param name="options">The text options used to position the line.</param>
/// <param name="index">The zero-based visual index of this line within the block.</param>
/// <param name="boxLocation">The running top-left position of the glyph boxes; advanced by this method.</param>
/// <param name="penLocation">The running pen position used for glyph placement; advanced by this method.</param>
/// <param name="visitor">The visitor that receives each positioned glyph.</param>
private static void LayoutLineVerticalMixed<TVisitor>(
TextBox textBox,
TextLine textLine,
TextDirection direction,
float maxScaledAdvance,
TextOptions options,
int index,
ref Vector2 boxLocation,
ref Vector2 penLocation,
ref TVisitor visitor)
where TVisitor : struct, IGlyphLayoutVisitor
{
float originY = penLocation.Y;
float offsetY = 0;
// Offset the location to center the line horizontally.
float scaledMaxLineHeight = textLine.ScaledMaxLineHeight;
// Recover the unscaled line height to calculate proper centering
float unscaledLineHeight = scaledMaxLineHeight / options.LineSpacing;
float advanceX = scaledMaxLineHeight;
// Center the glyphs within the extra space created by LineSpacing
float offsetX = (advanceX - unscaledLineHeight) * .5F;
float xLineAdvance = advanceX - offsetX;
// Set the Y-Origin for the line.
switch (options.VerticalAlignment)
{
case VerticalAlignment.Top:
offsetY = 0;
break;
case VerticalAlignment.Center:
offsetY -= maxScaledAdvance * .5F;
break;
case VerticalAlignment.Bottom:
offsetY -= maxScaledAdvance;
break;
}
// Set the alignment of lines within the text.
if (direction == TextDirection.LeftToRight)
{
switch (options.TextAlignment)
{
case TextAlignment.End:
offsetY += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
else
{
switch (options.TextAlignment)
{
case TextAlignment.Start:
offsetY += maxScaledAdvance - textLine.ScaledLineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (textLine.ScaledLineAdvance * .5F);
break;
}
}
bool isFirstLine = index == 0;
if (isFirstLine)
{
// In vertical-mixed layout, first-line Y ascent compensation introduces
// unwanted leading space before the first glyph. Keep first-line handling
// limited to X-origin block alignment only.
// Set the X-Origin for horizontal alignment.
switch (options.HorizontalAlignment)
{
case HorizontalAlignment.Right:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetX -= textBox.TextLines[i].ScaledMaxLineHeight;
}
break;
case HorizontalAlignment.Center:
for (int i = 0; i < textBox.TextLines.Count; i++)
{
offsetX -= textBox.TextLines[i].ScaledMaxLineHeight * .5F;
}
break;
}
}
penLocation.Y += offsetY;
penLocation.X += offsetX;
Vector2 boundsLocation = boxLocation;
bool emitted = false;
for (int i = 0; i < textLine.Count; i++)
{
GlyphLayoutData data = textLine[i];
float layoutAdvance = data.ScaledAdvance;
float scaledLineHeight = data.ScaledLineHeight / options.LineSpacing;
if (data.IsNewLine)
{
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),
data.Font,
boundsLocation,
hardBreakGlyphOrigin,
hardBreakDecorationOrigin,
xLineAdvance,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
data.BidiRun.Level,
true,
data.GraphemeIndex,
data.StringIndex));
boxLocation.X += advanceX;
boxLocation.Y = originY;
penLocation.X += xLineAdvance;
penLocation.Y = originY;
boundsLocation.X += advanceX;
boundsLocation.Y = originY;
return;
}
if (data.IsTransformed)
{
int j = 0;
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.
// Calculate the initial horizontal offset to center the glyph baseline:
// - 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 - scaledLineHeight) * .5F;
// Adjust the horizontal offset further by considering the descender differences:
// - Subtract the current glyph's descender (data.ScaledDescender) to align it properly.
float descenderAbs = Math.Abs(data.ScaledDescender);
float descenderDelta = (Math.Abs(textLine.ScaledMaxDescender) - descenderAbs) * .5F;
float centerOffsetX = baselineDelta + descenderAbs + descenderDelta;
Vector2 glyphOrigin = penLocation + new Vector2(centerOffsetX, 0);
visitor.Visit(
new GlyphLayout(
new Glyph(metric, data.PointSize),
data.Font,
boundsLocation,
glyphOrigin,
glyphOrigin,
advanceX,
data.ScaledAdvance,
GlyphLayoutMode.VerticalRotated,
data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
emitted = true;
j++;
}
}
else
{
int j = 0;
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;
// 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.
float glyphAlignX = (scaledLineHeight - (metric.AdvanceWidth * scale.X)) * .5F;
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;
visitor.Visit(
new GlyphLayout(
new Glyph(metric, data.PointSize),
data.Font,
boundsLocation,
glyphOrigin,
decorationOrigin,
advanceX,
data.ScaledAdvance,
GlyphLayoutMode.Vertical,
data.BidiRun.Level,
i == 0 && j == 0,
data.GraphemeIndex,
data.StringIndex));
emitted = true;
j++;
}
}
penLocation.Y += layoutAdvance;
boundsLocation.Y += data.ScaledAdvance;
}
boxLocation.Y = originY;
penLocation.Y = originY;
if (emitted)
{
boxLocation.X += advanceX;
penLocation.X += xLineAdvance;
}
}
/// <summary>
/// Shapes a single font run — maps codepoints in <paramref name="text"/> to glyph ids using
/// <paramref name="font"/>, then runs GSUB substitution and GPOS positioning. Codepoints that
/// the font cannot map are recorded for a later fallback pass.
/// </summary>
/// <param name="text">The run-relative text slice to shape.</param>
/// <param name="start">The starting grapheme index (absolute within the original input).</param>
/// <param name="textRuns">The ordered list of resolved text runs.</param>
/// <param name="textRunIndex">The index of the current text run; advanced as the enumerator crosses run boundaries.</param>
/// <param name="codePointIndex">The running codepoint index (absolute within the original input).</param>
/// <param name="bidiRunIndex">The running bidi run index.</param>
/// <param name="isFallbackRun">
/// <see langword="true"/> if this call is the fallback-font pass (in which case unmapped codepoints
/// may still emit <c>.notdef</c> glyphs).
/// </param>
/// <param name="font">The font to shape with.</param>
/// <param name="bidiRuns">The resolved bidi runs covering the whole input.</param>
/// <param name="bidiMap">A codepoint → bidi-run mapping accumulated across shaping passes.</param>
/// <param name="substitutions">The GSUB substitution collection to write into.</param>
/// <param name="positionings">The GPOS positioning collection to write into.</param>
/// <returns>
/// <see langword="true"/> if every codepoint mapped successfully; <see langword="false"/> if any
/// codepoint remains unmapped (so a fallback-font pass is needed).
/// </returns>
private static bool DoFontRun(
ReadOnlySpan<char> text,
int start,
IReadOnlyList<TextRun> textRuns,
ref int textRunIndex,
ref int codePointIndex,
ref int bidiRunIndex,
bool isFallbackRun,
Font font,
BidiRun[] bidiRuns,
Dictionary<int, int> bidiMap,
GlyphSubstitutionCollection substitutions,
GlyphPositioningCollection positionings)
{
// For each run we start with a fresh substitution collection to avoid
// overwriting the glyph ids.
substitutions.Clear();
// Enumerate through each grapheme in the text.
int graphemeIndex = start;
SpanGraphemeEnumerator graphemeEnumerator = new(text);
while (graphemeEnumerator.MoveNext())
{
ReadOnlySpan<char> grapheme = graphemeEnumerator.Current.Span;
int graphemeMax = grapheme.Length - 1;
int graphemeCodePointIndex = 0;
int charIndex = 0;
while (textRunIndex < textRuns.Count - 1 && graphemeIndex == textRuns[textRunIndex].End)
{
textRunIndex++;
}
// Now enumerate through each codepoint in the grapheme.
bool skipNextCodePoint = false;
SpanCodePointEnumerator codePointEnumerator = new(grapheme);
while (codePointEnumerator.MoveNext())
{
if (codePointIndex == bidiRuns[bidiRunIndex].End)
{
bidiRunIndex++;
}
if (skipNextCodePoint)
{
codePointIndex++;
graphemeCodePointIndex++;
continue;
}
bidiMap[codePointIndex] = bidiRunIndex;
int charsConsumed = 0;
CodePoint current = codePointEnumerator.Current;
charIndex += current.Utf16SequenceLength;
CodePoint? next = graphemeCodePointIndex < graphemeMax
? CodePoint.DecodeFromUtf16At(grapheme, charIndex, out charsConsumed)
: null;
charIndex += charsConsumed;
// Get the glyph id for the codepoint and add to the collection.
bool hasGlyph = font.FontMetrics.TryGetGlyphId(current, next, out ushort glyphId, out skipNextCodePoint);
// Unsupported default-ignorable code points such as FE0F should not block
// GSUB sequences like emoji ZWJ ligatures. Preserve joiners explicitly.
if (!hasGlyph &&
UnicodeUtility.IsDefaultIgnorableCodePoint((uint)current.Value) &&
!UnicodeUtility.ShouldRenderWhiteSpaceOnly(current) &&
!CodePoint.IsZeroWidthJoiner(current) &&
!CodePoint.IsZeroWidthNonJoiner(current))
{
codePointIndex++;
graphemeCodePointIndex++;
continue;
}
substitutions.AddGlyph(glyphId, current, (TextDirection)bidiRuns[bidiRunIndex].Direction, textRuns[textRunIndex], codePointIndex);
codePointIndex++;
graphemeCodePointIndex++;
}
graphemeIndex++;
}
// Apply the simple and complex substitutions.
// TODO: Investigate HarfBuzz normalizer.
SubstituteBidiMirrors(font.FontMetrics, substitutions);
font.FontMetrics.ApplySubstitution(substitutions);
return !isFallbackRun
? positionings.TryAdd(font, substitutions)
: positionings.TryUpdate(font, substitutions);
}
/// <summary>
/// Substitutes mirrored bracket glyphs (for example <c>(</c> ↔ <c>)</c>) inside right-to-left
/// bidi runs, per Unicode Bidirectional Algorithm rule L4. Relies on the font's <c>rtlm</c>
/// feature when available and falls back to the Unicode mirror table otherwise.
/// </summary>
/// <param name="fontMetrics">The font metrics used to look up mirrored glyph ids.</param>
/// <param name="collection">The substitution collection whose glyphs will be rewritten in place.</param>
private static void SubstituteBidiMirrors(FontMetrics fontMetrics, GlyphSubstitutionCollection collection)
{
for (int i = 0; i < collection.Count; i++)
{
GlyphShapingData data = collection[i];
if (data.Direction != TextDirection.RightToLeft)
{
continue;
}
if (!CodePoint.TryGetBidiMirror(data.CodePoint, out CodePoint mirror))
{
continue;
}
if (fontMetrics.TryGetGlyphId(mirror, out ushort glyphId))
{
collection.Replace(i, glyphId, KnownFeatureTags.RightToLeftMirroredForms);
}
}
// TODO: This only replaces certain glyphs. We should investigate the specification further.
// https://www.unicode.org/reports/tr50/#vertical_alternates
if (collection.TextOptions.LayoutMode.IsHorizontal())
{
return;
}
for (int i = 0; i < collection.Count; i++)
{
GlyphShapingData data = collection[i];
if (CodePoint.GetVerticalOrientationType(data.CodePoint) is VerticalOrientationType.Upright or VerticalOrientationType.TransformUpright)
{
continue;
}
if (!CodePoint.TryGetVerticalMirror(data.CodePoint, out CodePoint mirror))
{
continue;
}
if (fontMetrics.TryGetGlyphId(mirror, out ushort glyphId))
{
collection.Replace(i, glyphId, KnownFeatureTags.VerticalAlternates);
}
}
}
/// <summary>
/// Calculates the X offset to apply to a single line of horizontal text so that it is positioned
/// within the wrapping block according to the requested horizontal and text alignment.
/// </summary>
/// <remarks>
/// The returned offset is in unscaled (pre-Dpi) units and is combined with the pen location at
/// layout time. The result depends on the text direction because <see cref="TextAlignment.Start"/>
/// and <see cref="TextAlignment.End"/> flip under right-to-left text.
/// </remarks>
/// <param name="lineAdvance">The scaled advance of the current line.</param>
/// <param name="maxScaledAdvance">The scaled advance of the widest line (or wrapping length, whichever is greater).</param>
/// <param name="horizontalAlignment">Block-level horizontal alignment of the whole text.</param>
/// <param name="textAlignment">Per-line alignment within the block.</param>
/// <param name="direction">The resolved text direction for this line.</param>
/// <returns>The X offset to add to the line's pen location.</returns>
internal static float CalculateLineOffsetX(
float lineAdvance,
float maxScaledAdvance,
HorizontalAlignment horizontalAlignment,
TextAlignment textAlignment,
TextDirection direction)
{
float offsetX = 0;
// Set the X-Origin for horizontal alignment.
switch (horizontalAlignment)
{
case HorizontalAlignment.Right:
offsetX = -maxScaledAdvance;
break;
case HorizontalAlignment.Center:
offsetX = -(maxScaledAdvance * .5F);
break;
}
// Set the alignment of lines within the text.
if (direction == TextDirection.LeftToRight)
{
switch (textAlignment)
{
case TextAlignment.End:
offsetX += maxScaledAdvance - lineAdvance;
break;
case TextAlignment.Center:
offsetX += (maxScaledAdvance * .5F) - (lineAdvance * .5F);
break;
}
}
else
{
switch (textAlignment)
{
case TextAlignment.Start:
offsetX += maxScaledAdvance - lineAdvance;
break;
case TextAlignment.Center:
offsetX += (maxScaledAdvance * .5F) - (lineAdvance * .5F);
break;
}
}
return offsetX;
}
/// <summary>
/// Calculates the Y offset to apply to a single line of vertical text so that it is positioned
/// within the wrapping block according to the requested vertical and text alignment.
/// </summary>
/// <remarks>
/// The returned offset is in unscaled (pre-Dpi) units and is combined with the pen location at
/// layout time. The result depends on the text direction because <see cref="TextAlignment.Start"/>
/// and <see cref="TextAlignment.End"/> flip under right-to-left text.
/// </remarks>
/// <param name="lineAdvance">The scaled advance of the current line.</param>
/// <param name="maxScaledAdvance">The scaled advance of the longest line (or wrapping length, whichever is greater).</param>
/// <param name="verticalAlignment">Block-level vertical alignment of the whole text.</param>
/// <param name="textAlignment">Per-line alignment within the block.</param>
/// <param name="direction">The resolved text direction for this line.</param>
/// <returns>The Y offset to add to the line's pen location.</returns>
internal static float CalculateLineOffsetY(
float lineAdvance,
float maxScaledAdvance,
VerticalAlignment verticalAlignment,
TextAlignment textAlignment,
TextDirection direction)
{
float offsetY = 0;
// Set the Y-Origin for the line.
switch (verticalAlignment)
{
case VerticalAlignment.Top:
offsetY = 0;
break;
case VerticalAlignment.Center:
offsetY -= maxScaledAdvance * .5F;
break;
case VerticalAlignment.Bottom:
offsetY -= maxScaledAdvance;
break;
}
// Set the alignment of lines within the text.
if (direction == TextDirection.LeftToRight)
{
switch (textAlignment)
{
case TextAlignment.End:
offsetY += maxScaledAdvance - lineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (lineAdvance * .5F);
break;
}
}
else
{
switch (textAlignment)
{
case TextAlignment.Start:
offsetY += maxScaledAdvance - lineAdvance;
break;
case TextAlignment.Center:
offsetY += (maxScaledAdvance * .5F) - (lineAdvance * .5F);
break;
}
}
return offsetY;
}
}
}