Table of Contents

Paragraph layout

Egami.Fonts lays out paragraphs in the Egami.Fonts.Layout namespace: it finds where lines may break (UAX #14), turns styled text into shaped runs with the shaper from Unicode text and shaping, and builds lines at a width, with alignment and justification, line height, spacing, tabs, max lines and ellipsis, inline placeholders, vertical lines, decorations and shadows, and carets and selection for editors. The result is plain geometry (lines of GlyphRuns and decoration shapes), so Egami.Drawing's canvas, a PDF canvas and SVG draw it the same way.

Line breaking

LineBreaks.Enumerate walks a text's break opportunities through a LineBreakEnumerator that allocates nothing. Each LineBreak gives the index the next line would start at, whether the line must end there (after a newline, CR LF, NEL, VT, FF, U+2028 or U+2029) and whether the break follows a soft hyphen (U+00AD), which shows a hyphen when a line ends there. The last break is always at the end of the text.

// Where lines may end: after spaces and hyphens, and always at the end; a line must end after a newline.
List<int> breaks = [];
foreach (LineBreak b in LineBreaks.Enumerate("A well-known fact.\nNext"))
{
    breaks.Add(b.Index);                       // 2, 7, 13, 19 (mandatory), 23
}

// CSS switches: break-all lets letters break like ideographs.
LineBreakOptions breakAll = new(WordBreak: WordBreak.BreakAll);
int count = 0;
foreach (LineBreak b in LineBreaks.Enumerate("word", breakAll))
{
    count++;                                   // 4: after every letter
}

The rules are Unicode's line breaking algorithm (UAX #14) from Egami's own Unicode 17.0 tables, with each character's LineBreakClass from UnicodeData.GetLineBreakClass. LineBreakOptions adds CSS Text 3's switches:

  • WordBreak.BreakAll lets letters and digits of any script break like ideographs; WordBreak.KeepAll keeps ideographs, kana and Hangul together like the letters of a word, for Korean text with spaces.
  • LineBreakStrictness sets how Chinese and Japanese text breaks. Auto is CSS's default and the same as Normal: a line may start with a small kana, and in Chinese and Japanese (Language "zh" or "ja") with the hyphens U+2010, U+2013, U+301C and U+30A0. Loose also lets iteration marks, centred punctuation, wide prefixes and suffixes and inseparable characters break in Chinese and Japanese. Strict is UAX #14's own rules, which pass Unicode's LineBreakTest.txt in full. Anywhere breaks between any two grapheme clusters.
  • OverflowWrap says whether a piece too wide for its line may break between grapheme clusters; line filling uses it.

Thai, Lao, Khmer and Myanmar text breaks only at spaces and zero width spaces: Egami has no dictionaries. An object replacement character (U+FFFC, as placeholders use) breaks before and after.

Rich text and paragraphs

A RichText is a string with StyleSpans over it (start and length in UTF-16 units, never overlapping) and a Placeholder for each object replacement character (U+FFFC). RichTextBuilder builds one: Push a TextStyle, Append text, AddPlaceholder for an inline box, Pop back. Pushed styles nest, and the result has one flat span per stretch of equal style.

// Font families come from a collection; later families cover what the first lacks.
FontCollection fonts = new();
fonts.AddFile(System.IO.Path.Combine(folder, "NotoSans-Regular.ttf"));
fonts.AddFile(System.IO.Path.Combine(folder, "NotoSansArabic-Regular.ttf"));
fonts.TryGetFamily("Noto Sans", out FontFamily latin);
fonts.TryGetFamily("Noto Sans Arabic", out FontFamily arabic);

// Styles nest: the inner span keeps the outer one's size and adds a color and an underline.
RichText text = new RichTextBuilder()
    .Append("Peace is ")
    .Push(new TextStyle { Size = 20 })
    .Append("\u0633\u0644\u0627\u0645 ")
    .Push(new TextStyle { Color = new Rgba8(200, 30, 30, 255), Decoration = new TextDecoration() })
    .Append("in Arabic")
    .Pop()
    .Pop()
    .AddPlaceholder(new Placeholder(16, 16, PlaceholderAlignment.Middle))
    .ToRichText();

ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [latin, arabic], Size = 16, LineHeight = LineHeight.Multiple(1.4f) },
    Align = TextAlign.Justify,
    LineBreaking = new LineBreakOptions(WordBreak: WordBreak.Normal),
};

// Shaped once: lay it out at any width later.
Paragraph paragraph = Paragraph.Create(text, style);

Every TextStyle field is optional. An unset field takes the enclosing builder style's, then the ParagraphStyle's DefaultStyle, then Egami's default (16 units, black, normal line height):

  • Fonts: FontFamilies in order of preference (later families and the paragraph's Fallback cover what the first lacks, per grapheme cluster), Size, FontStyle, Variations, Language and OpenType Features.
  • Paint: Color (an Rgba8), or a Paint, which is any ITextPaint; Egami.Drawing's Paint is one, so gradients and image paints reach text. Background or BackgroundPaint fill behind the text, Decoration adds a TextDecoration (its TextDecorationLines, TextDecorationStyle and TextDecorationSkipInk), and Shadows a list of TextShadows.
  • Spacing: LetterSpacing after each grapheme cluster (which turns optional ligatures off), WordSpacing, a LineHeight (Normal, a Multiple of the font size or an Absolute height, by its LineHeightKind) and a BaselineShift.

A Placeholder has a width, a height and a PlaceholderAlignment: on the baseline at a given offset, above or below it, or at the line box's top, bottom or middle. Bidi, line breaking and indexes treat it as one character.

ParagraphStyle holds the rest: the DefaultStyle, the paragraph Direction, TextAlign, TextAlignLast and TextJustify, the LineBreakOptions in LineBreaking, LineFill (greedy or balanced), the Hyphen and Ellipsis strings, MaxLines and MaxHeight, a Strut, TrimEdges, TabSize or TabStops with their TabAlignment, the WritingMode and TextOrientation, and the font Fallback.

Paragraph.Create prepares the text once: it resolves the styles, finds the bidi levels and break opportunities, splits the text into runs where the bidi level, script, font or a shaping field changes, and shapes each run. Paint fields never split a run, so a color change inside a ligature keeps the ligature. A paragraph is immutable and safe to share between threads.

Laying out lines

Paragraph.Layout lays a paragraph out at a width and returns a TextLayout: its Width and Height, the LongestLine, and its Lines. Each TextLine has its text range (Start, Length), its line box (Top, Ascent, Descent, Baseline), its content's Left and Width (trailing spaces hang outside it), whether it ends at a HardBreak, and its LayoutRuns in visual order. A run is glyphs (Glyphs, a GlyphRun already at its layout position), a placeholder's box, a tab or a break, by its LayoutRunKind, with its BidiLevel and StyleIndex.

ParagraphStyle style = new() { DefaultStyle = new TextStyle { FontFamilies = [family], Size = 16 } };
Paragraph paragraph = Paragraph.Create("A paragraph is shaped once and laid out at any width.", style);

// Each width gives an immutable layout: lines of glyph runs, positioned from the box's top-left corner.
TextLayout narrow = paragraph.Layout(120);
TextLayout wide = paragraph.Layout(400);
foreach (TextLine line in narrow.Lines)
{
    string text = paragraph.Text[line.Start..line.End];   // the line's text, its trailing space included
    float baseline = line.Baseline;                        // y of the baseline
    foreach (LayoutRun run in line.Runs)
    {
        GlyphRun? glyphs = run.Glyphs;                     // glyphs at their layout positions
    }
}

// No wrapping: the box is as wide as the longest line.
TextLayout unbounded = paragraph.Layout(float.PositiveInfinity);
  • Lines fill greedily, as browsers do: each takes the text up to the last break opportunity that fits. A taken soft hyphen shows the Hyphen string, counted in the line's width. A piece too wide for any line overflows, or with OverflowWrap breaks between grapheme clusters.
  • Where a line breaks inside a shaped run at a point the shaper marks unsafe (an Arabic join, a contextual substitution), the pieces on each side are shaped again, so a line has the glyphs it would have on its own.
  • Each line reorders its own bidi runs (UAX #9 rules L1 and L2): trailing spaces take the paragraph's direction.
  • LineFill.Balanced evens the lines of a short paragraph (up to 6 lines): of the breaks that fit, the ones that leave the least squared space on every line, the last included, as Chromium's text-wrap: balance does.
  • float.PositiveInfinity lays the paragraph out without wrapping; the box is then as wide as the longest line.

Line boxes and spacing

Line boxes follow CSS Inline Layout 3, with the numbers browsers use:

  • Each run's inline box is its font's ascent and descent, rounded to whole units as browsers round them, plus the leading its LineHeight adds: Normal adds the font's line gap, Multiple and Absolute set the height (a computed height is floored to 1/64 unit, browsers' layout unit, so stacked lines do not drift). The leading is split half above and half below, the top half rounded down. A BaselineShift moves the box down (or up, when negative).
  • Every line also holds the root inline box, from the paragraph's DefaultStyle, as CSS's strut does; a Strut adds a minimum box of its own, and with Force it alone sets every line box, for line grids.
  • Placeholders sit by their alignment: on the baseline at an offset, above or below it, or at the middle (half the default font's x-height above the baseline, as CSS's middle); boxes aligned to the line's top or bottom stretch it last.
  • TrimEdges drops the space above the first line's text and below the last one's (CSS text-box-trim).
  • Letter spacing follows each grapheme cluster and word spacing each word separator; tabs advance to the next of the TabStops, which align the text after them at their start, end, centre or first decimal separator, or else to the next multiple of TabSize spaces, at least half a space on.
  • Characters take the first family that has them, as CSS matches fonts, so spaces and punctuation between words of another script stay in the first family.

Line boxes equal Chromium's within 0.01 unit on every line-height, strut, spacing, tab, placeholder and mixed-font case, and glyph positions equal its within 0.01 once its 1/64 layout unit is allowed for.

Alignment and justification

TextAlign places each line: Start and End follow the paragraph's direction, Left, Right and Center do not, and Justify stretches lines to the full width. The last line, and lines before a hard break, use TextAlignLast, whose Auto is the start for justified paragraphs. Trailing spaces hang past the line's end and never count for alignment.

TextJustify says where justified lines stretch: InterWord at word separators, InterCharacter between any two grapheme clusters except inside cursive scripts such as Arabic, and Auto (the default) at word separators and around Chinese, Japanese and Korean characters, as browsers justify. None leaves justified lines at the start.

Kashida justifies Arabic the way Word and Arabic typesetting do: words grow with tatweels (U+0640, kashida) before the spaces widen. Each word gets one point, the best one the shaping marks safe for a tatweel (after Seen or Sad, before a final Heh, Alef or Lam, and so on), and whole tatweel glyphs of the run's font go there; ParagraphStyle.Kashida caps them at one (Low), two (Medium, the default) or four (High) per word, and word spaces take the rest. Lines break as they would without kashida, text in other scripts and fonts without a tatweel justify at spaces, and vertical lines never get kashida. The tatweels join the character before them, so carets, selections and hit tests stay on character boundaries, and DrawTextLayout marks them with an empty ActualText so extracted text keeps the paragraph's words.

// Justified Arabic lengthens words with tatweels (kashida) before it widens spaces, as Word does.
ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [arabic], Size = 16 },
    Align = TextAlign.Justify,
    Justify = TextJustify.Kashida,
    Kashida = KashidaLevel.Medium,             // up to two tatweels a word; Low one, High four
};
TextLayout layout = Paragraph.Create(text, style).Layout(260);
canvas.DrawTextLayout(layout, new System.Numerics.Vector2(20, 10));

Line limits and the ellipsis

MaxLines and MaxHeight stop the lines: at the limit, or before the line whose bottom would pass the height. When text is cut, TextLayout.DidExceed is true and HiddenRange says what was hidden; the last kept line drops whole grapheme clusters from its end until its text and the Ellipsis (U+2026 by default, shaped in the style of the last kept character) fit, and the ellipsis ends the line in the paragraph's direction, flagged Ellipsized; a line with room keeps its trailing space before it, as Chromium's line clamp does. A null Ellipsis cuts at the line's end. Ellipses sit where Chromium's -webkit-line-clamp puts them in Latin, Arabic and Chinese.

Metrics

A TextLayout also answers how big its text is: MinIntrinsicWidth (CSS min-content, the widest piece that cannot break; OverflowWrap.Anywhere lowers it to the widest grapheme cluster) and MaxIntrinsicWidth (max-content, the widest line without wrapping), equal to Chromium's; FirstBaseline and LastBaseline for aligning boxes on text; InkBounds, the union of the glyphs' ink and placeholder boxes, which can reach past the layout box; PlaceholderRects, one TextBounds per placeholder in order, where the caller draws them; and GetLineAt and GetLineAtY to find the line holding a character or a height.

Hit testing, carets and selection

Editors and selectable text build on four queries, all read-only and safe from any thread:

  • HitTest turns a point into the nearest TextPosition: a UTF-16 index at a grapheme cluster boundary and a TextAffinity that says which side it belongs to where an index has two places (the end of one line and the start of the next, or a direction change). Points outside the lines go to the nearest one; at a direction change, the position is the one the surrounding text gives the edge, as browsers resolve it.
  • GetCaret says where to draw a Caret: its primary CaretLine, and at a bidi boundary a secondary one at the other direction's edge. Inside a ligature, carets use the font's GDEF caret values, or split the ligature evenly by grapheme.
  • MoveCaret steps by CaretMove: grapheme, word, line start and end, paragraph start and end, and line up and down keeping a preferred x across short lines.
  • GetSelectionRects covers a range with rectangles, one per visually contiguous piece on each line, as tall as the line box or, with SelectionHeight.Tight, the fonts' ascent and descent. GetWordBoundary and GetLineBoundary give the word and line around an index.
// A click: the nearest caret position, then where to draw the caret.
TextPosition position = layout.HitTest(new System.Numerics.Vector2(40, 30));
Caret caret = layout.GetCaret(position);
CaretLine bar = caret.Primary;                     // x, top and bottom; Secondary at a bidi boundary

// Arrow keys: logical moves, and line moves that keep the column.
TextPosition right = layout.MoveCaret(position, CaretMove.NextGrapheme);
TextPosition down = layout.MoveCaret(position, CaretMove.LineDown, preferredX: bar.X);

// A selection: rectangles to fill, one per visually contiguous piece on each line.
IReadOnlyList<TextBounds> highlight = layout.GetSelectionRects(position.Index, down.Index);
Egami.Fonts.Unicode.TextSegment word = layout.GetWordBoundary(position.Index); // double-click

Hit tests, carets and selection rectangles equal Chromium's (caretPositionFromPoint, collapsed ranges and Range.getClientRects) on Latin with ligatures, mixed bidi, Arabic, a right-to-left paragraph, emoji, hard breaks and a clamped paragraph.

Decorations

A TextDecoration on a TextStyle draws underlines, overlines and line-throughs, solid, double, dotted, dashed or wavy. TextLayout.Decorations gives each as a DecorationShape per decorated stretch of a line: one position and thickness for the stretch, and its Segments (each a DecorationSegment from left to right), with gaps cut where glyph ink crosses an underline or overline (SkipInk.Auto, the default; Chinese, Japanese and Korean glyphs never cut). AddTo writes a shape's outline, and TextLayout.ToPath writes the whole layout, glyphs then decorations, to any IGlyphOutlineSink.

TextStyle underlined = new()
{
    FontFamilies = [family],
    Decoration = new TextDecoration { Lines = TextDecorationLines.Underline, Style = TextDecorationStyle.Wavy },
};
TextLayout layout = Paragraph.Create("Typography, quickly", new ParagraphStyle { DefaultStyle = underlined }).Layout(400);

foreach (DecorationShape line in layout.Decorations)
{
    // Top and Thickness place the line; Segments skip the descenders of y, p, g and q.
    System.Console.WriteLine($"{line.Line} at {line.Top}: {line.Segments.Count} pieces");
}

// Or everything as outlines, to fill with any path renderer.
PathRecorder path = new();
layout.ToPath(path);

Positions follow Chromium's text-decoration rules rather than the font's underline values: the thickness is a tenth of the font size, floored to whole pixels; the underline sits half a thickness below the baseline (or UnderlineOffset below it); the overline on the ascent; the line-through a third of the ascent up. Thicknesses, positions and skip-ink gaps equal Chromium's ink within half a pixel on sans and serif text. Vertical layouts have no decorations yet.

Drawing paragraphs

Canvas.DrawTextLayout (Egami.Drawing) draws a layout with its top-left corner at a point, line by line in CSS's order: text shadows, run backgrounds, underlines and overlines, glyphs and placeholders, then line-throughs. A run's paint is its style's Paint (an Egami.Drawing Paint) or Color; shadows blur with a standard deviation of half their blur radius, as CSS. Lines outside the clip or the optional visible rectangle are skipped, and a PlaceholderPainter draws each placeholder in its box. Canvas.DrawText into a RectF lays out and draws in one call, placing the lines by VerticalAlign, and TextLayout.ToPath(origin) gives glyphs and decorations as one Path. On a PDF canvas the glyph runs keep their text, and an ellipsis or a soft hyphen's hyphen is marked content whose ActualText is the text it stands for.

ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle
    {
        FontFamilies = [family],
        Size = 18,
        Color = new Rgba8(30, 30, 60, 255),
        Shadows = [new TextShadow(new System.Numerics.Vector2(1, 1), 2, new Rgba8(0, 0, 0, 80))],
    },
    Align = TextAlign.Justify,
};

// One call: wrap into a box, centred across the lines.
TextLayout boxed = canvas.DrawText("Paragraphs wrap, justify and drop shadows.", new RectF(10, 10, 300, 80), style, VerticalAlign.Center);

// Or lay out once and draw where needed, skipping lines outside the visible part.
TextLayout layout = Paragraph.Create("Laid out once, drawn many times.", style).Layout(300);
canvas.DrawTextLayout(layout, new System.Numerics.Vector2(10, 110), visibleRect: new RectF(0, 0, 320, 200));

// Inline placeholders (an icon, a badge) are drawn by a painter called with each one's box.
canvas.DrawTextLayout(layout, new System.Numerics.Vector2(10, 130), placeholderPainter: (c, index, box) => c.FillRoundRect(new RoundRect(box, 3), new Rgba8(200, 60, 40, 255)));

// The same text as one path: glyphs and decorations.
Egami.Drawing.Path outline = layout.ToPath(new System.Numerics.Vector2(10, 150));

Vertical lines

WritingMode.VerticalRl stacks top-to-bottom lines from right to left, as Chinese and Japanese books, and VerticalLr from left to right. TextOrientation.Mixed (the default) stands each character as UAX #50's Vertical_Orientation says: Chinese, Japanese and Korean upright, shaped top to bottom with the font's vertical metrics and alternates, and Latin turned a quarter clockwise on the central baseline; Upright stands everything upright and Sideways turns whole lines. The width given to Layout is the lines' length; the layout's Height is that length and its Width the columns added up. Along a line, positions are y (a line's Left and Width, a run's X, a caret's X), across the lines x (a line's Top, Baseline and Bottom); glyph runs and rectangles stay in x and y. Line breaks, line boxes, positions, hit tests, carets and selections equal Chromium's writing-mode output on Japanese, Chinese and mixed Latin.

LineFlow lays a paragraph out one line at a time, each at its own width, to flow text across columns and pages or around images: Paragraph.CreateFlow, then NextLine until it returns null. Each line's top is 0; place it where it goes, and Rewind to a line's start to take back a line that does not fit.

// One line at a time, each at its own width: two columns of four lines, the second wrapping around an image.
LineFlow flow = paragraph.CreateFlow();
List<TextLine> first = [], second = [];
while (first.Count < 4 && flow.NextLine(150) is TextLine line)
{
    first.Add(line);
}

while (flow.NextLine(second.Count < 2 ? 90 : 150) is TextLine line)
{
    second.Add(line);                                     // narrower beside the image
}

Line breaks equal headless Chromium's on a corpus of eleven scripts at five widths each, and on the feature cases; the few differences are Chromium's own (its older quotation mark rule, no break at U+2028, no break after a slash in URLs, letter spacing in Arabic).

Recipes

Short answers to common tasks in this area. Each one compiles and runs in Egami's test run.

A caption in a box

// A caption wrapped into the bottom of a photo, centred, on a translucent band as tall as the lines.
RectF box = new(20, 200, 360, 80);
ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [family], Size = 15, Color = Rgba8.White },
    Align = TextAlign.Center,
    MaxLines = 3,
};
TextLayout layout = Paragraph.Create("Harbour at dawn, seen from the old lighthouse; the fishing boats leave before the ferries.", style).Layout(box.Width);
float top = box.Y + box.Height - layout.Height;
canvas.FillRect(new RectF(box.X - 8, top - 6, box.Width + 16, layout.Height + 12), new SolidPaint(new Rgba8(0, 0, 0, 140)));
canvas.DrawTextLayout(layout, new System.Numerics.Vector2(box.X, top));

The layout's height is known before drawing, so the band behind the text fits the lines exactly; MaxLines stops a long caption from climbing up the photo.

A justified article column

// A justified column: each paragraph balanced, indented by its first line, with space between paragraphs.
ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [family], Size = 13, LineHeight = LineHeight.Multiple(1.4f) },
    Align = TextAlign.Justify,
    LineFill = LineFill.Balanced,
};
float y = 10;
foreach (string text in paragraphs)
{
    TextLayout layout = Paragraph.Create(text, style).Layout(260);
    canvas.DrawTextLayout(layout, new System.Numerics.Vector2(20, y));
    y += layout.Height + 8;
}

Balanced filling evens out line lengths, so justified lines stretch less; soft hyphens break long words only where a line needs them.

A chat bubble with an emoji

// A chat bubble: at most two lines, cut with an ellipsis; the bubble shrinks to the longest line, so short messages get
// short bubbles. Emoji come from the second family, character by character.
ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [text, emoji], Size = 15, Color = Rgba8.White },
    MaxLines = 2,
};
TextLayout message = Paragraph.Create("See you at the caf\u00E9 at eight? I will bring the maps, the snacks and the long list of places we still have not seen \U0001F389", style).Layout(240);
RoundRect bubble = new(new RectF(12, 12, message.LongestLine + 24, message.Height + 16), 14);
canvas.FillRoundRect(bubble, new SolidPaint(new Rgba8(0, 120, 255, 255)));
canvas.DrawTextLayout(message, new System.Numerics.Vector2(24, 20));
bool cut = message.DidExceed;                       // true: offer "show more"

LongestLine is the width the lines actually use, which is what a bubble should wrap; DidExceed and HiddenRange say what the ellipsis cut.

Japanese vertical text

// Japanese set vertically, columns right to left: the width given to Layout is the column height, and DrawText into a
// rectangle puts the first column at its right edge.
ParagraphStyle style = new()
{
    DefaultStyle = new TextStyle { FontFamilies = [family], Size = 18, Language = "ja" },
    WritingMode = WritingMode.VerticalRl,
    LineBreaking = new LineBreakOptions(Strictness: LineBreakStrictness.Strict),
};
TextLayout layout = canvas.DrawText("\u8AF8\u56FD\u9593\u306E\u53CB\u597D\u95A2\u4FC2\u306E\u767A\u5C55\u3092\u4FC3\u9032\u3059\u308B\u3053\u3068\u304C\u809D\u8981\u3067\u3042\u308B\u306E\u3067\u3001\u56FD\u969B\u9023\u5408\u306E\u8AF8\u56FD\u6C11\u306F\u3001\u56FD\u9023\u61B2\u7AE0\u306B\u304A\u3044\u3066\u3001\u57FA\u672C\u7684\u4EBA\u6A29\u3001\u4EBA\u9593\u306E\u5C0A\u53B3\u53CA\u3073\u4FA1\u5024\u4E26\u3073\u306B\u7537\u5973\u306E\u540C\u6A29\u306B\u3064\u3044\u3066\u306E\u4FE1\u5FF5\u3092\u518D\u78BA\u8A8D\u3057", new RectF(10, 10, 220, 300), style);

Kana and kanji stand upright and Latin turns sideways, as TextOrientation.Mixed says; hit tests, carets and selections work along the columns.

Text flowing across two columns

// One article flowed down two columns of a page: LineFlow hands out one line at a time at the width asked for, so the
// first column can be narrowed beside a picture and the rest carries on in the second. On Egap's PDF canvas the same
// calls write searchable text.
ParagraphStyle style = new() { DefaultStyle = new TextStyle { FontFamilies = [family], Size = 10 }, Align = TextAlign.Justify };
LineFlow flow = Paragraph.Create(article, style).CreateFlow();
(float X, float Top, float Bottom)[] columns = [(40, 60, 300), (310, 60, 300)];
foreach ((float x, float top, float bottom) in columns)
{
    float y = top;
    while (!flow.IsComplete)
    {
        float width = x == 40 && y < 160 ? 150 : 245;      // beside a picture at the top of column one
        TextLine line = flow.NextLine(width)!;
        if (y + line.Height > bottom)
        {
            flow.Rewind(line.Start);                        // that line goes to the next column
            break;
        }

        foreach (LayoutRun run in line.Runs)
        {
            if (run.Glyphs is { Count: > 0 } glyphs)
            {
                page.DrawGlyphRun(glyphs, new System.Numerics.Vector2(x, y), new SolidPaint(Rgba8.Black));
            }
        }

        y += line.Height;
    }
}

Each line's top is 0, so the caller places it; a line that does not fit goes back with Rewind and starts the next column.