From f76de42b281bc632e2a31d974dc098dec1a8cbba Mon Sep 17 00:00:00 2001 From: Joe Milazzo Date: Sun, 16 Feb 2025 15:10:15 -0600 Subject: [PATCH] PDF Metadata Support (#3552) Co-authored-by: Matthias Neeracher --- API.Tests/Services/BookServiceTests.cs | 43 + API.Tests/Services/ScannerServiceTests.cs | 17 +- .../Test Data/BookService/encrypted.pdf | Bin 0 -> 6313 bytes .../Test Data/BookService/indirect.pdf | Bin 0 -> 33990 bytes ...th Specials Folder Alt Naming - Manga.json | 6 + API/Helpers/PdfComicInfoExtractor.cs | 159 ++ API/Helpers/PdfMetadataExtractor.cs | 1660 +++++++++++++++++ API/Services/BookService.cs | 27 +- API/Services/Plus/ExternalMetadataService.cs | 16 +- API/Services/ReadingItemService.cs | 2 +- .../Tasks/Scanner/Parser/PdfParser.cs | 3 + API/Services/Tasks/Scanner/ProcessSeries.cs | 2 +- API/config/appsettings.Development.json | 2 +- .../manage-settings.component.html | 38 +- .../manage-settings.component.ts | 10 +- .../chapter-detail.component.html | 4 +- .../chapter-detail.component.ts | 1 + .../manga-reader/manga-reader.component.ts | 2 +- .../person-detail.component.html | 1 + .../person-badge/person-badge.component.html | 3 +- .../scrobbling-holds.component.html | 2 +- .../volume-detail.component.html | 4 +- .../volume-detail/volume-detail.component.ts | 2 + openapi.json | 2 +- 24 files changed, 1949 insertions(+), 57 deletions(-) create mode 100644 API.Tests/Services/Test Data/BookService/encrypted.pdf create mode 100644 API.Tests/Services/Test Data/BookService/indirect.pdf create mode 100644 API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json create mode 100644 API/Helpers/PdfComicInfoExtractor.cs create mode 100644 API/Helpers/PdfMetadataExtractor.cs diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs index 23716e2f7..de87b9b6a 100644 --- a/API.Tests/Services/BookServiceTests.cs +++ b/API.Tests/Services/BookServiceTests.cs @@ -81,4 +81,47 @@ public class BookServiceTests Assert.Equal("Accel World", comicInfo.Series); } + [Fact] + public void ShouldHaveComicInfoForPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "test.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal("Variations Chromatiques de concert", comicInfo.Title); + Assert.Equal("Georges Bizet \\(1838-1875\\)", comicInfo.Writer); + } + + // TODO: Get the file from microtherion + // [Fact] + // public void ShouldUsePdfInfoDict() + // { + // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ScannerService/Library/Books/PDFs"); + // var document = Path.Join(testDirectory, "Rollo at Work SP01.pdf"); + // var comicInfo = _bookService.GetComicInfo(document); + // Assert.NotNull(comicInfo); + // Assert.Equal("Rollo at Work", comicInfo.Title); + // Assert.Equal("Jacob Abbott", comicInfo.Writer); + // Assert.Equal(2008, comicInfo.Year); + // } + + [Fact] + public void ShouldHandleIndirectPdfObjects() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "indirect.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.NotNull(comicInfo); + Assert.Equal(2018, comicInfo.Year); + Assert.Equal(8, comicInfo.Month); + } + + [Fact] + public void FailGracefullyWithEncryptedPdf() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService"); + var document = Path.Join(testDirectory, "encrypted.pdf"); + var comicInfo = _bookService.GetComicInfo(document); + Assert.Null(comicInfo); + } } diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 8cc7073da..5b6feeefa 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -101,7 +101,22 @@ public class ScannerServiceTests : AbstractDbTest [Fact] public async Task ScanLibrary_FlatSeriesWithSpecialFolder() { - var testcase = "Flat Series with Specials Folder - Manga.json"; + var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; + var library = await _scannerHelper.GenerateScannerData(testcase); + var scanner = _scannerHelper.CreateServices(); + await scanner.ScanLibrary(library.Id); + var postLib = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(library.Id, LibraryIncludes.Series); + + Assert.NotNull(postLib); + Assert.Single(postLib.Series); + Assert.Equal(4, postLib.Series.First().Volumes.Count); + Assert.NotNull(postLib.Series.First().Volumes.FirstOrDefault(v => v.Chapters.FirstOrDefault(c => c.IsSpecial) != null)); + } + + [Fact] + public async Task ScanLibrary_FlatSeriesWithSpecialFolder_AlternativeNaming() + { + var testcase = "Flat Series with Specials Folder Alt Naming - Manga.json"; var library = await _scannerHelper.GenerateScannerData(testcase); var scanner = _scannerHelper.CreateServices(); await scanner.ScanLibrary(library.Id); diff --git a/API.Tests/Services/Test Data/BookService/encrypted.pdf b/API.Tests/Services/Test Data/BookService/encrypted.pdf new file mode 100644 index 0000000000000000000000000000000000000000..64249b72884810ab6e44fdce37e193f702f99549 GIT binary patch literal 6313 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 NgF)ow^^Y1r{s%tkQ}_S? literal 0 HcmV?d00001 diff --git a/API.Tests/Services/Test Data/BookService/indirect.pdf b/API.Tests/Services/Test Data/BookService/indirect.pdf new file mode 100644 index 0000000000000000000000000000000000000000..11ecdcb7632c12d108e5052819eff09bf1a37509 GIT binary patch literal 33990 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 S9865iOpMSZBtkO6(EkSynPXA_ literal 0 HcmV?d00001 diff --git a/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json new file mode 100644 index 000000000..06ed0f1f9 --- /dev/null +++ b/API.Tests/Services/Test Data/ScannerService/TestCases/Flat Series with Specials Folder Alt Naming - Manga.json @@ -0,0 +1,6 @@ +[ + "My Dress-Up Darling/My Dress-Up Darling v01.cbz", + "My Dress-Up Darling/My Dress-Up Darling v02.cbz", + "My Dress-Up Darling/My Dress-Up Darling ch 10.cbz", + "My Dress-Up Darling/Specials/My Dress-Up Darling - Omakes SP01.cbz" +] \ No newline at end of file diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs new file mode 100644 index 000000000..f01a25604 --- /dev/null +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -0,0 +1,159 @@ +/// Translate PDF metadata (See PdfMetadataExtractor.cs) into ComicInfo structure. + +// Contributed by https://github.com/microtherion + +// All references to the "PDF Spec" (section numbers, etc) refer to the +// PDF 1.7 Specification a.k.a. PDF32000-1:2008 +// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + +using System; +using System.Xml; +using System.Text; +using System.IO; +using System.Diagnostics; +using API.Data.Metadata; +using API.Entities.Enums; +using API.Services; +using API.Services.Tasks.Scanner.Parser; +using Microsoft.Extensions.Logging; +using Nager.ArticleNumber; +using System.Collections.Generic; + +namespace API.Helpers; +#nullable enable + +public interface IPdfComicInfoExtractor +{ + ComicInfo? GetComicInfo(string filePath); +} + +public class PdfComicInfoExtractor : IPdfComicInfoExtractor +{ + private readonly ILogger _logger; + private readonly IMediaErrorService _mediaErrorService; + private readonly string[] _pdfDateFormats = [ // PDF Spec 7.9.4 + "D:yyyyMMddHHmmsszzz:", "D:yyyyMMddHHmmss+", "D:yyyyMMddHHmmss", + "D:yyyyMMddHHmmzzz:", "D:yyyyMMddHHmm+", "D:yyyyMMddHHmm", + "D:yyyyMMddHHzzz:", "D:yyyyMMddHH+", "D:yyyyMMddHH", + "D:yyyyMMdd", "D:yyyyMM", "D:yyyy" + ]; + + public PdfComicInfoExtractor(ILogger logger, IMediaErrorService mediaErrorService) + { + _logger = logger; + _mediaErrorService = mediaErrorService; + } + + private float? GetFloatFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + if (float.TryParse(text, out var value)) return value; + + return null; + } + + private DateTime? GetDateTimeFromText(string? text) + { + if (string.IsNullOrEmpty(text)) return null; + + // Dates stored in the XMP metadata stream (PDF Spec 14.3.2) + // are stored in ISO 8601 format, which is handled by C# out of the box + if (DateTime.TryParse(text, out var date)) return date; + + // Dates stored in the document information directory (PDF Spec 14.3.3) + // are stored in a proprietary format (PDF Spec 7.9.4) that needs to be + // massaged slightly to be expressible by a DateTime format. + if (text[0] != 'D') { + text = "D:" + text; + } + text = text.Replace("'", ":"); + text = text.Replace("Z", "+"); + + foreach(var format in _pdfDateFormats) + { + if (DateTime.TryParseExact(text, format, null, System.Globalization.DateTimeStyles.None, out var pdfDate)) return pdfDate; + } + + return null; + } + + private string? MaybeGetMetadata(Dictionary metadata, string key) + { + return metadata.ContainsKey(key) ? metadata[key] : null; + } + + private ComicInfo? GetComicInfoFromMetadata(Dictionary metadata, string filePath) + { + var info = new ComicInfo(); + + var publicationDate = GetDateTimeFromText(MaybeGetMetadata(metadata, "CreationDate")); + + if (publicationDate != null) + { + info.Year = publicationDate.Value.Year; + info.Month = publicationDate.Value.Month; + info.Day = publicationDate.Value.Day; + } + + info.Summary = MaybeGetMetadata(metadata, "Summary") ?? string.Empty; + info.Publisher = MaybeGetMetadata(metadata, "Publisher") ?? string.Empty; + info.Writer = MaybeGetMetadata(metadata, "Author") ?? string.Empty; + info.Title = MaybeGetMetadata(metadata, "Title") ?? string.Empty; + info.Genre = MaybeGetMetadata(metadata, "Subject") ?? string.Empty; + info.LanguageISO = BookService.ValidateLanguage(MaybeGetMetadata(metadata, "Language")); + info.Isbn = MaybeGetMetadata(metadata, "ISBN") ?? string.Empty; + + if (info.Isbn != string.Empty && !ArticleNumberHelper.IsValidIsbn10(info.Isbn) && !ArticleNumberHelper.IsValidIsbn13(info.Isbn)) + { + _logger.LogDebug("[BookService] {File} has an invalid ISBN number", filePath); + info.Isbn = string.Empty; + } + + info.UserRating = GetFloatFromText(MaybeGetMetadata(metadata, "UserRating")) ?? 0.0f; + info.TitleSort = MaybeGetMetadata(metadata, "TitleSort") ?? string.Empty; + info.Series = MaybeGetMetadata(metadata, "Series") ?? info.TitleSort; + info.SeriesSort = info.Series; + info.Volume = (GetFloatFromText(MaybeGetMetadata(metadata, "Volume")) ?? 0.0f).ToString(); + + // If this is a single book and not a collection, set publication status to Completed + if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath, LibraryType.Manga).Equals(Parser.LooseLeafVolume)) + { + info.Count = 1; + } + + // Removed as probably unneeded per discussion in https://github.com/Kareadita/Kavita/pull/3108#discussion_r1956747782 + // + // var hasVolumeInSeries = !Parser.ParseVolume(info.Title, LibraryType.Manga) + // .Equals(Parser.LooseLeafVolume); + + // if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries && (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series))) + // { + // // This is likely a light novel for which we can set series from parsed title + // info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga); + // info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga); + // } + + ComicInfo.CleanComicInfo(info); + + return info; + } + + public ComicInfo? GetComicInfo(string filePath) + { + try + { + var extractor = new PdfMetadataExtractor(_logger, filePath); + + return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath); + _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, + "There was an exception parsing PDF metadata", ex); + } + + return null; + } +} \ No newline at end of file diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs new file mode 100644 index 000000000..5ef20516c --- /dev/null +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -0,0 +1,1660 @@ +/// Parse PDF file and try to extract as much metadata as possible. +/// Supports both text based XRef tables and compressed XRef streams (Deflate only). +/// Supports both UTF-16 and PDFDocEncoding for strings. +/// Lacks support for many PDF configurations that are theoretically possible, but should handle most common cases. + +// Contributed by https://github.com/microtherion + +// All references to the "PDF Spec" (section numbers, etc) refer to the +// PDF 1.7 Specification a.k.a. PDF32000-1:2008 +// https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO.Compression; +using System.Reflection.Metadata.Ecma335; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Text; +using System.Xml; +using System.IO; +using Microsoft.Extensions.Logging; +using API.Services; + +namespace API.Helpers; +#nullable enable + +public class PdfMetadataExtractorException : Exception +{ + public PdfMetadataExtractorException() + { + } + + public PdfMetadataExtractorException(string message) + : base(message) + { + } + + public PdfMetadataExtractorException(string message, Exception inner) + : base(message, inner) + { + } +} + +public interface IPdfMetadataExtractor +{ + Dictionary GetMetadata(); +} + +class PdfStringBuilder +{ + private readonly StringBuilder _builder = new(); + private bool _secondByte = false; + private byte _prevByte = 0; + private bool _isUnicode = false; + + // PDFDocEncoding defined in PDF Spec D.1 + + private readonly char[] _pdfDocMappingLow = new char[] { + '\u02D8', '\u02C7', '\u02C6', '\u02D9', '\u02DD', '\u02DB', '\u02DA', '\u02DC', + }; + + private readonly char[] _pdfDocMappingHigh = new char[] { + '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044', + '\u2039', '\u203A', '\u2212', '\u2030', '\u201E', '\u201C', '\u201D', '\u2018', + '\u2019', '\u201A', '\u2122', '\uFB01', '\uFB02', '\u0141', '\u0152', '\u0160', + '\u0178', '\u017D', '\u0131', '\u0142', '\u0153', '\u0161', '\u017E', ' ', + '\u20AC', + }; + + public void AppendPdfDocByte(byte b) + { + if (b >= 0x18 && b < 0x20) + { + _builder.Append(_pdfDocMappingLow[b - 0x18]); + } + else if (b >= 0x80 && b < 0xA1) + { + _builder.Append(_pdfDocMappingHigh[b - 0x80]); + } + else + { + _builder.Append((char)b); + } + } + + public void Append(char c) + { + _builder.Append(c); + } + + public void AppendByte(byte b) + { + // PDF Spec 7.9.2.1: Strings are either UTF-16BE or PDFDocEncoded + if (_builder.Length == 0 && !_isUnicode) + { + // Unicode strings are prefixed by a big endian BOM \uFEFF + if (_secondByte) + { + if (b == 0xFF) + { + _isUnicode = true; + _secondByte = false; + } + else + { + AppendPdfDocByte(_prevByte); + AppendPdfDocByte(b); + } + } + else if (!_secondByte && b == 0xFE) + { + _secondByte = true; + _prevByte = b; + } + else + { + AppendPdfDocByte(b); + } + } + else if (_isUnicode) + { + if (_secondByte) + { + _builder.Append((char)(((char)_prevByte) << 8 | (char)b)); + _secondByte = false; + } + else + { + _prevByte = b; + _secondByte = true; + } + } + else + { + AppendPdfDocByte(b); + } + } + + override public string ToString() + { + if (_builder.Length == 0 && _secondByte) + { + AppendPdfDocByte(_prevByte); + } + + return _builder.ToString(); + } +} + +class PdfLexer(Stream stream) +{ + public enum TokenType + { + None, + Bool, + Int, + Double, + Name, + String, + ArrayStart, + ArrayEnd, + DictionaryStart, + DictionaryEnd, + StreamStart, + StreamEnd, + ObjectStart, + ObjectEnd, + ObjectRef, + Keyword, + Newline, + } + + public struct Token + { + public TokenType type; + public object value; + + public Token(TokenType type, object value) + { + this.type = type; + this.value = value; + } + } + + public Token NextToken(bool reportNewlines = false) + { + while (true) + { + switch ((char)NextByte()) + { + case '\n' when reportNewlines: + return new Token(TokenType.Newline, true); + + case '\r' when reportNewlines: + if (NextByte() != '\n') + { + PutBack(); + } + return new Token(TokenType.Newline, true); + + case ' ': + case '\x00': + case '\t': + case '\n': + case '\f': + case '\r': + continue; // Skip whitespace + + case '%': + SkipComment(); + continue; + + case '+': + case '-': + case '.': + case >= '0' and <= '9': + return ScanNumber(); + + case '/': + return ScanName(); + + case '(': + return ScanString(); + + case '[': + return new Token(TokenType.ArrayStart, true); + + case ']': + return new Token(TokenType.ArrayEnd, true); + + case '<': + if (NextByte() == '<') + { + return new Token(TokenType.DictionaryStart, true); + } + else + { + PutBack(); + return ScanHexString(); + } + case '>': + ExpectByte((byte)'>'); + + return new Token(TokenType.DictionaryEnd, true); + + case >= 'a' and <= 'z': + case >= 'A' and <= 'Z': + return ScanKeyword(); + + default: + throw new PdfMetadataExtractorException("Unexpected byte, got {LastByte()}"); + } + } + } + + public void ResetBuffer() + { + _pos = 0; + _valid = 0; + } + + public bool TestByte(byte expected) + { + var result = NextByte() == expected; + + PutBack(); + + return result; + } + + public void ExpectNewline() + { + while (true) + { + byte b = NextByte(); + switch ((char)b) + { + case ' ': + case '\t': + case '\f': + continue; // Skip whitespace + + case '\n': + return; + + case '\r': + if (NextByte() != '\n') + { + PutBack(); + } + + return; + + default: + throw new PdfMetadataExtractorException("Unexpected character, expected newline, got {b}"); + } + } + } + + public long GetXRefStart() + { + // Look for the startxref element as per PDF Spec 7.5.5 + while (true) + { + byte b = NextByte(); + + switch ((char)b) + { + case '\r': + b = NextByte(); + + if (b != '\n') + { + PutBack(); + } + + goto case '\n'; + + case '\n': + // Handle consecutive newlines + while (true) + { + b = NextByte(); + + if (b == '\r') + { + goto case '\r'; + } + else if (b == '\n') + { + goto case '\n'; + } + else if (b == ' ' || b == '\t' || b == '\f') + { + continue; + } + else + { + PutBack(); + + break; + } + } + + var token = NextToken(true); + + if (token.type == TokenType.Keyword && (string)token.value == "startxref") + { + token = NextToken(); + + if (token.type == TokenType.Int) + { + return (long)token.value; + } + else + { + throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); + } + } + + continue; + + default: + continue; + } + } + } + + public bool NextXRefEntry(ref long obj, ref int generation) + { + // Cross-reference table entry as per PDF Spec 7.5.4 + + WantLookahead(20); + + if (_valid - _pos < 20) + { + throw new PdfMetadataExtractorException("End of stream"); + } + + var inUse = true; + + if (obj == 0) + { + obj = Convert.ToInt64(System.Text.Encoding.ASCII.GetString(_buffer, _pos, 10)); + generation = Convert.ToInt32(System.Text.Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); + inUse = _buffer[_pos + 17] == 'n'; + } + + _pos += 20; + + return inUse; + } + + public Stream StreamObject(int length, bool deflate) + { + // Read a stream object as per PDF Spec 7.3.8 + // At the moment, we only accept uncompressed streams or the FlateDecode (PDF Spec 7.4.1) filter + // with no parameters. These cover the vast majority of streams we're interested in. + + var rawData = new MemoryStream(); + + ExpectNewline(); + + if (_pos < _valid) + { + int buffered = Math.Min(_valid - _pos, length); + rawData.Write(_buffer, _pos, buffered); + length -= buffered; + _pos += buffered; + } + + while (length > 0) + { + int buffered = Math.Min(length, _bufferSize); + stream.Read(_buffer, 0, buffered); + rawData.Write(_buffer, 0, buffered); + _pos = 0; + _valid = 0; + length -= buffered; + } + + rawData.Seek(0, SeekOrigin.Begin); + + if (deflate) + { + return new ZLibStream(rawData, CompressionMode.Decompress, false); + } + else + { + return rawData; + } + } + + private const int _bufferSize = 1024; + private readonly byte[] _buffer = new byte[_bufferSize]; + private int _pos = 0; + private int _valid = 0; + + private byte NextByte() + { + if (_pos >= _valid) + { + _pos = 0; + _valid = stream.Read(_buffer, 0, _bufferSize); + + if (_valid <= 0) + { + throw new PdfMetadataExtractorException("End of stream"); + } + } + + return _buffer[_pos++]; + } + + private byte LastByte() + { + return _buffer[_pos - 1]; + } + + private void PutBack() + { + --_pos; + } + + private void ExpectByte(byte expected) + { + if (NextByte() != expected) + { + throw new PdfMetadataExtractorException($"Unexpected character, expected {expected}"); + } + } + + private void WantLookahead(int length) + { + if (_pos + length > _valid) + { + Buffer.BlockCopy(_buffer, _pos, _buffer, 0, _valid - _pos); + _valid -= _pos; + _pos = 0; + _valid += stream.Read(_buffer, _valid, _bufferSize - _valid); + } + } + + private void SkipComment() + { + while (true) + { + byte b = NextByte(); + + if (b == '\n') + { + break; + } + else if (b == '\r') + { + if (NextByte() != '\n') + { + PutBack(); + } + + break; + } + } + } + + private Token ScanNumber() + { + StringBuilder sb = new(); + bool hasDot = LastByte() == '.'; + bool followedBySpace = false; + + sb.Append((char)LastByte()); + + while (true) + { + byte b = NextByte(); + + if (b == '.' || b >= '0' && b <= '9') + { + sb.Append((char)b); + + if (b == '.') + { + hasDot = true; + } + } + else + { + followedBySpace = (b == ' ' || b == '\t'); + PutBack(); + + break; + } + } + if (hasDot) + { + return new Token(TokenType.Double, double.Parse(sb.ToString())); + } + if (followedBySpace) + { + // Look ahead to see if it's an object reference (PDF Spec 7.3.10) + WantLookahead(32); + + var savedPos = _pos; + byte b = NextByte(); + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + // Generation number (ignored) + while (b >= '0' && b <= '9') + { + b = NextByte(); + } + + while (b == ' ' || b == '\t') + { + b = NextByte(); + } + + if (b == 'R') + { + return new Token(TokenType.ObjectRef, long.Parse(sb.ToString())); + } + else if (b == 'o' && NextByte() == 'b' && NextByte() == 'j') + { + return new Token(TokenType.ObjectStart, long.Parse(sb.ToString())); + } + else + { + _pos = savedPos; + } + } + + return new Token(TokenType.Int, long.Parse(sb.ToString())); + } + + private int HexDigit(byte b) + { + switch ((char)b) + { + case >= '0' and <= '9': + return b - (byte)'0'; + + case >= 'a' and <= 'f': + return b - (byte)'a' + 10; + + case >= 'A' and <= 'F': + return b - (byte)'A' + 10; + + default: + throw new PdfMetadataExtractorException("Invalid hex digit, got {b}"); + } + } + + private Token ScanName() + { + // PDF Spec 7.3.5 + + StringBuilder sb = new StringBuilder(); + while (true) + { + byte b = NextByte(); + switch ((char)b) + { + case '(': + case ')': + case '[': + case ']': + case '{': + case '}': + case '<': + case '>': + case '/': + case '%': + PutBack(); + + goto case ' '; + + case ' ': + case '\t': + case '\n': + case '\f': + case '\r': + return new Token(TokenType.Name, sb.ToString()); + + case '#': + byte b1 = NextByte(); + byte b2 = NextByte(); + b = (byte)((HexDigit(b1) << 4) | HexDigit(b2)); + + goto default; + + default: + sb.Append((char)b); + break; + } + } + } + + private Token ScanString() + { + // PDF Spec 7.3.4.2 + + PdfStringBuilder sb = new(); + int parenLevel = 1; + + while (true) + { + byte b = NextByte(); + + switch ((char)b) + { + case '(': + parenLevel++; + + goto default; + + case ')': + if (--parenLevel == 0) + { + return new Token(TokenType.String, sb.ToString()); + } + + goto default; + + case '\\': + b = NextByte(); + + switch ((char)b) + { + case 'b': + sb.Append('\b'); + + break; + + case 'f': + sb.Append('\f'); + + break; + + case 'n': + sb.Append('\n'); + + break; + + case 'r': + sb.Append('\r'); + + break; + + case 't': + sb.Append('\t'); + + break; + + case >= '0' and <= '7': + byte b1 = b; + byte b2 = NextByte(); + byte b3 = NextByte(); + + if (b2 < '0' || b2 > '7' || b3 < '0' || b3 > '7') + { + throw new PdfMetadataExtractorException("Invalid octal escape, got {b1}{b2}{b3}"); + } + + sb.AppendByte((byte)((b1 - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); + + break; + } + break; + + default: + sb.AppendByte(b); + break; + } + } + } + + private Token ScanHexString() + { + // PDF Spec 7.3.4.3 + + PdfStringBuilder sb = new(); + + while (true) + { + byte b = NextByte(); + + switch ((char)b) + { + case (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'): + byte b1 = NextByte(); + if (b1 == '>') + { + PutBack(); + b1 = (byte)'0'; + } + sb.AppendByte((byte)(HexDigit(b) << 4 | HexDigit(b1))); + + break; + + case '>': + return new Token(TokenType.String, sb.ToString()); + + default: + throw new PdfMetadataExtractorException("Invalid hex string, got {b}"); + } + } + } + + private Token ScanKeyword() + { + StringBuilder sb = new(); + + sb.Append((char)LastByte()); + + while (true) + { + byte b = NextByte(); + if ((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')) + { + sb.Append((char)b); + } + else + { + PutBack(); + + break; + } + } + + switch (sb.ToString()) + { + case "true": + return new Token(TokenType.Bool, true); + + case "false": + return new Token(TokenType.Bool, false); + + case "stream": + return new Token(TokenType.StreamStart, true); + + case "endstream": + return new Token(TokenType.StreamEnd, true); + + case "endobj": + return new Token(TokenType.ObjectEnd, true); + + default: + return new Token(TokenType.Keyword, sb.ToString()); + } + } +} + +class PdfMetadataExtractor : IPdfMetadataExtractor +{ + private readonly ILogger _logger; + private readonly PdfLexer _lexer; + private readonly FileStream _stream; + private long[] _objectOffsets = new long[0]; + private readonly Dictionary _metadata = new(); + + private struct MetadataRef + { + public long root; + public long info; + + public MetadataRef(long root, long info) + { + this.root = root; + this.info = info; + } + } + + private readonly Stack metadataRef = new(); + + private struct XRefSection + { + public long first; + public long count; + + public XRefSection(long first, long count) + { + this.first = first; + this.count = count; + } + } + + public PdfMetadataExtractor(ILogger logger, string filename) + { + _logger = logger; + _stream = File.OpenRead(filename); + _lexer = new PdfLexer(_stream); + + ReadObjectOffsets(); + ReadMetadata(filename); + } + + public Dictionary GetMetadata() + { + return _metadata; + } + + private void LogMetadata(string filename) + { + _logger.LogTrace("Metadata for {Path}:", filename); + + foreach (var entry in _metadata) + { + _logger.LogTrace(" {Key:0,-5} : {Value:1}", entry.Key, entry.Value); + } + } + + private void ReadObjectOffsets() + { + // Look for file trailer (PDF Spec 7.5.5) + // Spec says trailer must be strictly at end of file. + // Adobe software accepts trailer within last 1K of EOF, + // but in practice, virtually all PDFs have trailer at end. + + _stream.Seek(-32, SeekOrigin.End); + + var xrefOffset = _lexer.GetXRefStart(); + + ReadXRefAndTrailer(xrefOffset); + } + + private void ReadXRefAndTrailer(long xrefOffset) + { + _stream.Seek(xrefOffset, SeekOrigin.Begin); + _lexer.ResetBuffer(); + + if (!_lexer.TestByte((byte)'x')) + { + // Cross-reference stream (PDF Spec 7.5.8) + + ReadXRefStream(); + + return; + } + + // Cross-reference table (PDF Spec 7.5.4) + + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.Keyword || (string)token.value != "xref") + { + throw new PdfMetadataExtractorException("Expected xref keyword"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.type == PdfLexer.TokenType.Int) + { + var startObj = (long)token.value; + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected number of objects in xref subsection"); + } + + var numObj = (long)token.value; + + if (_objectOffsets.Length < startObj + numObj) + { + Array.Resize(ref _objectOffsets, (int)(startObj + numObj)); + } + + _lexer.ExpectNewline(); + + var generation = 0; + + for (var obj = startObj; obj < startObj + numObj; ++obj) + { + var inUse = _lexer.NextXRefEntry(ref _objectOffsets[obj], ref generation); + + if (!inUse) + { + _objectOffsets[obj] = 0; + } + } + } + else if (token.type == PdfLexer.TokenType.Keyword && (string)token.value == "trailer") + { + break; + } + else + { + throw new PdfMetadataExtractorException("Unexpected token in xref"); + } + } + + ReadTrailerDictionary(); + } + + private void ReadXRefStream() + { + // Cross-reference stream (PDF Spec 7.5.8) + + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected obj keyword"); + } + + long length = -1; + long size = -1; + var deflate = false; + long prev = -1; + long typeWidth = -1; + long offsetWidth = -1; + long generationWidth = -1; + Queue sections = new(); + var meta = new MetadataRef(-1, -1); + + // Cross-reference stream dictionary (PDF Spec 7.5.8.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) { + switch (key) + { + case "Type": + if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XRef") + { + throw new PdfMetadataExtractorException("Expected /Type to be /XRef"); + } + + return true; + + case "Length": + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.value; + + return true; + + case "Size": + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Size"); + } + + size = (long)value.value; + + return true; + + case "Prev": + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.value; + + return true; + + case "Index": + if (value.type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /Index"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + else if (token.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /Index array"); + } + + var first = (long)token.value; + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer pair in /Index array"); + } + + var count = (long)token.value; + sections.Enqueue(new XRefSection(first, count)); + } + + return true; + + case "W": + if (value.type != PdfLexer.TokenType.ArrayStart) + { + throw new PdfMetadataExtractorException("Expected array after /W"); + } + + var widths = new long[3]; + + for (var i = 0; i < 3; ++i) + { + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer in /W array"); + } + + widths[i] = (long)token.value; + } + + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ArrayEnd) + { + throw new PdfMetadataExtractorException("Unclosed array after /W"); + } + + typeWidth = widths[0]; + offsetWidth = widths[1]; + generationWidth = widths[2]; + + return true; + + case "Filter": + if (value.type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + case "Root": + if (value.type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.root = (long)value.value; + + return true; + + case "Info": + if (value.type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.info = (long)value.value; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var stream = _lexer.StreamObject((int)length, deflate); + + if (sections.Count == 0) + { + sections.Enqueue(new XRefSection(0, size)); + } + + while (sections.Count > 0) + { + var section = sections.Dequeue(); + + if (_objectOffsets.Length < size) + { + Array.Resize(ref _objectOffsets, (int)size); + } + + for (var i = section.first; i < section.first + section.count; ++i) + { + long type = 0; + long offset = 0; + long generation = 0; + + if (typeWidth == 0) + { + type = 1; + } + + for (var j = 0; j < typeWidth; ++j) + { + type = (type << 8) | (UInt16)stream.ReadByte(); + } + + for (var j = 0; j < offsetWidth; ++j) + { + offset = (offset << 8) | (UInt16)stream.ReadByte(); + } + + for (var j = 0; j < generationWidth; ++j) + { + generation = (generation << 8) | (UInt16)stream.ReadByte(); + } + + if (type == 1 && _objectOffsets[i] == 0) + { + _objectOffsets[i] = offset; + } + } + } + + if (prev > -1) + { + ReadXRefAndTrailer(prev); + } + + PushMetadataRef(meta); + } + + private void PushMetadataRef(MetadataRef meta) + { + if (metadataRef.Count > 0) + { + if (meta.root == metadataRef.Peek().root) + { + meta.root = -1; + } + + if (meta.info == metadataRef.Peek().info) + { + meta.info = -1; + } + } + + if (meta.root != -1 || meta.info != -1) + { + metadataRef.Push(meta); + } + } + + private void ReadTrailerDictionary() + { + // Read trailer directory (PDF Spec 7.5.5) + + long prev = -1; + long xrefStm = -1; + + MetadataRef meta = new(-1, -1); + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Root": + if (value.type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Root"); + } + + meta.root = (long)value.value; + + return true; + case "Prev": + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /Prev"); + } + + prev = (long)value.value; + + return true; + case "Info": + if (value.type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object reference after /Info"); + } + + meta.info = (long)value.value; + + return true; + case "XRefStm": + // Prefer encoded xref stream over xref table + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected offset after /XRefStm"); + } + + xrefStm = (long)value.value; + + return true; + + case "Encrypt": + throw new PdfMetadataExtractorException("Encryption not supported"); + + default: + return false; + } + }); + + PushMetadataRef(meta); + + if (xrefStm != -1) + { + ReadXRefAndTrailer(xrefStm); + } + + if (prev != -1) + { + ReadXRefAndTrailer(prev); + } + } + + private void ReadMetadata(string filename) + { + // We read potential metadata sources in backwards historical order, so + // we can overwrite to our heart's content + + while (metadataRef.Count > 0) + { + var meta = metadataRef.Pop(); + + _logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + + ReadMetadataFromInfo(meta.info); + ReadMetadataFromXML(MetadataObjInObjectCatalog(meta.root)); + } + } + + private void ReadMetadataFromInfo(long infoObj) + { + // Document information dictionary (PDF Spec 14.3.3) + // We treat this as less authoritative than the Metadata stream. + + if (infoObj < 1 || infoObj >= _objectOffsets.Length || _objectOffsets[infoObj] == 0) + { + return; + } + + _stream.Seek(_objectOffsets[infoObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + Dictionary indirectObjects = new(); + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) + { + case "Title": + case "Author": + case "Subject": + case "Keywords": + case "Creator": + case "Producer": + case "CreationDate": + case "ModDate": + if (value.type == PdfLexer.TokenType.ObjectRef) { + indirectObjects[key] = (long)value.value; + } + else if (value.type != PdfLexer.TokenType.String) + { + throw new PdfMetadataExtractorException("Expected string value"); + } + else + { + _metadata[key] = (string)value.value; + } + + return true; + + default: + return false; + } + }); + + // Resolve indirectly referenced values + foreach(var key in indirectObjects.Keys) { + _stream.Seek(_objectOffsets[indirectObjects[key]], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ObjectStart) { + throw new PdfMetadataExtractorException("Expected object here"); + } + + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.String) { + throw new PdfMetadataExtractorException("Expected string"); + } + + _metadata[key] = (string)token.value; + } + } + + private long MetadataObjInObjectCatalog(long rootObj) + { + // Look for /Metadata entry in document catalog (PDF Spec 7.7.2) + + if (rootObj < 1 || rootObj >= _objectOffsets.Length || _objectOffsets[rootObj] == 0) + { + return -1; + } + + _stream.Seek(_objectOffsets[rootObj], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long meta = -1; + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Metadata": + if (value.type != PdfLexer.TokenType.ObjectRef) + { + throw new PdfMetadataExtractorException("Expected object number after /Metadata"); + } + + meta = (long)value.value; + + return true; + + default: + return false; + } + }); + + return meta; + } + + // Obtain metadata from XMP stream object + // See XMP specification: https://developer.adobe.com/xmp/docs/XMPSpecifications/ + // and Dublin Core: https://www.dublincore.org/specifications/dublin-core/ + + private string? GetTextFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + return (doc.DocumentElement?.SelectSingleNode(path + "//rdf:li", ns) + ?? doc.DocumentElement?.SelectSingleNode(path, ns))?.InnerText; + } + + private string? GetListFromXmlNode(XmlDocument doc, XmlNamespaceManager ns, string path) + { + var nodes = doc.DocumentElement?.SelectNodes(path + "//rdf:li", ns); + + if (nodes == null) return null; + + var list = new StringBuilder(); + + foreach (XmlNode n in nodes) + { + if (list.Length > 0) + { + list.Append(","); + } + + list.Append(n.InnerText); + } + + return list.Length > 0 ? list.ToString() : null; + } + + private void SetMetadata(string key, string? value) + { + if (value == null) return; + + _metadata[key] = value; + } + + private void ReadMetadataFromXML(long meta) + { + if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; + + _stream.Seek(_objectOffsets[meta], SeekOrigin.Begin); + _lexer.ResetBuffer(); + + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.ObjectStart) + { + throw new PdfMetadataExtractorException("Expected object header"); + } + + long length = -1; + var deflate = false; + + // Metadata stream dictionary (PDF Spec 14.3.2) + + ParseDictionary(delegate(string key, PdfLexer.Token value) + { + switch (key) { + case "Type": + if (value.type != PdfLexer.TokenType.Name || (string)value.value != "Metadata") + { + throw new PdfMetadataExtractorException("Expected /Type to be /Metadata"); + } + + return true; + + case "Subtype": + if (value.type != PdfLexer.TokenType.Name || (string)value.value != "XML") + { + throw new PdfMetadataExtractorException("Expected /Subtype to be /XML"); + } + + return true; + + case "Length": + if (value.type != PdfLexer.TokenType.Int) + { + throw new PdfMetadataExtractorException("Expected integer after /Length"); + } + + length = (long)value.value; + + return true; + + case "Filter": + if (value.type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name after /Filter"); + } + + if ((string)value.value != "FlateDecode") + { + throw new PdfMetadataExtractorException("Unsupported filter, only FlateDecode is supported"); + } + + deflate = true; + + return true; + + default: + return false; + } + }); + + token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.StreamStart) + { + throw new PdfMetadataExtractorException("Expected xref stream after dictionary"); + } + + var xmlStream = _lexer.StreamObject((int)length, deflate); + + // Skip XMP header + while (true) { + var b = xmlStream.ReadByte(); + + if (b < 0) { + throw new PdfMetadataExtractorException("Reached EOF in XMP header"); + } + + if (b == '?') { + while (b == '?') { + b = xmlStream.ReadByte(); + } + + if (b == '>') { + break; + } + } + } + + var metaDoc = new XmlDocument(); + metaDoc.Load(xmlStream); + + var ns = new XmlNamespaceManager(metaDoc.NameTable); + ns.AddNamespace("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + ns.AddNamespace("dc", "http://purl.org/dc/elements/1.1/"); + ns.AddNamespace("calibreSI", "http://calibre-ebook.com/xmp-namespace-series-index"); + ns.AddNamespace("calibre", "http://calibre-ebook.com/xmp-namespace"); + ns.AddNamespace("pdfx", "http://ns.adobe.com/pdfx/1.3/"); + ns.AddNamespace("prism", "http://prismstandard.org/namespaces/basic/2.0/"); + ns.AddNamespace("xmp", "http://ns.adobe.com/xap/1.0/"); + + SetMetadata("CreationDate", + GetTextFromXmlNode(metaDoc, ns, "//dc:date") + ?? GetTextFromXmlNode(metaDoc, ns, "//xmp:CreateDate")); + SetMetadata("Summary", GetTextFromXmlNode(metaDoc, ns, "//dc:description")); + SetMetadata("Publisher", GetTextFromXmlNode(metaDoc, ns, "//dc:publisher")); + SetMetadata("Author", GetListFromXmlNode(metaDoc, ns, "//dc:creator")); + SetMetadata("Title", GetTextFromXmlNode(metaDoc, ns, "//dc:title")); + SetMetadata("Subject", GetListFromXmlNode(metaDoc, ns, "//dc:subject")); + SetMetadata("Language", GetTextFromXmlNode(metaDoc, ns, "//dc:language")); + SetMetadata("ISBN", GetTextFromXmlNode(metaDoc, ns, "//pdfx:isbn") ?? GetTextFromXmlNode(metaDoc, ns, "//prism:isbn")); + SetMetadata("UserRating", GetTextFromXmlNode(metaDoc, ns, "//calibre:rating")); + SetMetadata("TitleSort", GetTextFromXmlNode(metaDoc, ns, "//calibre:title_sort")); + SetMetadata("Series", GetTextFromXmlNode(metaDoc, ns, "//calibre:series/rdf:value")); + SetMetadata("Volume", GetTextFromXmlNode(metaDoc, ns, "//calibreSI:series_index")); + } + + private delegate bool DictionaryHandler(string key, PdfLexer.Token value); + + private void ParseDictionary(DictionaryHandler handler) + { + var token = _lexer.NextToken(); + + if (token.type != PdfLexer.TokenType.DictionaryStart) + { + throw new PdfMetadataExtractorException("Expected dictionary"); + } + + while (true) + { + token = _lexer.NextToken(); + + if (token.type == PdfLexer.TokenType.DictionaryEnd) + { + return; + } + else if (token.type == PdfLexer.TokenType.Name) + { + var value = _lexer.NextToken(); + + if (!handler((string)token.value, value)) { + SkipValue(value); + } + } + else + { + throw new PdfMetadataExtractorException("Improper token in dictionary"); + } + } + } + + private void SkipValue(PdfLexer.Token? existingToken = null) + { + var token = existingToken ?? _lexer.NextToken(); + + switch (token.type) + { + case PdfLexer.TokenType.Bool: + case PdfLexer.TokenType.Int: + case PdfLexer.TokenType.Double: + case PdfLexer.TokenType.Name: + case PdfLexer.TokenType.String: + case PdfLexer.TokenType.ObjectRef: + break; + + case PdfLexer.TokenType.ArrayStart: + SkipArray(); + + break; + + case PdfLexer.TokenType.DictionaryStart: + SkipDictionary(); + + break; + + default: + throw new PdfMetadataExtractorException("Unexpected token in SkipValue"); + } + } + + private void SkipArray() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.type == PdfLexer.TokenType.ArrayEnd) + { + break; + } + + SkipValue(token); + } + } + + private void SkipDictionary() + { + while (true) + { + var token = _lexer.NextToken(); + + if (token.type == PdfLexer.TokenType.DictionaryEnd) + { + break; + } + else if (token.type != PdfLexer.TokenType.Name) + { + throw new PdfMetadataExtractorException("Expected name in dictionary"); + } + + SkipValue(); + } + } +} diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 740227af8..55b652e1f 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -6,12 +6,14 @@ using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Xml; using API.Data.Metadata; using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services.Tasks.Scanner.Parser; +using API.Helpers; using Docnet.Core; using Docnet.Core.Converters; using Docnet.Core.Models; @@ -69,6 +71,8 @@ public class BookService : IBookService private static readonly RecyclableMemoryStreamManager StreamManager = new (); private const string CssScopeClass = ".book-content"; private const string BookApiUrl = "book-resources?file="; + private readonly PdfComicInfoExtractor _pdfComicInfoExtractor; + public static readonly EpubReaderOptions BookReaderOptions = new() { PackageReaderOptions = new PackageReaderOptions @@ -84,6 +88,7 @@ public class BookService : IBookService _directoryService = directoryService; _imageService = imageService; _mediaErrorService = mediaErrorService; + _pdfComicInfoExtractor = new PdfComicInfoExtractor(_logger, _mediaErrorService); } private static bool HasClickableHrefPart(HtmlNode anchor) @@ -425,10 +430,8 @@ public class BookService : IBookService } } - public ComicInfo? GetComicInfo(string filePath) + private ComicInfo? GetEpubComicInfo(string filePath) { - if (!IsValidFile(filePath) || Parser.IsPdf(filePath)) return null; - try { using var epubBook = EpubReader.OpenBook(filePath, BookReaderOptions); @@ -442,7 +445,7 @@ public class BookService : IBookService var (year, month, day) = GetPublicationDate(publicationDate); var summary = epubBook.Schema.Package.Metadata.Descriptions.FirstOrDefault(); - var info = new ComicInfo + var info = new ComicInfo { Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description, Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers.Select(p => p.Publisher)), @@ -583,6 +586,20 @@ public class BookService : IBookService return null; } + public ComicInfo? GetComicInfo(string filePath) + { + if (!IsValidFile(filePath)) return null; + + if (Parser.IsPdf(filePath)) + { + return _pdfComicInfoExtractor.GetComicInfo(filePath); + } + else + { + return GetEpubComicInfo(filePath); + } + } + private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info) { var titleId = metadataItem.Refines?.Replace("#", string.Empty); @@ -685,7 +702,7 @@ public class BookService : IBookService return (year, month, day); } - private static string ValidateLanguage(string? language) + public static string ValidateLanguage(string? language) { if (string.IsNullOrEmpty(language)) return string.Empty; diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 3255d93df..1781ba4c6 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -566,7 +566,6 @@ public class ExternalMetadataService : IExternalMetadataService return false; } - var relatedSeriesDict = new Dictionary(); foreach (var relation in externalMetadataRelations) { var names = new [] {relation.SeriesName.PreferredTitle, relation.SeriesName.RomajiTitle, relation.SeriesName.EnglishTitle, relation.SeriesName.NativeTitle}; @@ -586,19 +585,6 @@ public class ExternalMetadataService : IExternalMetadataService if (relationshipExists) continue; - relatedSeriesDict[relatedSeries.Id] = relatedSeries; - } - - // Process relationships - foreach (var relation in externalMetadataRelations) - { - var relatedSeries = relatedSeriesDict.GetValueOrDefault( - relatedSeriesDict.Keys.FirstOrDefault(k => - relatedSeriesDict[k].Name == relation.SeriesName.PreferredTitle || - relatedSeriesDict[k].Name == relation.SeriesName.NativeTitle)); - - if (relatedSeries == null) continue; - // Add new relationship var newRelation = new SeriesRelation { @@ -969,7 +955,7 @@ public class ExternalMetadataService : IExternalMetadataService return false; } - if (!string.IsNullOrEmpty(externalMetadata.CoverUrl) && !settings.HasOverride(MetadataSettingField.Covers)) + if (string.IsNullOrEmpty(externalMetadata.CoverUrl)) { return false; } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index 581690733..efdaec8ff 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -52,7 +52,7 @@ public class ReadingItemService : IReadingItemService /// private ComicInfo? GetComicInfo(string filePath) { - if (Parser.IsEpub(filePath)) + if (Parser.IsEpub(filePath) || Parser.IsPdf(filePath)) { return _bookService.GetComicInfo(filePath); } diff --git a/API/Services/Tasks/Scanner/Parser/PdfParser.cs b/API/Services/Tasks/Scanner/Parser/PdfParser.cs index 696a61867..3a5debcbd 100644 --- a/API/Services/Tasks/Scanner/Parser/PdfParser.cs +++ b/API/Services/Tasks/Scanner/Parser/PdfParser.cs @@ -68,6 +68,9 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret); } + // Patch in other information from ComicInfo + UpdateFromComicInfo(ret); + if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book) { ret.IsSpecial = true; diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index dd9068a1a..ce8c2b41d 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -285,7 +285,7 @@ public class ProcessSeries : IProcessSeries var firstChapter = SeriesService.GetFirstChapterForMetadata(series); var firstFile = firstChapter?.Files.FirstOrDefault(); - if (firstFile == null || Parser.Parser.IsPdf(firstFile.FilePath)) return; + if (firstFile == null) return; var chapters = series.Volumes .SelectMany(volume => volume.Chapters) diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 83a13e7ce..0c6352d06 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -2,7 +2,7 @@ "TokenKey": "super secret unguessable key that is longer because we require it", "Port": 5000, "IpAddresses": "0.0.0.0,::", - "BaseUrl": "/test/", + "BaseUrl": "/", "Cache": 75, "AllowIFraming": false } \ No newline at end of file diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index e4468ccc5..a0e2c60b7 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -17,13 +17,13 @@ @if (settingsForm.get('hostName'); as formControl) { - {{formControl.value}} + {{formControl.value | defaultValue}} + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.pattern) {
{{t('host-name-validation')}}
@@ -44,11 +44,11 @@
+ [class.is-invalid]="formControl.invalid && !formControl.untouched">
- @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.pattern) {
{{t('base-url-validation')}}
@@ -69,11 +69,11 @@
+ [class.is-invalid]="formControl.invalid && !formControl.untouched">
- @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.pattern) {
{{t('ip-address-validation')}}
@@ -116,9 +116,9 @@ + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.required) {
{{t('field-required')}}
@@ -146,9 +146,9 @@ + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.required) {
{{t('field-required')}}
@@ -175,13 +175,13 @@ - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.pattern) {
{{t('host-name-validation')}}
@@ -202,9 +202,9 @@ + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.required) {
{{t('field-required')}}
@@ -271,9 +271,9 @@ + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.required) {
{{t('field-required')}}
@@ -298,9 +298,9 @@ + [class.is-invalid]="formControl.invalid && !formControl.untouched"> - @if(settingsForm.dirty || settingsForm.touched) { + @if(settingsForm.dirty || !settingsForm.untouched) {
@if (formControl.errors?.required) {
{{t('field-required')}}
diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 1f2c8a8fb..f79b29042 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -5,17 +5,15 @@ import {take} from 'rxjs/operators'; import {ServerService} from 'src/app/_services/server.service'; import {SettingsService} from '../settings.service'; import {ServerSettings} from '../_models/server-settings'; -import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; -import {NgTemplateOutlet, TitleCasePipe} from '@angular/common'; +import {TitleCasePipe} from '@angular/common'; import {translate, TranslocoModule, TranslocoService} from "@jsverse/transloco"; import {WikiLink} from "../../_models/wiki"; -import {PageLayoutModePipe} from "../../_pipes/page-layout-mode.pipe"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component"; -import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe"; import {ConfirmService} from "../../shared/confirm.service"; import {debounceTime, distinctUntilChanged, filter, of, switchMap, tap} from "rxjs"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; +import {DefaultValuePipe} from "../../_pipes/default-value.pipe"; const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*\,)*\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\:){0,7}([\da-f]{0,4})))\s*$/i; @@ -25,7 +23,7 @@ const ValidIpAddress = /^(\s*((([12]?\d{1,2}\.){3}[12]?\d{1,2})|(([\da-f]{0,4}\: styleUrls: ['./manage-settings.component.scss'], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent] + imports: [ReactiveFormsModule, TitleCasePipe, TranslocoModule, SettingItemComponent, SettingSwitchComponent, DefaultValuePipe] }) export class ManageSettingsComponent implements OnInit { @@ -81,7 +79,7 @@ export class ManageSettingsComponent implements OnInit { // Automatically save settings as we edit them this.settingsForm.valueChanges.pipe( distinctUntilChanged(), - debounceTime(100), + debounceTime(300), filter(_ => this.settingsForm.valid), takeUntilDestroyed(this.destroyRef), switchMap(_ => { diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.html b/UI/Web/src/app/chapter-detail/chapter-detail.component.html index b342849aa..0af5fcc82 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.html +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.html @@ -95,7 +95,7 @@
@@ -111,7 +111,7 @@ diff --git a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts index eea06f2fb..c71dadb01 100644 --- a/UI/Web/src/app/chapter-detail/chapter-detail.component.ts +++ b/UI/Web/src/app/chapter-detail/chapter-detail.component.ts @@ -364,4 +364,5 @@ export class ChapterDetailComponent implements OnInit { } protected readonly LibraryType = LibraryType; + protected readonly encodeURIComponent = encodeURIComponent; } diff --git a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts index 872ee2f6f..f04121444 100644 --- a/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/_components/manga-reader/manga-reader.component.ts @@ -788,7 +788,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (this.mangaReaderService.shouldBeWebtoonMode()) { this.readerMode = ReaderMode.Webtoon; - this.toastr.info(translate('manga-reader.webtoon-override')); + this.toastr.info(translate('toasts.webtoon-override')); this.readerModeSubject.next(this.readerMode); this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/person-detail/person-detail.component.html b/UI/Web/src/app/person-detail/person-detail.component.html index 6a992e613..3d9eae235 100644 --- a/UI/Web/src/app/person-detail/person-detail.component.html +++ b/UI/Web/src/app/person-detail/person-detail.component.html @@ -26,6 +26,7 @@
} @else { - + }
diff --git a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html index 80e0d5662..37c833e99 100644 --- a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html +++ b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html @@ -31,7 +31,7 @@ - + diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.html b/UI/Web/src/app/volume-detail/volume-detail.component.html index 75f196e1e..073e1fff1 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.html +++ b/UI/Web/src/app/volume-detail/volume-detail.component.html @@ -99,7 +99,7 @@ @@ -109,7 +109,7 @@ diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 8baecad29..0c99b547c 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -666,4 +666,6 @@ export class VolumeDetailComponent implements OnInit { this.currentlyReadingChapter = undefined; } } + + protected readonly encodeURIComponent = encodeURIComponent; } diff --git a/openapi.json b/openapi.json index 49c14e678..ae43e72ac 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "Kavita", - "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.14", + "description": "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage. Assume all fields of a payload are required. Built against v0.8.4.15", "license": { "name": "GPL-3.0", "url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"