From dc4412b9e86ce1c806d53392c2363792b4bf8f8f Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 16 Oct 2026 16:14:08 +0100 Subject: [PATCH] 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> --- CHANGELOG.md | 8 + doc/architecture/postit.md | 2 + src/PostIt/PostIt.Tests/BlogReaderTests.cs | 143 ++++++++++++++++++ .../PostIt/Services/BlogImageResolver.cs | 13 +- .../PostIt/ViewModels/Blogs/BlogsViewModel.cs | 4 + src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml | 7 +- .../PostIt/Views/Blogs/BlogsPage.axaml.cs | 6 + 7 files changed, 177 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7db6bc10b..bb078c37c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md index 37d372d49..439bb8f70 100644 --- a/doc/architecture/postit.md +++ b/doc/architecture/postit.md @@ -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 diff --git a/src/PostIt/PostIt.Tests/BlogReaderTests.cs b/src/PostIt/PostIt.Tests/BlogReaderTests.cs index e40ae61c5..d80557895 100644 --- a/src/PostIt/PostIt.Tests/BlogReaderTests.cs +++ b/src/PostIt/PostIt.Tests/BlogReaderTests.cs @@ -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("ArticleReader")!; + var engine = Assert.IsAssignableFrom(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(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(() => 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 ReadAsync(Memory 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() { diff --git a/src/PostIt/PostIt/Services/BlogImageResolver.cs b/src/PostIt/PostIt/Services/BlogImageResolver.cs index b7048322d..1db8ba19d 100644 --- a/src/PostIt/PostIt/Services/BlogImageResolver.cs +++ b/src/PostIt/PostIt/Services/BlogImageResolver.cs @@ -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; + } } } diff --git a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs index f155d2bff..98d22ff95 100644 --- a/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Blogs/BlogsViewModel.cs @@ -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(); diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml index 704488123..d29d1f020 100644 --- a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml +++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml @@ -239,12 +239,12 @@ - - - - - + { + if (ArticleReader.Engine is IMarkdownEngine2 engine) + engine.HyperlinkCommand = (DataContext as ViewModels.BlogsViewModel)?.OpenResourceCommand; + }; } protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)