diff --git a/MainWindow.xaml b/MainWindow.xaml index 6008b20..9843d0f 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -1,6 +1,7 @@ + + + M19 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H19C20.1 22 21 21.1 21 20V4C21 2.9 20.1 2 19 2ZM6 4H11V12L8.5 10.5L6 12V4Z M19.14 12.94C19.18 12.64 19.2 12.33 19.2 12C19.2 11.68 19.18 11.36 19.13 11.06L21.16 9.48C21.34 9.34 21.39 9.07 21.28 8.87L19.36 5.55C19.24 5.33 18.99 5.26 18.77 5.33L16.38 6.29C15.88 5.91 15.35 5.59 14.76 5.35L14.4 2.81C14.36 2.57 14.16 2.4 13.92 2.4H10.08C9.84 2.4 9.65 2.57 9.61 2.81L9.25 5.35C8.66 5.59 8.12 5.92 7.63 6.29L5.24 5.33C5.02 5.25 4.77 5.33 4.65 5.55L2.74 8.87C2.62 9.08 2.66 9.34 2.86 9.48L4.89 11.06C4.84 11.36 4.8 11.69 4.8 12C4.8 12.31 4.82 12.64 4.87 12.94L2.85 14.52C2.67 14.66 2.62 14.93 2.73 15.13L4.65 18.45C4.77 18.67 5.02 18.74 5.24 18.67L7.63 17.71C8.13 18.09 8.66 18.41 9.25 18.65L9.61 21.19C9.65 21.43 9.84 21.6 10.08 21.6H13.92C14.16 21.6 14.36 21.43 14.39 21.19L14.75 18.65C15.34 18.41 15.88 18.09 16.37 17.71L18.76 18.67C18.98 18.75 19.23 18.67 19.35 18.45L21.27 15.13C21.39 14.91 21.34 14.66 21.15 14.52L19.14 12.94ZM12 15.6C10.02 15.6 8.4 13.98 8.4 12C8.4 10.02 10.02 8.4 12 8.4C13.98 8.4 15.6 10.02 15.6 12C15.6 13.98 13.98 15.6 12 15.6Z @@ -91,7 +95,7 @@ Foreground="{StaticResource TextBrush}" HorizontalAlignment="Center" Margin="0,0,0,8" /> - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -290,21 +323,39 @@ + - + - + + + + + + + + + - - _conversionService.ExtractEpubCover(filePath)) + .ContinueWith(t => + { + if (t.Result != null) + { + Dispatcher.Invoke(() => + { + CoverImage.Source = t.Result; + CoverBorder.Visibility = Visibility.Visible; + }); + } + }, TaskContinuationOptions.OnlyOnRanToCompletion); + // Store for Kindle sending (the EPUB itself is the output) _lastOutputPath = filePath; _lastTitle = TitleTextBox.Text; @@ -273,6 +291,20 @@ private async void LoadFile(string filePath) { SetStatus($"Loaded: {fileName}"); } + + // Extract cover (run on background thread) + _ = Task.Run(() => _conversionService.ExtractMobiCover(filePath)) + .ContinueWith(t => + { + if (t.Result != null) + { + Dispatcher.Invoke(() => + { + CoverImage.Source = t.Result; + CoverBorder.Visibility = Visibility.Visible; + }); + } + }, TaskContinuationOptions.OnlyOnRanToCompletion); } else { @@ -325,7 +357,7 @@ private async void LoadMultipleFiles(string[] filePaths) } } - // Extract metadata for each file + // Extract metadata and covers for each file foreach (var book in _books) { try @@ -337,6 +369,15 @@ private async void LoadMultipleFiles(string[] filePaths) book.Title = metadata.Title; if (!string.IsNullOrEmpty(metadata.Author)) book.Author = metadata.Author; + + // Extract cover + var filePath = book.FilePath; + _ = Task.Run(() => _conversionService.ExtractEpubCover(filePath!)) + .ContinueWith(t => + { + if (t.Result != null) + Dispatcher.Invoke(() => book.CoverImage = t.Result); + }, TaskContinuationOptions.OnlyOnRanToCompletion); } else { @@ -345,6 +386,15 @@ private async void LoadMultipleFiles(string[] filePaths) book.Title = metadata.Title; if (!string.IsNullOrEmpty(metadata.Author)) book.Author = metadata.Author; + + // Extract cover + var filePath = book.FilePath; + _ = Task.Run(() => _conversionService.ExtractMobiCover(filePath!)) + .ContinueWith(t => + { + if (t.Result != null) + Dispatcher.Invoke(() => book.CoverImage = t.Result); + }, TaskContinuationOptions.OnlyOnRanToCompletion); } } catch diff --git a/Models/BookMetadata.cs b/Models/BookMetadata.cs index ff5fc2c..781189f 100644 --- a/Models/BookMetadata.cs +++ b/Models/BookMetadata.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Windows.Media.Imaging; namespace Booky.Models; @@ -10,6 +11,7 @@ public class BookMetadata : INotifyPropertyChanged private string? _fileName; private string? _status; private bool _isEpub; + private BitmapImage? _coverImage; public string? Title { @@ -47,6 +49,12 @@ public bool IsEpub set { _isEpub = value; OnPropertyChanged(nameof(IsEpub)); } } + public BitmapImage? CoverImage + { + get => _coverImage; + set { _coverImage = value; OnPropertyChanged(nameof(CoverImage)); } + } + public event PropertyChangedEventHandler? PropertyChanged; protected void OnPropertyChanged(string propertyName) diff --git a/NullToCollapsedConverter.cs b/NullToCollapsedConverter.cs new file mode 100644 index 0000000..b13d7ba --- /dev/null +++ b/NullToCollapsedConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace Booky; + +public class NullToCollapsedConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value == null ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } +} diff --git a/Services/ConversionService.cs b/Services/ConversionService.cs index 3ed8640..506799f 100644 --- a/Services/ConversionService.cs +++ b/Services/ConversionService.cs @@ -2,6 +2,7 @@ using System.IO; using System.IO.Compression; using System.Text; +using System.Windows.Media.Imaging; using Booky.Models; using HtmlAgilityPack; @@ -53,6 +54,274 @@ public async Task ExtractEpubMetadataAsync(string inputPath) return metadata; } + /// + /// Extract cover image from an EPUB file + /// + public BitmapImage? ExtractEpubCover(string inputPath) + { + try + { + using var archive = ZipFile.OpenRead(inputPath); + + // Find the OPF file to locate the cover + var opfEntry = archive.Entries.FirstOrDefault(e => + e.FullName.EndsWith(".opf", StringComparison.OrdinalIgnoreCase)); + + if (opfEntry == null) + return null; + + string? coverPath = null; + var opfDir = Path.GetDirectoryName(opfEntry.FullName)?.Replace('\\', '/') ?? ""; + + using (var stream = opfEntry.Open()) + using (var reader = new StreamReader(stream)) + { + var opfContent = reader.ReadToEnd(); + + // Try multiple patterns to find the cover + + // Pattern 1: properties="cover-image" (EPUB3 standard) + var coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*properties\s*=\s*[""'][^""']*cover-image[^""']*[""'][^>]*href\s*=\s*[""']([^""']+)[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + + if (!coverMatch.Success) + { + // Pattern 1b: href before properties + coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*href\s*=\s*[""']([^""']+)[""'][^>]*properties\s*=\s*[""'][^""']*cover-image[^""']*[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + } + + if (!coverMatch.Success) + { + // Pattern 2: id="cover" or id="cover-image" + coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*id\s*=\s*[""']cover[^""']*[""'][^>]*href\s*=\s*[""']([^""']+)[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + } + + if (!coverMatch.Success) + { + // Pattern 2b: href before id + coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*href\s*=\s*[""']([^""']+)[""'][^>]*id\s*=\s*[""']cover[^""']*[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + } + + if (!coverMatch.Success) + { + // Pattern 3: meta name="cover" content="image-id", then find that image + var metaCoverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*name\s*=\s*[""']cover[""'][^>]*content\s*=\s*[""']([^""']+)[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + + if (!metaCoverMatch.Success) + { + // Try reversed attribute order + metaCoverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, @"]*content\s*=\s*[""']([^""']+)[""'][^>]*name\s*=\s*[""']cover[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + } + + if (metaCoverMatch.Success) + { + var coverId = metaCoverMatch.Groups[1].Value; + // Find item with this id + coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, $@"]*id\s*=\s*[""']{System.Text.RegularExpressions.Regex.Escape(coverId)}[""'][^>]*href\s*=\s*[""']([^""']+)[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + + if (!coverMatch.Success) + { + coverMatch = System.Text.RegularExpressions.Regex.Match( + opfContent, $@"]*href\s*=\s*[""']([^""']+)[""'][^>]*id\s*=\s*[""']{System.Text.RegularExpressions.Regex.Escape(coverId)}[""']", + System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline); + } + } + } + + if (coverMatch.Success) + { + coverPath = coverMatch.Groups[1].Value; + } + } + + if (string.IsNullOrEmpty(coverPath)) + { + // Fallback: look for common cover image names anywhere in the archive + var coverEntry = archive.Entries.FirstOrDefault(e => + { + var name = e.Name.ToLowerInvariant(); + return name.StartsWith("cover") && (name.EndsWith(".jpg") || name.EndsWith(".jpeg") || name.EndsWith(".png")); + }); + + if (coverEntry != null) + { + return LoadBitmapFromEntry(coverEntry); + } + + // Last resort: find any image in Images/covers folder + coverEntry = archive.Entries.FirstOrDefault(e => + { + var path = e.FullName.ToLowerInvariant(); + var ext = Path.GetExtension(path); + return (path.Contains("cover") || path.Contains("images")) && + (ext == ".jpg" || ext == ".jpeg" || ext == ".png"); + }); + + if (coverEntry != null) + { + return LoadBitmapFromEntry(coverEntry); + } + + return null; + } + + // Resolve relative path from OPF location + var fullCoverPath = string.IsNullOrEmpty(opfDir) + ? coverPath + : $"{opfDir}/{coverPath}"; + + // Normalize path + fullCoverPath = fullCoverPath.Replace('\\', '/').TrimStart('/'); + + var foundEntry = archive.Entries.FirstOrDefault(e => + e.FullName.Replace('\\', '/').Equals(fullCoverPath, StringComparison.OrdinalIgnoreCase)); + + // If not found, try without the OPF directory prefix + if (foundEntry == null) + { + foundEntry = archive.Entries.FirstOrDefault(e => + e.FullName.Replace('\\', '/').EndsWith(coverPath, StringComparison.OrdinalIgnoreCase)); + } + + if (foundEntry == null) + return null; + + return LoadBitmapFromEntry(foundEntry); + } + catch + { + return null; + } + } + + private static BitmapImage? LoadBitmapFromEntry(ZipArchiveEntry entry) + { + try + { + using var coverStream = entry.Open(); + using var memoryStream = new MemoryStream(); + coverStream.CopyTo(memoryStream); + memoryStream.Position = 0; + + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.StreamSource = memoryStream; + bitmap.EndInit(); + bitmap.Freeze(); + + return bitmap; + } + catch + { + return null; + } + } + + /// + /// Extract cover image from a MOBI file using mobitool + /// + public BitmapImage? ExtractMobiCover(string inputPath) + { + var toolPath = FindMobiTool(); + if (toolPath == null) + return null; + + // Create temp directory for extraction + var tempDir = Path.Combine(Path.GetTempPath(), $"booky_cover_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + try + { + var tempMobiPath = Path.Combine(tempDir, "input.mobi"); + File.Copy(inputPath, tempMobiPath); + + // Run mobitool to dump source files (includes cover) + var psi = new ProcessStartInfo + { + FileName = toolPath, + Arguments = $"-s \"{tempMobiPath}\"", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using var process = Process.Start(psi); + if (process == null) + return null; + + // Wait with timeout and kill if hung + if (!process.WaitForExit(5000)) + { + try { process.Kill(); } catch { } + return null; + } + + var markupDir = Path.Combine(tempDir, "input_markup"); + if (!Directory.Exists(markupDir)) + return null; + + // Look for cover image - mobitool typically extracts it as cover.jpg or similar + var imageExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif" }; + var coverFile = Directory.GetFiles(markupDir) + .FirstOrDefault(f => + { + var name = Path.GetFileNameWithoutExtension(f).ToLowerInvariant(); + var ext = Path.GetExtension(f).ToLowerInvariant(); + return imageExtensions.Contains(ext) && + (name.Contains("cover") || name == "image00000" || name == "image00001"); + }); + + // If no cover found, try the first image + if (coverFile == null) + { + coverFile = Directory.GetFiles(markupDir) + .FirstOrDefault(f => imageExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())); + } + + if (coverFile == null) + return null; + + // Load bitmap into memory before temp dir is deleted + using var coverStream = File.OpenRead(coverFile); + using var memoryStream = new MemoryStream(); + coverStream.CopyTo(memoryStream); + memoryStream.Position = 0; + + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.StreamSource = memoryStream; + bitmap.EndInit(); + bitmap.Freeze(); + + return bitmap; + } + catch + { + return null; + } + finally + { + // Cleanup + try { Directory.Delete(tempDir, true); } catch { } + } + } + /// /// Extract metadata (title, author) from a MOBI file using mobitool /// @@ -123,7 +392,7 @@ public async Task ConvertAsync(ConversionOptions options, PluginService? pluginS // Try format plugins first if (pluginService != null) { - var pluginResult = await pluginService.ConvertWithPluginAsync(inputPath, options.OutputPath, options.Title, options.Author); + var pluginResult = await pluginService.ConvertWithPluginAsync(inputPath, options.OutputPath, options.Title, options.Author ?? ""); if (pluginResult != null) { if (!pluginResult.Success)