diff --git a/CHANGELOG.md b/CHANGELOG.md
index b99a240..dba2c52 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
+### Fixed
+
+- MXP images a client does not support can fall back to configured ANSI terminal graphics, preserving
+ the other MXP elements. The fallback requires both enabled graphics and available picture pixels.
+
+
### Added
- **Output effects.** `OutputEffect` is a C# union of what output asks of a client besides text: the points
diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs
index fb462b5..7573f87 100644
--- a/MarkupString.Ansi/AnsiRegistration.cs
+++ b/MarkupString.Ansi/AnsiRegistration.cs
@@ -63,6 +63,7 @@ public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiCo
/// Returns a registry that writes for one client as describe it: every colour at its
/// depth in , and ,
/// and, in , links and pictures in the forms its terminal reads.
+ /// Pictures are also available as a fallback when MXP refuses its IMAGE element.
///
/// The registry to add to, one has already filled.
/// What the client is sent.
@@ -73,6 +74,7 @@ public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiOu
return registry
.With(new AnsiSetEmitter(options))
+ .With(new ImageAnsiEmitter(options))
.With(new AnsiPuebloEmitter(options.ColorDepth))
.With(new AnsiMxpEmitter(options.ColorDepth));
}
diff --git a/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs b/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs
new file mode 100644
index 0000000..871c965
--- /dev/null
+++ b/MarkupString.Ansi/Emitters/ImageAnsiEmitter.cs
@@ -0,0 +1,37 @@
+using System.Buffers;
+namespace MarkupString.Ansi;
+
+/// The terminal picture fallback for a dialect that cannot draw an image itself.
+internal sealed class ImageAnsiEmitter(AnsiOutputOptions options) : IMarkupEmitter
+{
+ public Type MarkupType => typeof(ImageMarkup);
+ public MarkupFormat Format => MarkupFormat.Ansi;
+
+ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context, IBufferWriter output)
+ {
+ // ANSI's set emitter has already drawn the picture before wrapping its remaining layers.
+ if (context.Format == MarkupFormat.Ansi)
+ {
+ output.Write(body);
+ return;
+ }
+
+ var image = (ImageMarkup)markup;
+ var method = TerminalPictureWriter.Method(options.ColorDepth is AnsiColorDepth.Attributes or AnsiColorDepth.None
+ ? options.Features & ~TerminalFeatures.BlockArt : options.Features);
+ if (method == TerminalFeatures.None || image.Row is not { } cells || !TerminalPictureWriter.CanDraw(method, cells)
+ || options.Pictures is null || !options.Pictures.TryGetPicture(image with { Row = null }, out var pixels))
+ {
+ output.Write(body);
+ return;
+ }
+
+ if (context.StartsRegion(image))
+ {
+ var style = AnsiEmitterSupport.Fold(context.Markups, context.Format).AtDepth(options.ColorDepth);
+ SgrWriter.Transition(AnsiStyle.None, style, output);
+ TerminalPictureWriter.Write(method, cells, pixels, style, options, output, output);
+ if (AnsiEmitterSupport.LeavesState(style)) SgrWriter.Reset(output);
+ }
+ }
+}
diff --git a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
index b9d0a82..788c7b5 100644
--- a/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
+++ b/MarkupString.Mxp/Emitters/ElementMxpEmitter.cs
@@ -67,7 +67,9 @@ public void Emit(IMarkup markup, ReadOnlySpan body, in EmitContext context
{
if (!Supports("IMAGE"))
{
- output.Write(body);
+ if (context.Registry.FindEmitter(typeof(ImageMarkup), MarkupFormat.Ansi) is { } fallback)
+ fallback.Emit(markup, body, context, output);
+ else output.Write(body);
return;
}
diff --git a/MarkupString.Mxp/MxpRegistration.cs b/MarkupString.Mxp/MxpRegistration.cs
index 0c8c2c0..2a9cb2f 100644
--- a/MarkupString.Mxp/MxpRegistration.cs
+++ b/MarkupString.Mxp/MxpRegistration.cs
@@ -23,7 +23,8 @@ public static class MxpRegistration
/// MXP lets a client say what it can render, and is where that
/// answer comes in: it is asked with an element's name from , upper case, and an
/// element it refuses is written as a format without MXP writes it — nothing for a sound, the
- /// description for a picture, the text in the main window for a pane. The answer belongs to one
+ /// description for a picture, the text in the main window for a pane. An unsupported picture first
+ /// delegates to a registered ANSI image emitter, allowing enabled terminal graphics as a fallback. The answer belongs to one
/// connection, so a server builds a registry per answer. With no predicate, every element is written,
/// which is right for a client that was never asked.
///
diff --git a/MarkupString.Mxp/README.md b/MarkupString.Mxp/README.md
index 4f0b046..1696141 100644
--- a/MarkupString.Mxp/README.md
+++ b/MarkupString.Mxp/README.md
@@ -54,6 +54,12 @@ An element the client refused is written as a format without MXP writes it: noth
description for a picture, the text in the main window for a pane. With no predicate every element is
written. Asking belongs to the telnet layer, and the answer to one connection.
+For a client that refuses `IMAGE` but renders terminal graphics, also configure
+`WithAnsiOutput(new AnsiOutputOptions(depth, features) { Pictures = source })`. Laid-out image rows
+then use the enabled Sixel, iTerm2, Kitty or block-art renderer while links and other supported elements
+remain MXP. Without pixels or an enabled graphics method, the picture keeps its text art. A client
+that supports `IMAGE` continues receiving MXP images.
+
## Elements
| Factory | Written as |
diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs
index fd3888f..1d48171 100644
--- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs
+++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs
@@ -3,6 +3,7 @@
using System.Text.RegularExpressions;
using MarkupString.Ansi;
using MarkupString.Layout;
+using MarkupString.Mxp;
///
/// What a terminal is sent beyond colour, by what its connection says it can do: OSC 8 and MSLP links, and
@@ -54,6 +55,47 @@ private static string RenderString(MarkupText text, AnsiOutputOptions options) =
private static MarkupText Laid(Block block, int width, PictureCells? cells = null) =>
BlockLayout.Build(block, width, context: new LayoutContext { Pictures = (_, _) => cells ?? new PictureCells(4, 2) });
+ [Test]
+ [Arguments(TerminalFeatures.InlineImages, "\e]1337;")]
+ [Arguments(TerminalFeatures.Sixel, "\eP")]
+ public async Task MxpWithoutImageUsesTerminalPictures(TerminalFeatures feature, string sequence)
+ {
+ var registry = MarkupRegistry.Empty.WithAnsi().WithMxp(_ => false).WithMxpSecureLines()
+ .WithAnsiOutput(new AnsiOutputOptions(Features: feature) { Pictures = new Source(RedBlue()) });
+ var figure = Laid(new Figure(Cat, MarkupText.Plain("cat")), 20);
+ var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "look", linkKind: LinkKind.Command), "look");
+ var rendered = MarkupText.Concat(figure, link).Render(MarkupFormat.Mxp, registry);
+ await Assert.That(rendered).Contains(sequence).And.DoesNotContain("look");
+ }
+
+ [Test]
+ [Arguments(true, true, true)]
+ [Arguments(false, false, true)]
+ [Arguments(false, true, false)]
+ public async Task MxpPrefersSupportedImagesAndKeepsArtWhenFallbackUnavailable(bool supportsImage, bool enabled, bool available)
+ {
+ var registry = MarkupRegistry.Empty.WithAnsi().WithMxp(_ => supportsImage)
+ .WithAnsiOutput(new AnsiOutputOptions(Features: enabled ? TerminalFeatures.InlineImages : TerminalFeatures.None)
+ { Pictures = new Source(available ? RedBlue() : null) });
+ var output = Laid(new Figure(Cat, MarkupText.Plain("cat")), 20).Render(MarkupFormat.Mxp, registry);
+ await Assert.That(output.Contains(" false)
+ .WithAnsiOutput(new AnsiOutputOptions(Features: TerminalFeatures.BlockArt) { Pictures = new Source(pixels) });
+ var laid = Laid(new Figure(Cat, MarkupText.Plain("cat")), 2, new PictureCells(2, 1));
+ var styled = MarkupText.Wrap(AnsiMarkup.Create(background: new AnsiColor.Standard(2, false)), laid);
+ await Assert.That(styled.Render(MarkupFormat.Mxp, registry).Split("42m").Length).IsGreaterThanOrEqualTo(3);
+ }
+
// ── Links ───────────────────────────────────────────────────────────────────
[Test]