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 dc4412b9e8
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
7 changed files with 177 additions and 6 deletions

View file

@ -22,6 +22,14 @@
### Fixed ### 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 ## [1.0.8-rc25] - unstable
### Added ### 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)` 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), sont affichées dans le billet et leur résolveur n'accepte que HTTP(S),
sans accès aux fichiers locaux du poste. 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 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 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;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Avalonia.Headless.XUnit; using Avalonia.Headless.XUnit;
using Avalonia.Logging;
using Avalonia.Threading; using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using Markdown.Avalonia; using Markdown.Avalonia;
@ -14,6 +15,7 @@ using PostIt.Views;
using PostIt.Views.Blogs; using PostIt.Views.Blogs;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Blogspot; using Yavsc.Blogspot;
using SkiaSharp;
namespace PostIt.Tests; 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] [AvaloniaFact]
public async Task Long_reading_content_scrolls_and_clears_when_selection_is_removed() 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] [AvaloniaFact]
public async Task Clicking_a_user_video_pushes_a_dedicated_video_viewmodel() 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.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Markdown.Avalonia.Utils; using Markdown.Avalonia.Utils;
using Avalonia.Logging;
namespace PostIt.Services; namespace PostIt.Services;
@ -28,6 +29,16 @@ public sealed class BlogImageResolver : IPathResolver
"La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath); "La base des fichiers de l'auteur est indisponible.")), relativeOrAbsolutePath);
if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp) if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)
throw new InvalidOperationException("Les images du billet doivent utiliser HTTP ou HTTPS."); 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 ReaderArticle => ReaderBaseUrl is not null ? SelectedPost?.Article ?? string.Empty : string.Empty;
public string ReaderAuthor => SelectedPost?.Author?.UserName ?? SelectedPost?.AuthorId ?? 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(); private readonly BlogResourceService _resources = new();
@ -644,6 +646,8 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
OnPropertyChanged(nameof(SelectedPost)); OnPropertyChanged(nameof(SelectedPost));
OnPropertyChanged(nameof(ReaderArticle)); OnPropertyChanged(nameof(ReaderArticle));
OnPropertyChanged(nameof(ReaderAuthor)); OnPropertyChanged(nameof(ReaderAuthor));
OnPropertyChanged(nameof(ReaderTitle));
OnPropertyChanged(nameof(ReaderDateModified));
} }
partial void OnIsBusyChanged(bool value) => UpdateCommandStates(); partial void OnIsBusyChanged(bool value) => UpdateCommandStates();

View file

@ -239,12 +239,12 @@
</Grid> </Grid>
<Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}" <Grid x:Name="PostReader" Grid.Row="2" IsVisible="{Binding !IsEditing}"
RowDefinitions="Auto,Auto,*" MaxWidth="900" HorizontalAlignment="Stretch" RowSpacing="12"> 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" /> TextWrapping="Wrap" />
<WrapPanel Grid.Row="1" Orientation="Horizontal" <WrapPanel Grid.Row="1" Orientation="Horizontal"
IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding ReaderAuthor}" Margin="0,0,16,0" /> <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" /> Opacity="0.65" />
</WrapPanel> </WrapPanel>
<reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2" <reader:MarkdownScrollViewer x:Name="ArticleReader" Grid.Row="2"
@ -258,9 +258,6 @@
</reader:MdAvPlugins.PathResolver> </reader:MdAvPlugins.PathResolver>
</reader:MdAvPlugins> </reader:MdAvPlugins>
</reader:MarkdownScrollViewer.Plugins> </reader:MarkdownScrollViewer.Plugins>
<reader:MarkdownScrollViewer.Engine>
<reader:Markdown HyperlinkCommand="{Binding ((vm:BlogsViewModel)DataContext).OpenResourceCommand, ElementName=ArticleReader}" />
</reader:MarkdownScrollViewer.Engine>
</reader:MarkdownScrollViewer> </reader:MarkdownScrollViewer>
<TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}" <TextBlock Grid.RowSpan="3" IsVisible="{Binding SelectedPost, Converter={x:Static ObjectConverters.IsNull}}"
Text="Choisissez un billet dans la liste pour le lire." 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.Platform.Storage;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Markdown.Avalonia;
namespace PostIt.Views.Blogs; namespace PostIt.Views.Blogs;
@ -12,6 +13,11 @@ public partial class BlogsPage : ContentPage
public BlogsPage() public BlogsPage()
{ {
InitializeComponent(); InitializeComponent();
DataContextChanged += (_, _) =>
{
if (ArticleReader.Engine is IMarkdownEngine2 engine)
engine.HyperlinkCommand = (DataContext as ViewModels.BlogsViewModel)?.OpenResourceCommand;
};
} }
protected override async void OnApplyTemplate(TemplateAppliedEventArgs e) protected override async void OnApplyTemplate(TemplateAppliedEventArgs e)