Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`,
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Fixed

- MXP `IMAGE` and `SOUND` put the file in the `T=` type's directory under the base address, as the MXP
specification says: `<IMAGE map.png URL=https://h/img/ T=maps>` is `https://h/img/maps/map.png`.
- An SGR extended colour with too few components (`38;2;40`) no longer reads what is left as codes of their own,
so it does not set a black background.

## 2.26.0 — 2026-11-06

### Added
Expand Down
3 changes: 3 additions & 0 deletions MarkupString.Ansi/Decoding/VtPlugins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ public static AnsiStyle Apply(AnsiStyle style, in CsiSequence sequence)
}
else if (first + 2 >= sequence.Count)
{
// Too few components: they are still the colour's, not codes of their own (38;2;40 does not
// set a black background).
i = sequence.Count - 1;
return null;
}

Expand Down
16 changes: 12 additions & 4 deletions MarkupString.Mxp/Decoding/MxpDecoding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ private void Image(ParsedTag tag)
{
var file = tag.Get("FNAME", 0);
if (string.IsNullOrEmpty(file)) return;
var source = Address(tag.Get("URL"), file);
var source = Address(tag.Get("URL"), tag.Get("T"), file);
var (width, widthCells) = Size(tag.Get("W"));
var (height, heightCells) = Size(tag.Get("H"));
var align = Enum.TryParse<ImageAlign>(tag.Get("ALIGN"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null;
Expand Down Expand Up @@ -758,13 +758,21 @@ private void Sound(ParsedTag tag)
? l < 0 ? SoundMarkup.Forever : l
: null;
var continues = music && tag.Get("C") == "1";
Context.Point(new SoundMarkup(Address(tag.Get("U"), file), channel, volume, repeats, continues));
Context.Point(new SoundMarkup(Address(tag.Get("U"), tag.Get("T"), file), channel, volume, repeats, continues));
}

private static string Address(string? directory, string file)
/// <summary>
/// Where a picture or sound is: the file under the base address, in the type's directory when there is one
/// ("the classname is appended to the URL, along with the name of the file itself"), or the file as it is
/// when there is no base or the file is already absolute.
/// </summary>
private static string Address(string? directory, string? type, string file)
{
if (string.IsNullOrEmpty(directory) || Uri.TryCreate(file, UriKind.Absolute, out _)) return file;
return directory.EndsWith('/') ? directory + file : directory + "/" + file;
var address = directory.EndsWith('/') ? directory : directory + "/";
var kind = type?.Trim().Trim('/');
if (!string.IsNullOrEmpty(kind)) address += kind + "/";
return address + file;
}

// ── Helpers ─────────────────────────────────────────────────────────────────
Expand Down
16 changes: 16 additions & 0 deletions MarkupString.Tests/Decoding/MxpDecodingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,22 @@ public async Task WhatThisPackageWrites_ReadsBack()
await Assert.That(spans[^1].Style.Interaction).IsEqualTo(new Interaction(InteractionKind.Command, "look") { Hint = "Look" });
}

[Test]
public async Task Image_TypeIsADirectoryUnderTheUrl()
{
var (lines, _, _) = Decode("\e[1z<IMAGE map.png URL=\"https://example.test/img/\" T=maps>\n\e[1z<IMAGE map.png URL=https://example.test/img T=/maps/>\n\e[1z<SOUND door.wav U=https://example.test/ T=doors>\n");
await Assert.That(Markups(lines[0]).OfType<ImageMarkup>().Single().Source).IsEqualTo("https://example.test/img/maps/map.png");
await Assert.That(Markups(lines[1]).OfType<ImageMarkup>().Single().Source).IsEqualTo("https://example.test/img/maps/map.png");
await Assert.That(Markups(lines[2]).OfType<SoundMarkup>().Single().Source).IsEqualTo("https://example.test/doors/door.wav");
}

[Test]
public async Task Image_TypeWithoutUrlLeavesTheName()
{
var (lines, _, _) = Decode("\e[1z<IMAGE https://example.test/x.png T=maps>\n");
await Assert.That(Markups(lines[0]).OfType<ImageMarkup>().Single().Source).IsEqualTo("https://example.test/x.png");
}

[Test]
public async Task Image_InCells_IsDrawnOverTheLines()
{
Expand Down
8 changes: 8 additions & 0 deletions MarkupString.Tests/Decoding/VtDecodingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ public async Task Sgr_StandardColour()
await Assert.That(span.Style.Has(TextAttributes.Bold)).IsTrue();
}

[Test]
public async Task Sgr_TruncatedTrueColour_SetsNothing()
{
var span = Only("\e[31;38;2;40mx\n");
await Assert.That(span.Style.Foreground).IsEqualTo(new RgbColor(170, 0, 0));
await Assert.That(span.Style.Background).IsNull();
}

[Test]
public async Task Sgr_TrueColour_SemicolonsAndColons()
{
Expand Down
7 changes: 4 additions & 3 deletions docs/decoding.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ The decoder acts on none of these. Refuse a clipboard write unless the player al
| `53`, `55` | Overline on, off |
| `58` | Underline colour: its parameters are read and dropped |

Any other code is ignored. An extended colour out of range leaves the colour as it was. The style
Any other code is ignored. An extended colour out of range leaves the colour as it was, and so does one
with too few components, whose components are not read as codes of their own. The style
is written under the `VtPens.Style` pen as an `AnsiMarkup`.

### `ControlsPlugin`
Expand Down Expand Up @@ -584,8 +585,8 @@ What the tags become:
| `B`, `I`, `U`, `S`, `H`, `COLOR`, `FONT` | An `AnsiMarkup`. SGR inside an MXP colour wins, as in an MXP client. |
| `A href hint` | A URL link, when `DecoderOptions.Links` allows the address |
| `SEND href hint prompt` | A command link. With several commands (`a\|b`) or `PROMPT`, a `CommandMenuMarkup` as well. With no `href`, or `&text;` in it, the command is the tag's text. |
| `IMAGE fname url w h align` | An `ImageMarkup`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. |
| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points |
| `IMAGE fname url t w h align` | An `ImageMarkup` at `url`, then the `t` directory, then `fname`. Sized in cells (`W=4c H=2c`), it is drawn over the cells the server leaves for it. |
| `SOUND`, `MUSIC` | `SoundMarkup` or `SoundStopMarkup` points, at `u`, then `t`, then the file |
| `FRAME`, `DEST` | A `PaneMarkup` over the text in `DEST`, titled from its `FRAME` |
| `VAR name` | A `VariableMarkup` over its text, and a `VariableEvent` |
| `EXPIRE`, `RELOCATE`, `USER`, `PASSWORD` | Their points |
Expand Down
Loading