diff --git a/CHANGELOG.md b/CHANGELOG.md index bb7d12a..2ebfcb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `` 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 diff --git a/MarkupString.Ansi/Decoding/VtPlugins.cs b/MarkupString.Ansi/Decoding/VtPlugins.cs index 63196a4..d86188e 100644 --- a/MarkupString.Ansi/Decoding/VtPlugins.cs +++ b/MarkupString.Ansi/Decoding/VtPlugins.cs @@ -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; } diff --git a/MarkupString.Mxp/Decoding/MxpDecoding.cs b/MarkupString.Mxp/Decoding/MxpDecoding.cs index 04dc49b..6c50fe2 100644 --- a/MarkupString.Mxp/Decoding/MxpDecoding.cs +++ b/MarkupString.Mxp/Decoding/MxpDecoding.cs @@ -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(tag.Get("ALIGN"), ignoreCase: true, out var parsed) ? parsed : (ImageAlign?)null; @@ -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) + /// + /// 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. + /// + 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 ───────────────────────────────────────────────────────────────── diff --git a/MarkupString.Tests/Decoding/MxpDecodingTests.cs b/MarkupString.Tests/Decoding/MxpDecodingTests.cs index 734a819..ee9e790 100644 --- a/MarkupString.Tests/Decoding/MxpDecodingTests.cs +++ b/MarkupString.Tests/Decoding/MxpDecodingTests.cs @@ -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\n\e[1z\n\e[1z\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Source).IsEqualTo("https://example.test/img/maps/map.png"); + await Assert.That(Markups(lines[1]).OfType().Single().Source).IsEqualTo("https://example.test/img/maps/map.png"); + await Assert.That(Markups(lines[2]).OfType().Single().Source).IsEqualTo("https://example.test/doors/door.wav"); + } + + [Test] + public async Task Image_TypeWithoutUrlLeavesTheName() + { + var (lines, _, _) = Decode("\e[1z\n"); + await Assert.That(Markups(lines[0]).OfType().Single().Source).IsEqualTo("https://example.test/x.png"); + } + [Test] public async Task Image_InCells_IsDrawnOverTheLines() { diff --git a/MarkupString.Tests/Decoding/VtDecodingTests.cs b/MarkupString.Tests/Decoding/VtDecodingTests.cs index e5cb968..b6f8ba7 100644 --- a/MarkupString.Tests/Decoding/VtDecodingTests.cs +++ b/MarkupString.Tests/Decoding/VtDecodingTests.cs @@ -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() { diff --git a/docs/decoding.md b/docs/decoding.md index d7b08a1..c34d130 100644 --- a/docs/decoding.md +++ b/docs/decoding.md @@ -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` @@ -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 |