// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
using System;
using System.Collections.Generic;
using System.Numerics;
using SixLabors.Fonts.Unicode;
namespace SixLabors.Fonts {
///
/// Line break candidate collection and layout-level tailoring.
///
internal static partial class TextLayout
{
private const int SoftHyphen = 0x00AD;
private const int StandardHyphen = 0x2010;
private const int StandardEllipsis = 0x2026;
///
/// 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 logical text line and line break opportunities before line breaking.
public static LogicalTextLine ComposeLogicalLine(
in ShapedText shapedText,
ReadOnlySpan text,
TextOptions options)
{
bool isHorizontalLayout = shapedText.LayoutMode.IsHorizontal();
bool isVerticalLayout = shapedText.LayoutMode.IsVertical();
bool isVerticalMixedLayout = shapedText.LayoutMode.IsVerticalMixed();
int graphemeIndex = 0;
int codePointIndex = 0;
int glyphSearchIndex = 0;
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];
// 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())
{
WordSegment wordSegment = wordEnumerator.Current;
int wordSegmentGraphemeStart = graphemeIndex;
SpanGraphemeEnumerator graphemeEnumerator = new(wordSegment.Span);
while (graphemeEnumerator.MoveNext())
{
// Now enumerate through each codepoint in the grapheme.
ReadOnlySpan grapheme = graphemeEnumerator.Current.Span;
int graphemeCodePointIndex = 0;
SpanCodePointEnumerator codePointEnumerator = new(grapheme);
while (codePointEnumerator.MoveNext())
{
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;
}
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;
}
metrics.Add(data.Metrics);
}
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;
}
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;
}
// 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;
}
// Calculate the advance for the current codepoint.
// 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)
{
glyphAdvance = glyph.AdvanceWidth;
}
else
{
glyphAdvance = glyph.AdvanceHeight;
}
decomposedAdvances[0] = glyphAdvance;
bool isSoftHyphen = codePoint.Value == SoftHyphen;
if (isSoftHyphen)
{
glyphAdvance = 0;
decomposedAdvances[0] = 0;
}
else if (CodePoint.IsTabulation(codePoint))
{
if (options.TabWidth > -1F)
{
// 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))
{
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 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)
{
for (int i = 1; i < metrics.Count; i++)
{
float a = metrics[i].AdvanceWidth;
if (isDecomposed)
{
glyphAdvance += a;
decomposedAdvances[i] = a;
}
else if (a > glyphAdvance)
{
glyphAdvance = a;
}
}
}
else
{
for (int i = 1; i < metrics.Count; i++)
{
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++)
{
decomposedAdvances[i] *= scaleAX;
}
}
else
{
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;
// For non-decomposed glyphs the length is always 1.
int glyphDataIndex = 0;
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];
// Work out the scaled metrics for the glyph.
while (glyphData[glyphDataIndex].Data.IsPlaceholder)
{
glyphDataIndex++;
}
GlyphPositioningCollection.GlyphPositioningData positionedGlyph = glyphData[glyphDataIndex];
FontGlyphMetrics metric = positionedGlyph.Metrics;
// 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))
{
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;
// 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;
// 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;
// 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;
}
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,
positionedGlyph.Font,
options));
}
// Add our metrics to the line.
textLine.Add(
isDecomposed ? new FontGlyphMetrics[] { metric } : metrics,
positionedGlyph.Font,
pointSize,
decomposedAdvance,
lineHeight,
ascender,
descender,
delta,
shapedText.BidiRuns[shapedText.BidiMap[codePointIndex]],
graphemeIndex,
isLastInGrapheme,
codePointIndex,
graphemeCodePointIndex,
shouldRotate || shouldOffset,
isDecomposed,
stringIndex,
mode,
options.LineSpacing,
hyphenationMarkerIndex);
glyphDataIndex++;
}
codePointIndex++;
graphemeCodePointIndex++;
}
stringIndex += grapheme.Length;
graphemeIndex++;
}
wordSegments.Add(new WordSegmentRun(
wordSegmentGraphemeStart,
graphemeIndex,
wordSegment.Utf16Offset,
wordSegment.Utf16Offset + wordSegment.Utf16Length));
}
// 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))
{
for (int i = 0; i < endGlyphData.Count; i++)
{
GlyphPositioningCollection.GlyphPositioningData data = endGlyphData[i];
if (data.Data.IsPlaceholder)
{
textLine.AddPlaceholder(
data,
graphemeIndex,
stringIndex,
isHorizontalLayout,
isVerticalMixedLayout,
options.LineSpacing);
}
}
}
// Line break candidates are width-independent and belong with the composed logical line.
List lineBreaks = CollectLineBreaks(text, hyphenationMarkerCodePoint.HasValue);
return new LogicalTextLine(textLine, lineBreaks, wordSegments, hyphenationMarkers);
}
///
/// 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;
if (maxLines == 0)
{
TextDirection emptyTextDirection = options.TextDirection == TextDirection.RightToLeft
? TextDirection.RightToLeft
: TextDirection.LeftToRight;
return new TextBox([], emptyTextDirection);
}
TextDirection textDirection = GetTextDirection(logicalLine, options);
List textLines = [];
TextLineBreakEnumerator lineEnumerator = new(logicalLine, options);
while (lineEnumerator.MoveNext(wrappingLength))
{
textLines.Add(lineEnumerator.Current);
}
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.
///
///
///
/// is the Unicode-conforming default line breaker. Its default
/// constructor remains independent from layout policy so the Unicode conformance tests continue to
/// describe only the default UAX #14 behavior. This method is the boundary where layout-specific
/// tailoring is requested.
///
///
/// The line breaker itself is streaming and does not allocate. Layout materializes the resulting
/// break opportunities because the line fitting loop scans the same candidates repeatedly while it
/// removes finalized lines from the front of the shaped text line.
///
///
/// Solidus handling is intentionally conservative. UAX #14 classifies U+002F SOLIDUS as SY, which
/// gives ordinary text a break opportunity after a slash. That is valid for the default algorithm,
/// but it produced undesirable layout in issue 448 for ordinary slash-separated text. At the same
/// time, UAX #14 section 8 explicitly calls out URL tailoring that can allow breaks after slash
/// separated URL segments even when the next segment starts with a digit. The result here is:
/// keep default slash behavior for standard enumeration, suppress ordinary slash breaks for layout,
/// and reintroduce the narrow URL numeric-segment break only for URL-like runs.
///
///
/// 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, bool includeHyphenationBreaks)
{
LineBreakEnumerator lineBreakEnumerator = new(text, tailorUrls: true);
List lineBreaks = [];
while (lineBreakEnumerator.MoveNext())
{
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 font used to shape and render the marker.
/// 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,
Font font,
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 },
font,
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
};
}
}