fix(PostIt): render embedded blog images reliably

Use seekable streams for embedded images, bind reader commands only when a page view model is present, and avoid empty-selection binding errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-16 16:14:08 +01:00
commit e0671597a1
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
7 changed files with 177 additions and 6 deletions

View file

@ -22,6 +22,14 @@
### Fixed
* [PostIt] Correction du rendu des images incorporées dans les billets :
le résolveur fournit désormais un flux mémoire repositionnable, à son
début, pour éviter l'échec du décodage des images issues d'un flux HTTP.
* [PostIt] Suppression des erreurs de binding au démarrage du lecteur
lorsqu'aucun billet n'est sélectionné ; la commande des liens est
raccordée lors du changement de contexte. Les échecs HTTP des images
sont désormais journalisés avec leur URL dans la catégorie `BlogImages`.
## [1.0.8-rc25] - unstable
### Added

View file

@ -58,6 +58,8 @@ un lien `/files/paul/photo.jpg` conserve son chemin depuis la racine Org.
Les liens absolus ne sont pas réécrits. Les images Markdown `![texte](url)`
sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
sans accès aux fichiers locaux du poste.
Le résolveur charge chaque image dans un flux mémoire repositionnable
initialement à la position zéro, nécessaire au décodage du lecteur Markdown.
Le lecteur utilise `Markdown.Avalonia.Tight` 12.0.0-a3 (version compatible
Avalonia 12) pour déléguer les hyperliens à une commande PostIt. L'éditeur

View file

@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Headless.XUnit;
using Avalonia.Logging;
using Avalonia.Threading;
using Avalonia.VisualTree;
using Markdown.Avalonia;
@ -14,6 +15,7 @@ using PostIt.Views;
using PostIt.Views.Blogs;
using Yavsc.Api.Client;
using Yavsc.Blogspot;
using SkiaSharp;
namespace PostIt.Tests;
@ -111,6 +113,87 @@ public class BlogReaderTests
}
}
[AvaloniaFact]
public async Task Reader_initialization_and_empty_selection_do_not_report_binding_errors()
{
var vm = CreateViewModel();
await vm.InitializeAsync();
var post = Post();
vm.Posts.Add(post);
vm.FilteredPosts.Add(post);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
Window? window = null;
try
{
var page = new BlogsPage();
var reader = page.FindControl<MarkdownScrollViewer>("ArticleReader")!;
var engine = Assert.IsAssignableFrom<IMarkdownEngine2>(reader.Engine);
Assert.Null(engine.HyperlinkCommand);
page.DataContext = vm;
var nav = new NavigationPage();
await nav.PushAsync(page);
window = new Window { Width = 800, Height = 700, Content = nav };
window.Show();
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.Same(vm.OpenResourceCommand, engine.HyperlinkCommand);
Assert.IsType<BlogImageResolver>(reader.Plugins.PathResolver);
vm.SelectedPost = post;
Dispatcher.UIThread.RunJobs();
Assert.Equal(post.Title, vm.ReaderTitle);
Assert.Equal(post.DateModified, vm.ReaderDateModified);
vm.SelectedPost = null;
page.DataContext = null;
Dispatcher.UIThread.RunJobs();
Assert.Empty(vm.ReaderTitle);
Assert.Null(vm.ReaderDateModified);
Assert.Null(engine.HyperlinkCommand);
Assert.DoesNotContain(sink.Entries, entry => entry.Area == LogArea.Binding);
}
finally
{
window?.Close();
Logger.Sink = previousSink;
}
}
[AvaloniaFact]
public async Task Image_http_failure_is_logged_with_its_url_and_is_not_swallowed()
{
const string url = "https://org.example.test/files/paul/absent.jpg";
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(
_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
var resolver = new BlogImageResolver(client);
var previousSink = Logger.Sink;
var sink = new RecordingLogSink();
Logger.Sink = sink;
try
{
await Assert.ThrowsAsync<HttpRequestException>(() => resolver.ResolveImageResource(url));
var error = Assert.Single(sink.Entries, entry => entry.Area == "BlogImages");
Assert.Contains(url, error.Values);
}
finally
{
Logger.Sink = previousSink;
}
}
private sealed class RecordingLogSink : ILogSink
{
public List<(string Area, object?[] Values)> Entries { get; } = [];
public bool IsEnabled(LogEventLevel level, string area) => level >= LogEventLevel.Warning;
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
=> Entries.Add((area, []));
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] values)
=> Entries.Add((area, values));
}
[AvaloniaFact]
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed()
{
@ -142,6 +225,66 @@ public class BlogReaderTests
}
}
[Theory]
[InlineData("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("/files/paul/blogs/14/IMG-20260923-WA0001.jpg")]
[InlineData("blogs/14/IMG-20260923-WA0001.jpg")]
public async Task Embedded_jpeg_stream_is_seekable_at_the_start_for_markdown_decoding(string url)
{
using var source = new SKBitmap(32, 24);
source.Erase(SKColors.CornflowerBlue);
using var encoded = source.Encode(SKEncodedImageFormat.Jpeg, 90);
var jpeg = encoded.ToArray();
using var client = new HttpClient(new BlogResourceServiceTests.StubHandler(request =>
{
Assert.Equal("https://yavsc.pschneider.fr/files/paul/blogs/14/IMG-20260923-WA0001.jpg",
request.RequestUri!.AbsoluteUri);
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new NonSeekableStream(jpeg))
};
}));
var resolver = new BlogImageResolver(client)
{
AssetPathRoot = "https://yavsc.pschneider.fr/files/paul/"
};
await using var stream = await resolver.ResolveImageResource(url);
Assert.NotNull(stream);
Assert.True(stream.CanSeek);
Assert.Equal(0, stream.Position);
using var decoded = SKBitmap.Decode(stream);
Assert.NotNull(decoded);
Assert.Equal(32, decoded.Width);
Assert.Equal(24, decoded.Height);
}
private sealed class NonSeekableStream(byte[] bytes) : Stream
{
private readonly MemoryStream _inner = new(bytes, writable: false);
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
=> _inner.ReadAsync(buffer, cancellationToken);
public override void Flush() => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
_inner.Dispose();
base.Dispose(disposing);
}
}
[AvaloniaFact]
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel()
{

View file

@ -4,6 +4,7 @@ using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Markdown.Avalonia.Utils;
using Avalonia.Logging;
namespace PostIt.Services;
@ -28,6 +29,16 @@ public sealed class BlogImageResolver : IPathResolver
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS.");
return await _client.GetStreamAsync(uri);
try
{
// The Markdown decoder needs a seekable stream positioned at the start.
return new MemoryStream(await _client.GetByteArrayAsync(uri), writable: false);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
Logger.TryGet(LogEventLevel.Error, "BlogImages")?.Log(this,
"Unable to load blog image {Url}: {Error}", uri.AbsoluteUri, ex.Message);
throw;
}
}
}

View file

@ -155,6 +155,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
public string ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? string.Empty;
public string ReaderTitle => SelectedPost?.Title ?? string.Empty;
public DateTime? ReaderDateModified => SelectedPost?.DateModified;
private readonly BlogResourceService _resources = new();
@ -644,6 +646,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
OnPropertyChanged(nameof(SelectedPost));
OnPropertyChanged(nameof(ReaderArticle));
OnPropertyChanged(nameof(ReaderAuthor));
OnPropertyChanged(nameof(ReaderTitle));
OnPropertyChanged(nameof(ReaderDateModified));
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates();

View file

@ -239,12 +239,12 @@
</Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12">
<TextBlock Text="{Binding SelectedPost.Title}" FontSize="28" FontWeight="SemiBold"
<TextBlock Text="{Binding ReaderTitle}" FontSize="28" FontWeight="SemiBold"
TextWrapping="Wrap" />
<WrapPanel Grid.Row="1" Orientation="Horizontal"
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" />
<TextBlock Text="{Binding SelectedPost.DateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
<TextBlock Text="{Binding ReaderDateModified, StringFormat='Mis à jour le {0:dd/MM/yyyy HH:mm}'}"
Opacity="0.65" />
</WrapPanel>
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
@ -258,9 +258,6 @@
</reader:MdAvPlugins.PathResolver>
</reader:MdAvPlugins>
</reader:MarkdownScrollViewer.Plugins>
<reader:MarkdownScrollViewer.Engine>
<reader:Markdown HyperlinkCommand="{Binding ((vm:BlogsViewModel)DataContext).OpenResourceCommand, ElementName=ArticleReader}" />
</reader:MarkdownScrollViewer.Engine>
</reader:MarkdownScrollViewer>
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
Text="Choisissez un billet dans la liste pour le lire."

View file

@ -4,6 +4,7 @@ using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Markdown.Avalonia;
namespace PostIt.Views.Blogs;
@ -12,6 +13,11 @@ public partial class BlogsPage : ContentPage
public BlogsPage()
{
InitializeComponent();
DataContextChanged += (_, _) =>
{
if (ArticleReader.Engine is IMarkdownEngine2 engine)
engine.HyperlinkCommand = (DataContext as ViewModels.BlogsViewModel)?.OpenResourceCommand;
};
}
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)