diff --git a/src/SharpClient.Core/Rendering/AnsiParser.cs b/src/SharpClient.Core/Rendering/AnsiParser.cs
index 73d1854..c2f2ffc 100644
--- a/src/SharpClient.Core/Rendering/AnsiParser.cs
+++ b/src/SharpClient.Core/Rendering/AnsiParser.cs
@@ -74,7 +74,7 @@ void HandleTag(string raw)
inSend = true;
}
}
- else if (name == "a" && mxp.IsPuebloActive)
+ else if (name == "a" && mxp!.IsPuebloActive)
{
var cmd = GetAttr(attrs, "xch_cmd");
if (cmd is not null)
@@ -155,10 +155,14 @@ void HandleTag(string raw)
}
// MXP / Pueblo markup. Locked MXP lines (mode 2) suppress all parsing.
- if (current == '<' && mxp is not null
- && (mxp.IsPuebloActive || (mxp.IsMxpActive && mxp.LineMode != 2)))
+ var markup = mxp is not null
+ && (mxp.IsPuebloActive || (mxp.IsMxpActive && mxp.LineMode != 2));
+
+ if (current == '<' && markup)
{
- var gt = line.IndexOf('>', index);
+ // Quote-aware scan for the tag terminator so a '>' inside a quoted attribute
+ // value (e.g. ) doesn't truncate the tag.
+ var gt = FindTagEnd(line, index + 1);
if (gt < 0)
{
text.Append(current); // unterminated tag: treat '<' as literal
@@ -171,6 +175,16 @@ void HandleTag(string raw)
continue;
}
+ // HTML entity decoding only happens inside MXP/Pueblo markup (after tag tokenisation,
+ // on text runs). In plain ANSI output '&' is always literal. A lone or unrecognised
+ // '&' is emitted verbatim — never dropped.
+ if (current == '&' && markup && TryDecodeEntity(line, index, out var entity, out var consumed))
+ {
+ text.Append(entity);
+ index += consumed;
+ continue;
+ }
+
text.Append(current);
index++;
}
@@ -215,7 +229,7 @@ void HandleTag(string raw)
j++;
}
- return body[start..j];
+ return DecodeEntities(body[start..j]);
}
else
{
@@ -225,7 +239,7 @@ void HandleTag(string raw)
j++;
}
- return body[start..j];
+ return DecodeEntities(body[start..j]);
}
}
@@ -235,6 +249,140 @@ void HandleTag(string raw)
return null;
}
+ ///
+ /// Scan from for the '>' that closes a tag, skipping over single- or
+ /// double-quoted attribute values so a literal '>' inside an attribute doesn't end the tag early
+ /// (the Pueblo reference client guards this exact case). Returns -1 if unterminated.
+ ///
+ private static int FindTagEnd(string s, int start)
+ {
+ var quote = '\0';
+ for (var k = start; k < s.Length; k++)
+ {
+ var c = s[k];
+ if (quote != '\0')
+ {
+ if (c == quote)
+ {
+ quote = '\0';
+ }
+ }
+ else if (c is '"' or '\'')
+ {
+ quote = c;
+ }
+ else if (c == '>')
+ {
+ return k;
+ }
+ }
+
+ return -1;
+ }
+
+ /// Decode every HTML entity in a string (used for attribute values).
+ private static string DecodeEntities(string s)
+ {
+ if (s.IndexOf('&') < 0)
+ {
+ return s;
+ }
+
+ var sb = new StringBuilder(s.Length);
+ var i = 0;
+ while (i < s.Length)
+ {
+ if (s[i] == '&' && TryDecodeEntity(s, i, out var decoded, out var consumed))
+ {
+ sb.Append(decoded);
+ i += consumed;
+ }
+ else
+ {
+ sb.Append(s[i]);
+ i++;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Try to decode the HTML entity beginning at (must point at '&').
+ /// Handles the core named entities, '/ /©/®/™,
+ /// and numeric &#NN; (decimal) / &#xNN; (hex), decoding to full Unicode.
+ /// Named lookup is case-insensitive (friendlier than the reference client). A terminating ';'
+ /// is required; an unrecognised or unterminated sequence returns false so the caller emits the
+ /// literal '&' (entities are never silently dropped). Numeric values below U+0020 or outside
+ /// the Unicode range are consumed but produce no output (control chars are ignored, per MXP).
+ ///
+ private static bool TryDecodeEntity(string s, int i, out string decoded, out int consumed)
+ {
+ decoded = string.Empty;
+ consumed = 0;
+
+ if (i >= s.Length || s[i] != '&')
+ {
+ return false;
+ }
+
+ var semi = s.IndexOf(';', i + 1);
+ if (semi < 0 || semi - (i + 1) is 0 or > 12)
+ {
+ return false; // no terminator, empty, or implausibly long
+ }
+
+ var name = s[(i + 1)..semi];
+ consumed = semi - i + 1;
+
+ if (name[0] == '#')
+ {
+ var isHex = name.Length > 1 && name[1] is 'x' or 'X';
+ var digits = isHex ? name[2..] : name[1..];
+ var v = 0;
+ var ok = digits.Length > 0 && (isHex
+ ? int.TryParse(digits, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out v)
+ : int.TryParse(digits, out v));
+ if (!ok)
+ {
+ consumed = 0;
+ return false;
+ }
+
+ if (v < 0x20 || v > 0x10FFFF || (v >= 0xD800 && v <= 0xDFFF))
+ {
+ decoded = string.Empty; // ignore control / invalid / surrogate code points
+ return true;
+ }
+
+ decoded = char.ConvertFromUtf32(v);
+ return true;
+ }
+
+ var mapped = name.ToLowerInvariant() switch
+ {
+ "lt" => "<",
+ "gt" => ">",
+ "amp" => "&",
+ "quot" => "\"",
+ "apos" => "'",
+ "nbsp" => " ",
+ "copy" => "©",
+ "reg" => "®",
+ "trade" => "™",
+ _ => null,
+ };
+
+ if (mapped is null)
+ {
+ consumed = 0;
+ return false;
+ }
+
+ decoded = mapped;
+ return true;
+ }
+
private static bool IsCsiFinal(char c) => c is >= '@' and <= '~';
private static TextStyle ApplySgr(TextStyle style, string parameters)
diff --git a/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs b/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs
new file mode 100644
index 0000000..92f2c44
--- /dev/null
+++ b/tests/SharpClient.Tests/Rendering/PuebloEntityTests.cs
@@ -0,0 +1,97 @@
+using SharpClient.Core.Rendering;
+
+namespace SharpClient.Tests.Rendering;
+
+///
+/// HTML-entity handling for Pueblo/MXP markup. In Pueblo HTML mode the server entity-encodes the
+/// reserved characters (e.g. a literal '>' is sent as ">"), so the client must decode them
+/// on render — but only inside markup mode, and a lone/unknown '&' must pass through literally.
+///
+public sealed class PuebloEntityTests
+{
+ private static MxpParserState Pueblo() => new() { IsPuebloActive = true };
+
+ private static string Render(string line, MxpParserState? mxp)
+ {
+ var sb = new System.Text.StringBuilder();
+ foreach (var seg in AnsiParser.Parse(line, mxp))
+ {
+ sb.Append(seg.Text);
+ }
+
+ return sb.ToString();
+ }
+
+ [Test]
+ public async Task DecodesCoreEntitiesInPuebloMode()
+ {
+ await Assert.That(Render("5 > 3 && 2 < 4 "q"", Pueblo()))
+ .IsEqualTo("5 > 3 && 2 < 4 \"q\"");
+ }
+
+ [Test]
+ public async Task DecodesAposAndNbsp()
+ {
+ await Assert.That(Render("it's here", Pueblo())).IsEqualTo("it's here");
+ }
+
+ [Test]
+ public async Task DecodesNumericDecimalAndHex()
+ {
+ await Assert.That(Render(">>&", Pueblo())).IsEqualTo(">>&");
+ }
+
+ [Test]
+ public async Task IgnoresControlRangeNumericEntities()
+ {
+ // U+0007 (BEL) is below U+0020: consumed but produces no output (matches MXP behaviour).
+ await Assert.That(Render("ab", Pueblo())).IsEqualTo("ab");
+ }
+
+ [Test]
+ public async Task LoneAmpersandPassesThroughLiterally()
+ {
+ await Assert.That(Render("Tom & Jerry", Pueblo())).IsEqualTo("Tom & Jerry");
+ }
+
+ [Test]
+ public async Task UnknownEntityPassesThroughLiterally()
+ {
+ await Assert.That(Render("x¬athing;y", Pueblo())).IsEqualTo("x¬athing;y");
+ }
+
+ [Test]
+ public async Task DoesNotDecodeEntitiesOutsideMarkupMode()
+ {
+ // No MXP/Pueblo active: '&' and entities are plain text, never decoded.
+ await Assert.That(Render("5 > 3", null)).IsEqualTo("5 > 3");
+ }
+
+ [Test]
+ public async Task DecodesEntitiesInMxpMode()
+ {
+ await Assert.That(Render("5 > 3", new MxpParserState { IsMxpActive = true }))
+ .IsEqualTo("5 > 3");
+ }
+
+ [Test]
+ public async Task EntityInsideAttributeValueIsDecoded()
+ {
+ var segs = AnsiParser.Parse("go", Pueblo());
+
+ await Assert.That(segs.Count).IsEqualTo(1);
+ await Assert.That(segs[0].Text).IsEqualTo("go");
+ await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3");
+ }
+
+ [Test]
+ public async Task RawGreaterThanInsideQuotedAttributeDoesNotTruncateTag()
+ {
+ // A literal '>' inside a quoted attribute must not end the tag early.
+ var segs = AnsiParser.Parse(" 3\">go", Pueblo());
+
+ await Assert.That(segs.Count).IsEqualTo(1);
+ await Assert.That(segs[0].Text).IsEqualTo("go");
+ await Assert.That(segs[0].Command).IsEqualTo("say 5 > 3");
+ }
+}