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:
parent
c1254a522f
commit
dc4412b9e8
7 changed files with 177 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ``
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue