Files
Nexus.Reader/src/NexusReader.Web.Client/Services/WasmEpubService.cs
T
Antigravity bf31effd36 fix: preserve and render EPUB images via dynamic server endpoint (#65)
Fixes #64

### Summary of Changes
1. **Extended `IEpubReader` & `EpubReaderService`**: Added `GetEpubResourceAsync` to handle binary data extraction of static assets (like images) from the EPUB archive.
2. **Added Client-Side HTTP Call**: Extended `WasmEpubService` to retrieve static resources from the server using the API client.
3. **Preserved and Sanitized Images**: Updated `ExtractParagraphs` and `SanitizeParagraph` to treat `<img>` tags as first-class citizens, preserving their `src` attributes and excluding them from sanitization stripping.
4. **Dynamic URL Rewriting**: Introduced a relative-to-absolute path resolution algorithm (`ResolveRelativePath`) and rewrote image `src` attributes to use the dynamic endpoint `/api/epub/{ebookId}/resource?path=...`.
5. **Registered API Resource Serving Endpoint**: Added the `/api/epub/{ebookId:guid}/resource` minimal API endpoint in `Program.cs` that maps requests directly to `GetEpubResourceAsync` and returns files with the correct MIME type.
6. **Added Unit Tests**: Created `EpubReaderServiceTests.cs` to verify all image extraction, path resolution, and sanitization/rewriting rules. All tests pass successfully.

---------

Co-authored-by: Marek Jasiński <jasins.marek@gmail.com>
Reviewed-on: #65
Co-authored-by: Antigravity <antigravity@google.com>
Co-committed-by: Antigravity <antigravity@google.com>
2026-06-01 16:04:56 +00:00

83 lines
3.0 KiB
C#

using System.Net.Http.Json;
using FluentResults;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Application.Queries.Reader;
namespace NexusReader.Web.Client.Services;
public class WasmEpubReader : IEpubReader
{
private readonly HttpClient _httpClient;
public WasmEpubReader(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<Result<ReaderPageViewModel>> GetEpubContentAsync(
Guid ebookId,
int chapterIndex,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync($"/api/epub/{ebookId}/{chapterIndex}", cancellationToken);
if (response.IsSuccessStatusCode)
{
var viewModel = await response.Content.ReadFromJsonAsync<ReaderPageViewModel>(cancellationToken: cancellationToken);
return viewModel != null ? Result.Ok(viewModel) : Result.Fail("Failed to deserialize response.");
}
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Fail($"Server error ({response.StatusCode}): {errorBody}");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Network or parsing error: {ex.Message}").CausedBy(ex));
}
}
public async Task<Result<byte[]>> GetEpubResourceAsync(
Guid ebookId,
string resourcePath,
string? userId = null,
CancellationToken cancellationToken = default)
{
try
{
var response = await _httpClient.GetAsync($"/api/epub/{ebookId}/resource?path={Uri.EscapeDataString(resourcePath)}", cancellationToken);
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
return Result.Ok(bytes);
}
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Fail($"Server error fetching EPUB resource ({response.StatusCode}): {errorBody}");
}
catch (Exception ex)
{
return Result.Fail(new Error($"Network error fetching EPUB resource: {ex.Message}").CausedBy(ex));
}
}
}
public class WasmEpubMetadataExtractor : IEpubMetadataExtractor
{
public async Task<Result<LocalEpubMetadata>> ExtractMetadataAsync(Stream epubStream)
{
try
{
using var bookRef = await VersOne.Epub.EpubReader.OpenBookAsync(epubStream);
var title = bookRef.Title ?? "Unknown Title";
var author = bookRef.Author ?? "Unknown Author";
byte[]? cover = await bookRef.ReadCoverAsync();
return Result.Ok(new LocalEpubMetadata { Title = title, Author = author, CoverImage = cover });
}
catch (Exception ex)
{
return Result.Fail(new Error($"Failed to extract EPUB metadata locally: {ex.Message}").CausedBy(ex));
}
}
}