// Copyright (c) Six Labors. // Licensed under the Six Labors Split License. using System; using System.Collections.Generic; 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. => 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); } } }