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