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

535 lines
18 KiB
Markdown

# 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<LineMetrics> lines = block.GetLineMetrics(320);
ReadOnlyMemory<GraphemeMetrics> graphemes = block.GetGraphemeMetrics(320);
ReadOnlyMemory<WordMetrics> words = block.GetWordMetrics(320);
ReadOnlyMemory<GlyphMetrics> glyphs = block.GetGlyphMetrics(320);
```
Method-returned measurement collections use `ReadOnlyMemory<T>` 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<T>` 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<LineMetrics> lines = metrics.LineMetrics;
ReadOnlySpan<GraphemeMetrics> graphemes = metrics.GraphemeMetrics;
ReadOnlySpan<WordMetrics> 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<GlyphMetrics> 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<LineLayout> 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<GraphemeMetrics> 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<FontRectangle> selection = line.GetSelectionBounds(caret, next);
ReadOnlyMemory<FontRectangle> wordSelection = line.GetSelectionBounds(word);
ReadOnlyMemory<GlyphMetrics> 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<FontRectangle> 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<FontRectangle>` 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<FontRectangle> 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<FontRectangle> 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<FontRectangle> 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<FontRectangle> 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<FontRectangle> 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<FontRectangle> selection = metrics.GetSelectionBounds(anchor, caret);
```
For per-line UI:
```csharp
ReadOnlyMemory<LineLayout> lines = block.GetLineLayouts(wrappingLength);
foreach (LineLayout line in lines.Span)
{
ReadOnlySpan<GraphemeMetrics> graphemes = line.GraphemeMetrics;
ReadOnlyMemory<GlyphMetrics> 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.