Skip to content

Commit 81bd62a

Browse files
committed
fix: drop zero-width runes with no base cell in MarkupParser
Previously, a zero-width rune at the start of a parsed line (e.g. stray U+FEFF from Outlook HTML) was added as a standalone cell with a zero-width character, making cells.Count one longer than the visual width and misaligning every subsequent cell in MarkupControl rendering. This desynchronized the terminal cursor from the buffer's bookkeeping and broke rendering of the rest of the line. Now matches CharacterBuffer.WriteString semantics: leading zero-width runes with nothing to attach to are dropped. Tests updated to codify the corrected contract.
1 parent c53e6df commit 81bd62a

3 files changed

Lines changed: 44 additions & 35 deletions

File tree

SharpConsoleUI.Tests/Parsing/MarkupParserUnicodeEdgeCaseTests.cs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -410,35 +410,35 @@ public void Parse_BidiMarks_AttachAsCombiners()
410410
#region Zero-Width at String Start
411411

412412
[Fact]
413-
public void Parse_ZWJAtStart_CreatesOwnCell()
413+
public void Parse_ZWJAtStart_IsDropped()
414414
{
415-
// ZWJ at start with no previous cell → falls through to own cell
415+
// ZWJ at start with no previous cell to attach to is dropped.
416416
var cells = MarkupParser.Parse("\u200DA", Color.White, Color.Black);
417417

418-
Assert.Equal(2, cells.Count);
419-
Assert.Equal(new Rune('A'), cells[1].Character);
418+
Assert.Single(cells);
419+
Assert.Equal(new Rune('A'), cells[0].Character);
420420
}
421421

422422
[Fact]
423-
public void Parse_CombiningMarkAtStart_CreatesOwnCell()
423+
public void Parse_CombiningMarkAtStart_IsDropped()
424424
{
425-
// U+0301 at start with no base → creates own cell
425+
// U+0301 at start with no base cell to attach to is dropped.
426426
var cells = MarkupParser.Parse("\u0301A", Color.White, Color.Black);
427427

428-
Assert.Equal(2, cells.Count);
429-
Assert.Equal(new Rune('A'), cells[1].Character);
428+
Assert.Single(cells);
429+
Assert.Equal(new Rune('A'), cells[0].Character);
430430
}
431431

432432
[Fact]
433-
public void Parse_MultipleCombinersAtStart_EachCreatesCell()
433+
public void Parse_MultipleCombinersAtStart_AllDropped()
434434
{
435-
// Two zero-width at start: first creates cell, second attaches to first
435+
// Leading zero-width runes with no base cell are all dropped;
436+
// the first real base cell starts fresh without their combiners.
436437
var cells = MarkupParser.Parse("\u0300\u0301A", Color.White, Color.Black);
437438

438-
// First U+0300 creates own cell (no previous), U+0301 attaches to it
439-
Assert.Equal(2, cells.Count);
440-
Assert.NotNull(cells[0].Combiners); // U+0301 attached
441-
Assert.Equal(new Rune('A'), cells[1].Character);
439+
Assert.Single(cells);
440+
Assert.Equal(new Rune('A'), cells[0].Character);
441+
Assert.Null(cells[0].Combiners);
442442
}
443443

444444
#endregion

SharpConsoleUI.Tests/Parsing/MarkupParserWideCharTests.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,14 +376,16 @@ public void Parse_MultipleCombinersOnOneCell()
376376
}
377377

378378
[Fact]
379-
public void Parse_ZeroWidthAtStartOfString_CreatesOwnCell()
379+
public void Parse_ZeroWidthAtStartOfString_IsDropped()
380380
{
381-
// If zero-width char appears first with no previous cell to attach to,
382-
// it falls through to create its own cell (defensive behavior)
381+
// If a zero-width char appears first with no previous cell to attach to,
382+
// it is dropped. Keeping it as a standalone cell would desynchronize
383+
// cell-count from visual width and misalign every subsequent cell when
384+
// painted (e.g. Outlook's stray U+FEFF breaking rendering of the line).
383385
var cells = MarkupParser.Parse("\uFE0FA", Color.White, Color.Black);
384386

385-
Assert.Equal(2, cells.Count);
386-
Assert.Equal(new Rune('A'), cells[1].Character);
387+
Assert.Single(cells);
388+
Assert.Equal(new Rune('A'), cells[0].Character);
387389
}
388390

389391
#endregion

SharpConsoleUI/Parsing/MarkupParser.cs

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,25 +156,32 @@ public static List<Cell> Parse(string markup, Color defaultFg, Color defaultBg)
156156
if (Rune.TryGetRuneAt(markup, i, out var rune))
157157
{
158158
int runeWidth = GetRuneWidth(rune);
159-
if (runeWidth == 0 && cells.Count > 0)
159+
if (runeWidth == 0)
160160
{
161-
var lastIdx = cells.Count - 1;
162-
// Skip past continuation cells to attach to the base cell
163-
if (cells[lastIdx].IsWideContinuation && lastIdx > 0)
164-
lastIdx--;
165-
var lastCell = cells[lastIdx];
166-
// VS16 widens certain emoji from 1→2 columns
167-
if (IsVS16(rune) && IsVs16Widened(lastCell.Character) && !IsWideRune(lastCell.Character))
161+
if (cells.Count > 0)
168162
{
169-
lastCell.AppendCombiner(rune);
170-
cells[lastIdx] = lastCell;
171-
cells.Add(new Cell(' ', currentFg, currentBg, currentDec) { IsWideContinuation = true });
172-
}
173-
else
174-
{
175-
lastCell.AppendCombiner(rune);
176-
cells[lastIdx] = lastCell;
163+
var lastIdx = cells.Count - 1;
164+
// Skip past continuation cells to attach to the base cell
165+
if (cells[lastIdx].IsWideContinuation && lastIdx > 0)
166+
lastIdx--;
167+
var lastCell = cells[lastIdx];
168+
// VS16 widens certain emoji from 1→2 columns
169+
if (IsVS16(rune) && IsVs16Widened(lastCell.Character) && !IsWideRune(lastCell.Character))
170+
{
171+
lastCell.AppendCombiner(rune);
172+
cells[lastIdx] = lastCell;
173+
cells.Add(new Cell(' ', currentFg, currentBg, currentDec) { IsWideContinuation = true });
174+
}
175+
else
176+
{
177+
lastCell.AppendCombiner(rune);
178+
cells[lastIdx] = lastCell;
179+
}
177180
}
181+
// else: zero-width rune with no preceding base cell — drop it.
182+
// Creating a standalone cell for a zero-width rune desynchronizes
183+
// cell-count from visual width and misaligns every subsequent cell
184+
// (e.g. the FEFF-at-start-of-line rendering bug with Outlook HTML).
178185
}
179186
else
180187
{

0 commit comments

Comments
 (0)