diff --git a/src/NexusReader.Infrastructure/Services/BookStorageService.cs b/src/NexusReader.Infrastructure/Services/BookStorageService.cs index 9af4ee5..d1f844f 100644 --- a/src/NexusReader.Infrastructure/Services/BookStorageService.cs +++ b/src/NexusReader.Infrastructure/Services/BookStorageService.cs @@ -27,6 +27,7 @@ public class BookStorageService : IBookStorageService var uploadsFolder = Path.Combine(_environment.WebRootPath, "uploads"); EnsureDirectoryExists(uploadsFolder); + fileName = SanitizeFileName(fileName); var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var filePath = Path.Combine(uploadsFolder, uniqueFileName); @@ -52,6 +53,7 @@ public class BookStorageService : IBookStorageService var coversFolder = Path.Combine(_environment.WebRootPath, "covers"); EnsureDirectoryExists(coversFolder); + fileName = SanitizeFileName(fileName); var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; var filePath = Path.Combine(coversFolder, uniqueFileName); @@ -63,6 +65,25 @@ public class BookStorageService : IBookStorageService return $"covers/{uniqueFileName}"; } + private string SanitizeFileName(string fileName) + { + if (string.IsNullOrEmpty(fileName)) return fileName; + + var sanitized = fileName + .Replace('\u00A0', ' ') + .Replace('\u2007', ' ') + .Replace('\u200B', ' ') + .Replace('\u202F', ' '); + + var invalidChars = Path.GetInvalidFileNameChars(); + foreach (var c in invalidChars) + { + sanitized = sanitized.Replace(c, '_'); + } + + return sanitized; + } + private void EnsureDirectoryExists(string path) { if (!Directory.Exists(path)) diff --git a/src/NexusReader.Infrastructure/Services/EpubReaderService.cs b/src/NexusReader.Infrastructure/Services/EpubReaderService.cs index 6079d42..4154640 100644 --- a/src/NexusReader.Infrastructure/Services/EpubReaderService.cs +++ b/src/NexusReader.Infrastructure/Services/EpubReaderService.cs @@ -18,7 +18,7 @@ public class EpubReaderService : IEpubReader private readonly ILogger _logger; private const int WordThreshold = 1000; - private static readonly Regex ImageTagRegex = new(@"[^>]*?\bsrc=[""'])(?[^""']*?)(?[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex ImageTagRegex = new(@"(?]*?\bsrc=[""'])(?[^""']*?)(?[""'][^>]*?>)", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex BodyMatchRegex = new(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex ParagraphMatchRegex = new(@"<(p|h[1-6]|ul|ol|blockquote|pre)\b[^>]*>.*?|]*>|]*>", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); private static readonly Regex StyleScriptRegex = new(@"<(style|script)\b[^>]*>.*?", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); @@ -27,6 +27,9 @@ public class EpubReaderService : IEpubReader private static readonly Regex ImgTagSanitizerRegex = new(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex SrcAttributeRegex = new(@"\bsrc=[""'](?[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex AltAttributeRegex = new(@"\balt=[""'](?[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex SvgImageTagRegex = new(@"[^>]*?)>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex HrefAttributeRegex = new(@"\b(xlink:)?href=[""'](?[^""']*)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex EmptyBlockRegex = new(@"^(]*>| |\s)*$", RegexOptions.IgnoreCase | RegexOptions.Compiled); public EpubReaderService( IDbContextFactory dbContextFactory, @@ -102,7 +105,7 @@ public class EpubReaderService : IEpubReader foreach (var p in paragraphs) { var sanitizedContent = SanitizeParagraph(p); - if (string.IsNullOrWhiteSpace(sanitizedContent)) continue; + if (string.IsNullOrWhiteSpace(sanitizedContent) || EmptyBlockRegex.IsMatch(sanitizedContent)) continue; blocks.Add(new TextSegmentBlock($"seg-{blockCounter++}", sanitizedContent)); @@ -236,7 +239,9 @@ public class EpubReaderService : IEpubReader { if (string.IsNullOrEmpty(html)) return html; - return ImageTagRegex.Replace(html, match => + var normalizedHtml = NormalizeSvgImageTags(html); + + return ImageTagRegex.Replace(normalizedHtml, match => { var rawSrc = match.Groups["src"].Value; @@ -258,6 +263,31 @@ public class EpubReaderService : IEpubReader }); } + private static string NormalizeSvgImageTags(string html) + { + if (string.IsNullOrEmpty(html)) return html; + + return SvgImageTagRegex.Replace(html, match => + { + var attrs = match.Groups["attrs"].Value; + + if (SrcAttributeRegex.IsMatch(attrs)) + { + return $""; + } + + var hrefMatch = HrefAttributeRegex.Match(attrs); + if (hrefMatch.Success) + { + var hrefVal = hrefMatch.Groups["href"].Value; + var cleanedAttrs = HrefAttributeRegex.Replace(attrs, ""); + return $""; + } + + return match.Value; + }); + } + private static string ResolveRelativePath(string basePath, string relativePath) { if (string.IsNullOrEmpty(relativePath)) return string.Empty; diff --git a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css index 604e35c..4456cdb 100644 --- a/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css +++ b/src/NexusReader.UI.Shared/Components/Organisms/ReaderCanvas.razor.css @@ -45,7 +45,7 @@ min-height: calc(100vh - 180px); display: flex; flex-direction: column; - gap: 1.5rem; + gap: 0.75rem; position: relative; padding: 3rem 4rem 15rem 4rem; /* Large padding-bottom for reachability, plus comfortable side margins */ @@ -69,10 +69,21 @@ .block-wrapper { transition: all 0.5s ease; border-radius: 8px; - padding: 8px; + padding: 2px 8px; border: 1px solid transparent; } +/* Pull subsequent block closer to headings or bold exercise labels */ +.block-wrapper:has(h1), +.block-wrapper:has(h2), +.block-wrapper:has(h3), +.block-wrapper:has(h4), +.block-wrapper:has(h5), +.block-wrapper:has(h6), +.block-wrapper:has(p > strong) { + margin-bottom: -0.25rem; +} + /* Typographic refinement for TextSegmentBlock */ ::deep .nexus-ebook { font-family: 'Merriweather', serif !important; @@ -90,12 +101,24 @@ /* Warm charcoal for legibility */ } +/* Reset default margins for elements within separate block-wrappers */ +::deep .nexus-ebook p, +::deep .nexus-ebook h1, +::deep .nexus-ebook h2, +::deep .nexus-ebook h3, +::deep .nexus-ebook h4, +::deep .nexus-ebook h5, +::deep .nexus-ebook h6 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + /* Callout Box styling for legacy blockquote segments */ ::deep .nexus-ebook blockquote { background-color: rgba(255, 255, 255, 0.02); border-left: 4px solid var(--nexus-neon); padding: 1rem 1.25rem; - margin: 1.5rem 0 1.5rem 0; + margin: 1rem 0 1rem 0; border-radius: 0 8px 8px 0; font-size: 1.05rem; color: #e2e8f0; @@ -116,7 +139,7 @@ color: #e0e0e0; padding: 1.25rem; border-radius: 8px; - margin: 2rem 0; + margin: 1.25rem 0; overflow-x: auto; box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1); border-left: 4px solid var(--nexus-neon); @@ -353,7 +376,7 @@ padding-left: 18px !important; padding-right: 18px !important; padding-bottom: 4rem; - /* Safe breathing room */ + gap: 0.75rem !important; /* Tighter spacing on mobile */ } } @@ -367,8 +390,8 @@ ::deep .nexus-ebook h1 { font-size: 1.35rem !important; line-height: 1.4 !important; - margin-top: 1.5rem !important; - margin-bottom: 1rem !important; + margin-top: 0.5rem !important; /* Tighter margins on mobile */ + margin-bottom: 0.25rem !important; } } @@ -580,4 +603,44 @@ transform: rotate(0deg) scale(1); opacity: 1; } +} + +/* Ebook Image Scaling, Alignment, and Separation Lines */ +.block-wrapper:has(img) { + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding: 1rem 0; + margin: 0.5rem 0; + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.theme-light .block-wrapper:has(img) { + border-top: 1px solid rgba(0, 0, 0, 0.08); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); +} + +::deep .nexus-ebook img { + width: 100%; + max-width: 100%; + max-height: 75vh; + height: auto; + display: block; + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.1); + opacity: 0.9; + transition: opacity 0.25s ease; +} + +::deep .nexus-ebook img:hover { + opacity: 1; +} + +.theme-light ::deep .nexus-ebook img { + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.12); + border: 1px solid rgba(0, 0, 0, 0.1); + opacity: 1; } \ No newline at end of file diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index d3484d3..08d30bd 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -1,4 +1,5 @@ using NexusReader.Web.Components; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Components; using NexusReader.Application; @@ -121,6 +122,17 @@ builder.Services.AddAuthentication(options => options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme; }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Authority = builder.Configuration["Jwt:Authority"] ?? "https://example.com/"; + options.Audience = builder.Configuration["Jwt:Audience"] ?? "NexusReaderAPI"; + options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true + }; + }) .AddGoogle(options => { options.ClientId = builder.Configuration["Authentication:Google:ClientId"] ?? "placeholder-id"; diff --git a/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs b/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs index 81609bf..8c960ca 100644 --- a/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs +++ b/tests/NexusReader.Application.Tests/Services/EpubReaderServiceTests.cs @@ -267,6 +267,74 @@ public class EpubReaderServiceTests : IDisposable colonResult.Errors.First().Message.Should().Contain("Invalid resource path"); } + [Fact] + public void RewriteImageUrls_PreservesImgPrefix() + { + // Arrange + var method = typeof(EpubReaderService).GetMethod("RewriteImageUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + method.Should().NotBeNull(); + + var input = ""; + var ebookId = Guid.NewGuid(); + + // Act + var result = (string)method.Invoke(null, new object[] { input, ebookId, "OEBPS/cover-page.xhtml" }); + + // Assert + result.Should().StartWith(""; + var inputHref = ""; + var ebookId = Guid.NewGuid(); + + // Act + var resultXlink = (string)method.Invoke(null, new object[] { inputXlink, ebookId, "OEBPS/chapter1.xhtml" }); + var resultHref = (string)method.Invoke(null, new object[] { inputHref, ebookId, "OEBPS/chapter1.xhtml" }); + + // Assert + resultXlink.Should().Contain("

")] + [InlineData("

 

")] + [InlineData("


")] + [InlineData("
")] + [InlineData(" ")] + public void EmptyBlockRegex_MatchesEmptyBlocks(string input) + { + // Arrange + var field = typeof(EpubReaderService).GetField("EmptyBlockRegex", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + field.Should().NotBeNull(); + var regex = (System.Text.RegularExpressions.Regex)field.GetValue(null); + regex.Should().NotBeNull(); + + var method = typeof(EpubReaderService).GetMethod("SanitizeParagraph", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + method.Should().NotBeNull(); + + // Act + var sanitized = (string)method.Invoke(null, new object[] { input }); + var isMatch = regex.IsMatch(sanitized); + + // Assert + isMatch.Should().BeTrue(); + } + public void Dispose() { _connection.Close();