From 2b57449a63b9306c74941d1d88ffb24dc4794051 Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Fri, 7 Jan 2022 06:56:28 -0800 Subject: [PATCH] Book Reader Issues (#906) * Refactored the Font Escaping Regex with new unit tests. * Fonts are now properly escaped, somehow a regression was introduced. * Refactored most of the book page loading for the reader into the service. * Fixed a bug where going into fullscreen in non dark mode will cause the background of the reader to go black. Fixed a rendering issue with margin left/right screwing html up. Fixed an issue where line-height: 100% would break book's css, now we remove the styles if they are non-valuable. * Changed how I fixed the black mode in fullscreen * Fixed an issue where anchors wouldn't be colored blue in white mode * Fixed a bug in the code that checks if a filename is a cover where it would choose "backcover" as a cover, despite it not being a valid case. * Validate if ReleaseYear is a valid year and if not, set it to 0 to disable it. * Fixed an issue where some large images could blow out the screen when reading on mobile. Now images will force to be max of width of browser * Put my hack back in for fullscreen putting background color to black * Change forwarded headers from All to explicit names * Fixed an issue where Scheme was not https when it should have been. Now the browser will handle which scheme to request. * Cleaned up the user preferences to stack multiple controls onto one row * Fixed fullscreen scroll issue with progress, but now sticky top is missing. * Corrected the element on which we fullscreen --- API.Tests/Parser/ParserTest.cs | 37 +-- API/Controllers/AccountController.cs | 1 - API/Controllers/BookController.cs | 166 +++----------- API/Data/Metadata/ComicInfo.cs | 6 +- API/Parser/Parser.cs | 12 +- API/Services/BookService.cs | 176 +++++++++++++- API/Services/MetadataService.cs | 6 + API/Startup.cs | 2 +- UI/Web/src/app/_services/reader.service.ts | 14 -- .../book-reader/book-reader.component.html | 11 +- .../book-reader/book-reader.component.scss | 18 +- .../book-reader/book-reader.component.ts | 74 +++--- .../user-preferences.component.html | 214 +++++++++--------- UI/Web/src/styles.scss | 4 + 14 files changed, 426 insertions(+), 315 deletions(-) diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index d24c03067..d1aee7760 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -1,3 +1,4 @@ +using System.Linq; using API.Entities.Enums; using Xunit; using static API.Parser.Parser; @@ -58,20 +59,28 @@ namespace API.Tests.Parser Assert.Equal(expected, CleanTitle(input, isComic)); } + [Theory] + [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", true)] + [InlineData("src: url(ideal-sans-serif.woff)", true)] + [InlineData("src: local(\"Helvetica Neue Bold\")", true)] + [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] + [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", true)] + [InlineData("src: url(data:application/x-font-woff", false)] + public void FontCssRewriteMatches(string input, bool expectedMatch) + { + Assert.Equal(expectedMatch, FontSrcUrlRegex.Matches(input).Count > 0); + } - // [Theory] - // //[InlineData("@font-face{font-family:\"PaytoneOne\";src:url(\"..\\/Fonts\\/PaytoneOne.ttf\")}", "@font-face{font-family:\"PaytoneOne\";src:url(\"PaytoneOne.ttf\")}")] - // [InlineData("@font-face{font-family:\"PaytoneOne\";src:url(\"..\\/Fonts\\/PaytoneOne.ttf\")}", "..\\/Fonts\\/PaytoneOne.ttf")] - // //[InlineData("@font-face{font-family:'PaytoneOne';src:url('..\\/Fonts\\/PaytoneOne.ttf')}", "@font-face{font-family:'PaytoneOne';src:url('PaytoneOne.ttf')}")] - // //[InlineData("@font-face{\r\nfont-family:'PaytoneOne';\r\nsrc:url('..\\/Fonts\\/PaytoneOne.ttf')\r\n}", "@font-face{font-family:'PaytoneOne';src:url('PaytoneOne.ttf')}")] - // public void ReplaceStyleUrlTest(string input, string expected) - // { - // var replacementStr = "PaytoneOne.ttf"; - // // Use Match to validate since replace is weird - // //Assert.Equal(expected, FontSrcUrlRegex.Replace(input, "$1" + replacementStr + "$2" + "$3")); - // var match = FontSrcUrlRegex.Match(input); - // Assert.Equal(!string.IsNullOrEmpty(expected), FontSrcUrlRegex.Match(input).Success); - // } + [Theory] + [InlineData("src: url(fonts/AvenirNext-UltraLight.ttf)", new [] {"src: url(", "fonts/AvenirNext-UltraLight.ttf", ")"})] + [InlineData("src: url(ideal-sans-serif.woff)", new [] {"src: url(", "ideal-sans-serif.woff", ")"})] + [InlineData("src: local(\"Helvetica Neue Bold\")", new [] {"src: local(\"", "Helvetica Neue Bold", "\")"})] + [InlineData("src: url(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: url(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] + [InlineData("src: local(\"/fonts/OpenSans-Regular-webfont.woff2\")", new [] {"src: local(\"", "/fonts/OpenSans-Regular-webfont.woff2", "\")"})] + public void FontCssCorrectlySeparates(string input, string[] expected) + { + Assert.Equal(expected, FontSrcUrlRegex.Match(input).Groups.Values.Select(g => g.Value).Where((s, i) => i > 0).ToArray()); + } [Theory] @@ -161,6 +170,8 @@ namespace API.Tests.Parser [InlineData("cover.jpg", true)] [InlineData("cover.png", true)] [InlineData("ch1/cover.png", true)] + [InlineData("ch1/backcover.png", false)] + [InlineData("backcover.png", false)] public void IsCoverImageTest(string inputPath, bool expected) { Assert.Equal(expected, IsCoverImage(inputPath)); diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 62ad0ebb8..3d3a93f85 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -12,7 +12,6 @@ using API.Extensions; using API.Services; using AutoMapper; using Kavita.Common; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 2dc5001ad..473640df7 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -10,6 +10,7 @@ using API.Extensions; using API.Services; using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VersOne.Epub; @@ -21,10 +22,11 @@ namespace API.Controllers private readonly IBookService _bookService; private readonly IUnitOfWork _unitOfWork; private readonly ICacheService _cacheService; - private static readonly string BookApiUrl = "book-resources?file="; + private const string BookApiUrl = "book-resources?file="; - public BookController(ILogger logger, IBookService bookService, IUnitOfWork unitOfWork, ICacheService cacheService) + public BookController(ILogger logger, IBookService bookService, + IUnitOfWork unitOfWork, ICacheService cacheService) { _logger = logger; _bookService = bookService; @@ -212,146 +214,40 @@ namespace API.Controllers var counter = 0; var doc = new HtmlDocument {OptionFixNestedTags = true}; - var baseUrl = Request.Scheme + "://" + Request.Host + Request.PathBase + "/api/"; + + var baseUrl = "//" + Request.Host + Request.PathBase + "/api/"; var apiBase = baseUrl + "book/" + chapterId + "/" + BookApiUrl; var bookPages = await book.GetReadingOrderAsync(); foreach (var contentFileRef in bookPages) { - if (page == counter) + if (page != counter) { - var content = await contentFileRef.ReadContentAsync(); - if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(content); - - // In more cases than not, due to this being XML not HTML, we need to escape the script tags. - content = BookService.EscapeTags(content); - - doc.LoadHtml(content); - var body = doc.DocumentNode.SelectSingleNode("//body"); - - if (body == null) - { - if (doc.ParseErrors.Any()) - { - LogBookErrors(book, contentFileRef, doc); - return BadRequest("The file is malformed! Cannot read."); - } - _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); - doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); - body = doc.DocumentNode.SelectSingleNode("/html/body"); - } - - var inlineStyles = doc.DocumentNode.SelectNodes("//style"); - if (inlineStyles != null) - { - foreach (var inlineStyle in inlineStyles) - { - var styleContent = await _bookService.ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); - body.PrependChild(HtmlNode.CreateNode($"")); - } - } - - var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); - if (styleNodes != null) - { - foreach (var styleLinks in styleNodes) - { - var key = BookService.CleanContentKeys(styleLinks.Attributes["href"].Value); - // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml - // In this case, we will do a search for the key that ends with - if (!book.Content.Css.ContainsKey(key)) - { - var correctedKey = book.Content.Css.Keys.SingleOrDefault(s => s.EndsWith(key)); - if (correctedKey == null) - { - _logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key); - continue; - } - - key = correctedKey; - } - - var styleContent = await _bookService.ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, book.Content.Css[key].FileName, book); - if (styleContent != null) - { - body.PrependChild(HtmlNode.CreateNode($"")); - } - } - } - - var anchors = doc.DocumentNode.SelectNodes("//a"); - if (anchors != null) - { - foreach (var anchor in anchors) - { - BookService.UpdateLinks(anchor, mappings, page); - } - } - - var images = doc.DocumentNode.SelectNodes("//img"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "img") continue; - - // Need to do for xlink:href - if (image.Attributes["src"] != null) - { - var imageFile = image.Attributes["src"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - image.Attributes.Remove("src"); - image.Attributes.Add("src", $"{apiBase}" + imageFile); - } - } - } - - images = doc.DocumentNode.SelectNodes("//image"); - if (images != null) - { - foreach (var image in images) - { - if (image.Name != "image") continue; - - if (image.Attributes["xlink:href"] != null) - { - var imageFile = image.Attributes["xlink:href"].Value; - if (!book.Content.Images.ContainsKey(imageFile)) - { - var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); - if (correctedKey != null) - { - imageFile = correctedKey; - } - } - image.Attributes.Remove("xlink:href"); - image.Attributes.Add("xlink:href", $"{apiBase}" + imageFile); - } - } - } - - // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping - var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); - if (htmlNode != null && htmlNode.Attributes.Contains("class")) - { - var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; - var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; - body.Attributes.Add("class", $"{classes}"); - // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. - return Ok($"
{body.InnerHtml}
"); - } - - - return Ok(body.InnerHtml); + counter++; + continue; } - counter++; + var content = await contentFileRef.ReadContentAsync(); + if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return Ok(content); + + // In more cases than not, due to this being XML not HTML, we need to escape the script tags. + content = BookService.EscapeTags(content); + + doc.LoadHtml(content); + var body = doc.DocumentNode.SelectSingleNode("//body"); + + if (body == null) + { + if (doc.ParseErrors.Any()) + { + LogBookErrors(book, contentFileRef, doc); + return BadRequest("The file is malformed! Cannot read."); + } + _logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath); + doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("")); + body = doc.DocumentNode.SelectSingleNode("/html/body"); + } + + return Ok(await _bookService.ScopePage(doc, book, apiBase, body, mappings, page)); } return BadRequest("Could not find the appropriate html for that page"); diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index 7e53bb486..4c3e13107 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -28,9 +28,9 @@ namespace API.Data.Metadata /// This is the link to where the data was scraped from /// public string Web { get; set; } = string.Empty; - public int Day { get; set; } - public int Month { get; set; } - public int Year { get; set; } + public int Day { get; set; } = 0; + public int Month { get; set; } = 0; + public int Year { get; set; } = 0; /// diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 828bc529f..2354876bd 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -28,7 +28,8 @@ namespace API.Parser /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data /// /// See here for some examples https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face - public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(src:\s?)?url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(?.{1}\))", + public static readonly Regex FontSrcUrlRegex = new Regex(@"(?(?:src:\s?)?(?:url|local)\((?!data:)" + "(?:[\"']?)" + @"(?!data:))" + + "(?(?!data:)[^\"']+?)" + "(?[\"']?" + @"\);?)", MatchOptions, RegexTimeout); /// /// https://developer.mozilla.org/en-US/docs/Web/CSS/@import @@ -54,7 +55,7 @@ namespace API.Parser MatchOptions, RegexTimeout); private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, MatchOptions, RegexTimeout); - private static readonly Regex CoverImageRegex = new Regex(@"(? /// Tests whether the file is a cover image such that: contains "cover", is named "folder", and is an image /// - /// + /// If the path has "backcover" in it, it will be ignored + /// Filename with extension /// - public static bool IsCoverImage(string name) + public static bool IsCoverImage(string filename) { - return IsImage(name, true) && (CoverImageRegex.IsMatch(name)); + return IsImage(filename, true) && CoverImageRegex.IsMatch(filename); } public static bool HasBlacklistedFolderInPath(string path) diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index d08229778..87a6af69f 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -47,6 +47,8 @@ namespace API.Services /// /// Where the files will be extracted to. If doesn't exist, will be created. void ExtractPdfImages(string fileFilePath, string targetDirectory); + + Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page); } public class BookService : IBookService @@ -168,13 +170,10 @@ namespace API.Services } stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); - var importMatches = Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml); - foreach (Match match in importMatches) - { - if (!match.Success) continue; - var importFile = match.Groups["Filename"].Value; - stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); - } + + EscapeCSSImportReferences(ref stylesheetHtml, apiBase, prepend); + + EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); // Check if there are any background images and rewrite those urls EscapeCssImageReferences(ref stylesheetHtml, apiBase, book); @@ -201,6 +200,26 @@ namespace API.Services return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } + private static void EscapeCSSImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + { + foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) + { + if (!match.Success) continue; + var importFile = match.Groups["Filename"].Value; + stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); + } + } + + private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend) + { + foreach (Match match in Parser.Parser.FontSrcUrlRegex.Matches(stylesheetHtml)) + { + if (!match.Success) continue; + var importFile = match.Groups["Filename"].Value; + stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile); + } + } + private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book) { var matches = Parser.Parser.CssImageUrlRegex.Matches(stylesheetHtml); @@ -216,6 +235,128 @@ namespace API.Services } } + private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase) + { + var images = doc.DocumentNode.SelectNodes("//img"); + if (images != null) + { + foreach (var image in images) + { + if (image.Name != "img") continue; + + // Need to do for xlink:href + if (image.Attributes["src"] != null) + { + var imageFile = image.Attributes["src"].Value; + if (!book.Content.Images.ContainsKey(imageFile)) + { + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } + } + + image.Attributes.Remove("src"); + image.Attributes.Add("src", $"{apiBase}" + imageFile); + } + } + } + + images = doc.DocumentNode.SelectNodes("//image"); + if (images != null) + { + foreach (var image in images) + { + if (image.Name != "image") continue; + + if (image.Attributes["xlink:href"] != null) + { + var imageFile = image.Attributes["xlink:href"].Value; + if (!book.Content.Images.ContainsKey(imageFile)) + { + var correctedKey = book.Content.Images.Keys.SingleOrDefault(s => s.EndsWith(imageFile)); + if (correctedKey != null) + { + imageFile = correctedKey; + } + } + + image.Attributes.Remove("xlink:href"); + image.Attributes.Add("xlink:href", $"{apiBase}" + imageFile); + } + } + } + } + + private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body) + { + // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping + var htmlNode = doc.DocumentNode.SelectSingleNode("//html"); + if (htmlNode == null || !htmlNode.Attributes.Contains("class")) return body.InnerHtml; + + var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty; + var classes = htmlNode.Attributes["class"].Value + " " + bodyClasses; + body.Attributes.Add("class", $"{classes}"); + // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there. + //return Ok($"
{body.InnerHtml}
"); + return $"
{body.InnerHtml}
"; + } + + private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary mappings) + { + var anchors = doc.DocumentNode.SelectNodes("//a"); + if (anchors != null) + { + foreach (var anchor in anchors) + { + BookService.UpdateLinks(anchor, mappings, page); + } + } + } + + private async Task InlineStyles(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body) + { + var inlineStyles = doc.DocumentNode.SelectNodes("//style"); + if (inlineStyles != null) + { + foreach (var inlineStyle in inlineStyles) + { + var styleContent = await ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book); + body.PrependChild(HtmlNode.CreateNode($"")); + } + } + + var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link"); + if (styleNodes != null) + { + foreach (var styleLinks in styleNodes) + { + var key = BookService.CleanContentKeys(styleLinks.Attributes["href"].Value); + // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml + // In this case, we will do a search for the key that ends with + if (!book.Content.Css.ContainsKey(key)) + { + var correctedKey = book.Content.Css.Keys.SingleOrDefault(s => s.EndsWith(key)); + if (correctedKey == null) + { + _logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key); + continue; + } + + key = correctedKey; + } + + var styleContent = await ScopeStyles(await book.Content.Css[key].ReadContentAsync(), apiBase, + book.Content.Css[key].FileName, book); + if (styleContent != null) + { + body.PrependChild(HtmlNode.CreateNode($"")); + } + } + } + } + public ComicInfo GetComicInfo(string filePath) { if (!IsValidFile(filePath) || Parser.Parser.IsPdf(filePath)) return null; @@ -466,6 +607,27 @@ namespace API.Services }); } + /// + /// Responsible to scope all the css, links, tags, etc to prepare a self contained html file for the page + /// + /// Html Doc that will be appended to + /// Underlying epub + /// API Url for file loading to pass through + /// Body element from the epub + /// Epub mappings + /// Page number we are loading + /// + public async Task ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary mappings, int page) + { + await InlineStyles(doc, book, apiBase, body); + + RewriteAnchors(page, doc, mappings); + + ScopeImages(doc, book, apiBase); + + return PrepareFinalHtml(doc, body); + } + /// /// Extracts the cover image to covers directory and returns file path back /// diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 0054ad62f..5ebce257d 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -323,6 +323,12 @@ public class MetadataService : IMetadataService series.Metadata.ReleaseYear = series.Volumes .SelectMany(volume => volume.Chapters).Min(c => c.ReleaseDate.Year); + if (series.Metadata.ReleaseYear < 1000) + { + // Not a valid year, default to 0 + series.Metadata.ReleaseYear = 0; + } + var genres = comicInfos.SelectMany(i => i?.Genre.Split(",")).Distinct().ToList(); var tags = comicInfos.SelectMany(i => i?.Tags.Split(",")).Distinct().ToList(); var people = series.Volumes.SelectMany(volume => volume.Chapters).SelectMany(c => c.People).ToList(); diff --git a/API/Startup.cs b/API/Startup.cs index f7eb31aee..7e34303f8 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -209,7 +209,7 @@ namespace API app.UseForwardedHeaders(new ForwardedHeadersOptions { - ForwardedHeaders = ForwardedHeaders.All + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }); app.UseRouting(); diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index c192db1fc..16c2ea656 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -203,13 +203,6 @@ export class ReaderService { } }); } - // else if (el.mozRequestFullScreen) { - // el.mozRequestFullScreen(); - // } else if (el.webkitRequestFullscreen) { - // el.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); - // } else if (el.msRequestFullscreen) { - // el.msRequestFullscreen(); - // } } } @@ -221,13 +214,6 @@ export class ReaderService { } }); } - // else if (document.msExitFullscreen) { - // document.msExitFullscreen(); - // } else if (document.mozCancelFullScreen) { - // document.mozCancelFullScreen(); - // } else if (document.webkitExitFullscreen) { - // document.webkitExitFullscreen(); - // } } /** diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index f5da74301..178831b64 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -1,4 +1,4 @@ -
+
Skip to main content @@ -94,7 +94,7 @@
-
    +
    • {{chapterGroup.title}}
    • @@ -110,8 +110,11 @@
-
-
+
+ +
diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 2db71d374..80f960155 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -154,19 +154,33 @@ $primary-color: #0062cc; } .reading-section { - height: 100vh; + max-height: 100vh; width: 100%; - overflow: auto; + //overflow: auto; // This will break progress reporting } .book-content { position: relative; } +// A bunch of resets so books render correctly +::ng-deep .book-content { + & a, & :link { + color: blue; + } +} + .drawer-body { padding-bottom: 20px; } +.chapter-title { + padding-inline-start: 0px +} + +::ng-deep .scale-width { + max-width: 100%; +} // Click to Paginate styles diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index c3fae3c56..7ea3e5665 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -1,5 +1,5 @@ -import { AfterViewInit, Component, ElementRef, HostListener, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; -import {Location} from '@angular/common'; +import { AfterViewInit, Component, ElementRef, HostListener, Inject, OnDestroy, OnInit, Renderer2, RendererStyleFlags2, ViewChild } from '@angular/core'; +import {DOCUMENT, Location} from '@angular/common'; import { FormControl, FormGroup } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; @@ -243,7 +243,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { private seriesService: SeriesService, private readerService: ReaderService, private location: Location, private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, - private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService) { + private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService, + @Inject(DOCUMENT) private document: Document) { this.navService.hideNavBar(); this.darkModeStyleElem = this.renderer.createElement('style'); @@ -281,7 +282,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); } - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null) { this.originalBodyColor = bodyNode.style.background; } @@ -296,14 +297,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { */ ngAfterViewInit() { // check scroll offset and if offset is after any of the "id" markers, save progress - fromEvent(window, 'scroll') + fromEvent(this.reader.nativeElement, 'scroll') .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { if (this.isLoading) return; + console.log('Scroll'); + // Highlight the current chapter we are on if (Object.keys(this.pageAnchors).length !== 0) { // get the height of the document so we can capture markers that are halfway on the document viewport - const verticalOffset = this.scrollService.scrollPosition + (document.body.offsetHeight / 2); + const verticalOffset = this.scrollService.scrollPosition + (this.document.body.offsetHeight / 2); const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); if (alreadyReached.length > 0) { @@ -350,7 +353,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null && this.originalBodyColor !== undefined) { bodyNode.style.background = this.originalBodyColor; if (this.user.preferences.siteDarkMode) { @@ -359,7 +362,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } this.navService.showNavBar(); - const head = document.querySelector('head'); + const head = this.document.querySelector('head'); this.renderer.removeChild(head, this.darkModeStyleElem); if (this.clickToPaginateVisualOverlayTimeout !== undefined) { @@ -581,8 +584,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { resetSettings() { const windowWidth = window.innerWidth - || document.documentElement.clientWidth - || document.body.clientWidth; + || this.document.documentElement.clientWidth + || this.document.body.clientWidth; let margin = '15%'; if (windowWidth <= 700) { @@ -631,7 +634,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } moveFocus() { - const elems = document.getElementsByClassName('reading-section'); + const elems = this.document.getElementsByClassName('reading-section'); if (elems.length > 0) { (elems[0] as HTMLDivElement).focus(); } @@ -679,10 +682,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getPageMarkers(ids: Array) { try { - return document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', ')); + return this.document.querySelectorAll(ids.map(id => '#' + this.cleanIdSelector(id)).join(', ')); } catch (Exception) { // Fallback to anchors instead. Some books have ids that are not valid for querySelectors, so anchors should be used instead - return document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', ')); + return this.document.querySelectorAll(ids.map(id => '[href="#' + id + '"]').join(', ')); } } @@ -717,6 +720,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.setupPage(part, scrollTop); return; } + + // Apply scaling class to all images to ensure they scale down to max width to not blow out the reader + Array.from(imgs).forEach(img => this.renderer.addClass(img, 'scale-width')); + Promise.all(Array.from(imgs) .filter(img => !img.complete) .map(img => new Promise(resolve => { img.onload = img.onerror = resolve; }))) @@ -868,14 +875,25 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { updateReaderStyles() { if (this.readingHtml != undefined && this.readingHtml.nativeElement) { - for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { - const elem = this.readingHtml.nativeElement.children.item(i); - if (elem?.tagName != 'STYLE') { - Object.entries(this.pageStyles).forEach(item => { - this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); - }); + // for(let i = 0; i < this.readingHtml.nativeElement.children.length; i++) { + // const elem = this.readingHtml.nativeElement.children.item(i); + // if (elem?.tagName != 'STYLE') { + // Object.entries(this.pageStyles).forEach(item => { + // if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') return; + // this.renderer.setStyle(elem, item[0], item[1], RendererStyleFlags2.Important); + // }); + // } + // } + console.log('pageStyles: ', this.pageStyles); + console.log('readingHtml: ', this.readingHtml.nativeElement); + Object.entries(this.pageStyles).forEach(item => { + if (item[1] == '100%' || item[1] == '0px' || item[1] == 'inherit') { + // Remove the style or skip + this.renderer.removeStyle(this.readingHtml.nativeElement, item[0]); + return; } - } + this.renderer.setStyle(this.readingHtml.nativeElement, item[0], item[1], RendererStyleFlags2.Important); + }); } } @@ -903,7 +921,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setOverrideStyles() { - const bodyNode = document.querySelector('body'); + const bodyNode = this.document.querySelector('body'); if (bodyNode !== undefined && bodyNode !== null) { if (this.user.preferences.siteDarkMode) { bodyNode.classList.remove('bg-dark'); @@ -912,7 +930,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { bodyNode.style.background = this.getDarkModeBackgroundColor(); } this.backgroundColor = this.getDarkModeBackgroundColor(); - const head = document.querySelector('head'); + const head = this.document.querySelector('head'); if (this.darkMode) { this.renderer.appendChild(head, this.darkModeStyleElem) } else { @@ -948,7 +966,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { // Part selector is a XPATH element = this.getElementFromXPath(partSelector); } else { - element = document.querySelector('*[id="' + partSelector + '"]'); + element = this.document.querySelector('*[id="' + partSelector + '"]'); } if (element === null) return; @@ -984,7 +1002,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } getElementFromXPath(path: string) { - const node = document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; + const node = this.document.evaluate(path, this.document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; if (node?.nodeType === Node.ELEMENT_NODE) { return node as Element; } @@ -994,7 +1012,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { getXPathTo(element: any): string { if (element === null) return ''; if (element.id !== '') { return 'id("' + element.id + '")'; } - if (element === document.body) { return element.tagName; } + if (element === this.document.body) { return element.tagName; } let ix = 0; @@ -1027,12 +1045,16 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.isFullscreen) { this.readerService.exitFullscreen(() => { this.isFullscreen = false; + this.renderer.removeStyle(this.reader.nativeElement, 'background'); }); } else { this.readerService.enterFullscreen(this.reader.nativeElement, () => { this.isFullscreen = true; + // HACK: This is a bug with how browsers change the background color for fullscreen mode + if (!this.darkMode) { + this.renderer.setStyle(this.reader.nativeElement, 'background', 'white'); + } }); } } - } diff --git a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html index a25ea961f..b336ae534 100644 --- a/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html +++ b/UI/Web/src/app/user-settings/user-preferences/user-preferences.component.html @@ -49,39 +49,47 @@

Image Reader

-
-   - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - + +
+
+   + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + +
+ +
+   + How to scale the image to your screen. + How to scale the image to your screen. + +
+
+ +
+
+   + How to split a full width image (ie both left and right images are combined) + How to split a full width image (ie both left and right images are combined) + +
+
+ + +
+
-   - How to scale the image to your screen. - How to scale the image to your screen. - -
- -
-   - How to split a full width image (ie both left and right images are combined) - How to split a full width image (ie both left and right images are combined) - -
-
- - -
-
+
@@ -96,67 +104,82 @@

Book Reader

-
- -
-
- - -
-
- - +
+
+ +
+
+ + +
+
+ + +
-
-
-   - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. - -
-
-   - Should the sides of the book reader screen allow tapping on it to move to prev/next page - Should the sides of the book reader screen allow tapping on it to move to prev/next page -
-
- - -
-
- - -
-
-
-
-   - Font familty to load up. Default will load the book's default font - Font familty to load up. Default will load the book's default font - -
-
- -
-
-
-   - How much spacing between the lines of the book - How much spacing between the lines of the book -
-
-
-   - How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. - How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. -
+
+   + Should the sides of the book reader screen allow tapping on it to move to prev/next page + Should the sides of the book reader screen allow tapping on it to move to prev/next page +
+
+ + +
+
+ + +
+
+
+ +
+
+   + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + Direction to click to move to next page. Right to Left means you click on left side of screen to move to next page. + +
+ + +
+   + Font familty to load up. Default will load the book's default font + Font familty to load up. Default will load the book's default font + +
+
+ + + +
+
+ +
+
+
+   + How much spacing between the lines of the book + How much spacing between the lines of the book +
+
+ +
+   + How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. + How much spacing on each side of the screen. This will override to 0 on mobile devices regardless of this setting. +
+
+
+ +
@@ -165,23 +188,6 @@ - - diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index 8b8e582af..03861b498 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -52,6 +52,10 @@ body { cursor: pointer; } +app-root { + background-color: inherit; +} + // Utiliities @include media-breakpoint-down(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) {