Skip to content

Implementation proposal: Unicode-safe CJK wrapping across rich-text component boundaries #90

Description

@Aimli

Hello,

I investigated the Chinese wrapping issue further and would like to provide a concrete implementation proposal.

The problem is not only that Chinese text has no spaces. The rich-text parser also splits one visible paragraph into separate RichTextComponent instances for normal text, links, bold text, and other inline formatting.

For example:

normal text + link text + normal text + bold text

These are laid out separately, even though they form one continuous visible paragraph.

The current wrapping code appears to have three relevant limitations:

  1. A long unit is only hard-split when startOffsetX == 0.
  2. After moving a unit from the remaining space of the current line to a new line, the same unit is not always remeasured against the full width of the new line.
  3. EnumLinebreakBehavior.AfterCharacter advances by one UTF-16 char instead of one Unicode grapheme cluster.

A low-risk fix would be:

  1. Use character-based wrapping by default for zh-CN and zh-TW.
  2. In character-based mode, iterate by extended grapheme cluster rather than UTF-16 char.
  3. When a unit does not fit in the remaining width, move to the next line and remeasure the same unit there.
  4. Only force-split it if it still does not fit on an otherwise empty full-width line.
  5. Use a strict width comparison, with a small floating-point tolerance, so text that exactly fills the line does not cause an additional automatic line break.
  6. Keep explicit CR, LF, CRLF and VTML
    handling separate from automatic wrapping.

The important layout rule is:

if the next unit does not fit in the remaining width:
    move to the next line
    remeasure the same unit using the full new-line width
    if it still does not fit:
        split it at a grapheme boundary

The original startOffsetX should not decide whether the unit can be split after the layout has already advanced to a new physical line. At that point, curX == 0 is the relevant condition.

using System.Globalization;

private string GetNextGrapheme(string text)
{
if (caretPos >= text.Length)
{
return null;
}

int length = StringInfo.GetNextTextElementLength(text, caretPos);
string grapheme = text.Substring(caretPos, length);
caretPos += length;

return grapheme;

}

StringInfo should only be used to determine safe grapheme boundaries. It does not by itself implement the Unicode line-breaking rules.

private string GetNextCharacterUnit(
string text,
EnumLinebreakBehavior behavior
)
{
if (caretPos >= text.Length)
{
return null;
}

gotLinebreak = false;
gotSpace = false;

char current = text[caretPos];

// Explicit line breaks are layout instructions,
// not visible wrapping units.
if (current == '\r')
{
    caretPos++;

    if (
        caretPos < text.Length &&
        text[caretPos] == '\n'
    )
    {
        caretPos++;
    }

    gotLinebreak = true;
    return string.Empty;
}

if (current == '\n')
{
    caretPos++;
    gotLinebreak = true;
    return string.Empty;
}

if (
    current == ' ' &&
    behavior != EnumLinebreakBehavior.None
)
{
    caretPos++;
    gotSpace = true;
    return string.Empty;
}

if (behavior == EnumLinebreakBehavior.AfterCharacter)
{
    int length =
        StringInfo.GetNextTextElementLength(text, caretPos);

    string result = text.Substring(caretPos, length);
    caretPos += length;
    return result;
}

// Keep the existing word-based logic for AfterWord and None.
return GetNextWordBasedUnit(text, behavior);

}

const double WidthEpsilon = 0.01;

while ((unit = GetNextUnit(text, linebreak)) != null)
{
string trailingSpace =
gotLinebreak ||
caretPos >= text.Length ||
!gotSpace
? string.Empty
: " ";

while (true)
{
    TextFlowPath section =
        GetCurrentFlowPathSection(flowPath, curY)
        ?? new TextFlowPath(500);

    double usableWidth =
        section.X2 - section.X1 - curX;

    double candidateWidth = MeasureWidth(
        ctx,
        lineTextBldr + unit + trailingSpace
    );

    if (candidateWidth <= usableWidth + WidthEpsilon)
    {
        break;
    }

    /*
     * The current accumulated text does not fit, or this rich-text
     * component starts too close to the right edge.
     *
     * Finish the physical line and retry the SAME unit against the
     * full width of the following line.
     */
    if (lineTextBldr.Length > 0 || curX > 0)
    {
        EmitLine(
            lines,
            ctx,
            section,
            lineTextBldr.ToString(),
            curX,
            curY,
            lineheight,
            usableWidth
        );

        lineTextBldr.Clear();
        curY += lineheight;
        curX = 0;

        // Critical: do not append or discard unit here.
        // Measure this same unit again on the new line.
        continue;
    }

    /*
     * We are already at an empty, full-width line.
     * Only now is emergency splitting allowed.
     */
    SplitUnitAtGraphemeBoundary(
        ctx,
        unit,
        usableWidth,
        out string fittingPart,
        out string remainingPart
    );

    lineTextBldr.Append(fittingPart);

    EmitLine(
        lines,
        ctx,
        section,
        lineTextBldr.ToString(),
        curX,
        curY,
        lineheight,
        usableWidth
    );

    lineTextBldr.Clear();
    curY += lineheight;
    curX = 0;

    unit = remainingPart;

    if (unit.Length == 0)
    {
        break;
    }
}

lineTextBldr.Append(unit);

if (gotSpace)
{
    lineTextBldr.Append(' ');
}

// Keep the existing explicit-line-break layout behavior here.

}

The key change is the inner retry loop. The unit remains unchanged when the layout advances to the next physical line.

A candidate whose width is exactly equal to the available width fits the line. Treating equality as overflow can create an automatic break immediately before an explicit
, resulting in an unintended blank line.

For a complete implementation, I recommend using Unicode Standard Annex #14 rather than maintaining a handwritten list of Chinese characters.

The layout engine should:

  • determine safe text units using extended grapheme clusters;
  • determine legal break opportunities using Unicode line-break properties;
  • select the last legal opportunity that fits the available width;
  • preserve mandatory breaks separately;
  • use emergency grapheme-boundary splitting only when no normal break opportunity fits.
  1. Plain Chinese text
    这是一段没有空格的中文长文本。

  2. Link boundary
    普通文字链接文字链接后的普通文字

  3. Bold boundary
    普通文字粗体文字后续普通文字

  4. Exact line width followed by

    Must not create an extra blank line.


  5. Must produce exactly one requested line advance.



  6. Must preserve exactly one intentional blank line.

  7. CRLF
    Must never be rendered as visible missing-glyph boxes.

  8. Emoji and combining sequences
    A🤷🏽‍♀️B
    The emoji must not be split internally.

  9. Chinese punctuation
    Closing punctuation must not begin a line.
    Opening punctuation must not end a line.

  10. Long URL or unbroken Latin text
    Preserve it when it fits on a full line;
    split it only as an emergency fallback.

A full UAX #14 implementation would be ideal, but the retry-on-new-line change, grapheme-safe AfterCharacter mode, and strict width comparison should already fix the reported Chinese rich-text overflow without rewriting the entire UI text system.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions