mirror of
				https://github.com/Kareadita/Kavita.git
				synced 2025-11-04 03:27:05 -05:00 
			
		
		
		
	* Removed console.logs * Removed console.log() from app * Fixed a bug where when a user adds their own favicon to their folder, the check could fail as the www. wasn't being stripped. * Don't fail series metadata update if we can't cleanup due to another update taking place (common with komf)
		
			
				
	
	
		
			482 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			482 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System;
 | 
						|
using System.Collections.Generic;
 | 
						|
using System.IO;
 | 
						|
using System.Linq;
 | 
						|
using System.Threading.Tasks;
 | 
						|
using API.Constants;
 | 
						|
using API.Entities.Enums;
 | 
						|
using API.Extensions;
 | 
						|
using EasyCaching.Core;
 | 
						|
using Flurl;
 | 
						|
using Flurl.Http;
 | 
						|
using HtmlAgilityPack;
 | 
						|
using Kavita.Common;
 | 
						|
using Microsoft.Extensions.Logging;
 | 
						|
using NetVips;
 | 
						|
using Image = NetVips.Image;
 | 
						|
 | 
						|
namespace API.Services;
 | 
						|
#nullable enable
 | 
						|
 | 
						|
public interface IImageService
 | 
						|
{
 | 
						|
    void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1);
 | 
						|
    string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size);
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Creates a Thumbnail version of a base64 image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="encodedImage">base64 encoded image</param>
 | 
						|
    /// <param name="fileName"></param>
 | 
						|
    /// <param name="encodeFormat">Convert and save as encoding format</param>
 | 
						|
    /// <param name="thumbnailWidth">Width of thumbnail</param>
 | 
						|
    /// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
 | 
						|
    string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320);
 | 
						|
    /// <summary>
 | 
						|
    /// Writes out a thumbnail by stream input
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="stream"></param>
 | 
						|
    /// <param name="fileName"></param>
 | 
						|
    /// <param name="outputDirectory"></param>
 | 
						|
    /// <param name="encodeFormat"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
 | 
						|
    /// <summary>
 | 
						|
    /// Writes out a thumbnail by file path input
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="sourceFile"></param>
 | 
						|
    /// <param name="fileName"></param>
 | 
						|
    /// <param name="outputDirectory"></param>
 | 
						|
    /// <param name="encodeFormat"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
 | 
						|
    /// <summary>
 | 
						|
    /// Converts the passed image to encoding and outputs it in the same directory
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="filePath">Full path to the image to convert</param>
 | 
						|
    /// <param name="outputPath">Where to output the file</param>
 | 
						|
    /// <param name="encodeFormat">Encoding Format</param>
 | 
						|
    /// <returns>File of written encoded image</returns>
 | 
						|
    Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat);
 | 
						|
    Task<bool> IsImage(string filePath);
 | 
						|
    Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat);
 | 
						|
}
 | 
						|
 | 
						|
public class ImageService : IImageService
 | 
						|
{
 | 
						|
    public const string Name = "BookmarkService";
 | 
						|
    private readonly ILogger<ImageService> _logger;
 | 
						|
    private readonly IDirectoryService _directoryService;
 | 
						|
    private readonly IEasyCachingProviderFactory _cacheFactory;
 | 
						|
    public const string ChapterCoverImageRegex = @"v\d+_c\d+";
 | 
						|
    public const string SeriesCoverImageRegex = @"series\d+";
 | 
						|
    public const string CollectionTagCoverImageRegex = @"tag\d+";
 | 
						|
    public const string ReadingListCoverImageRegex = @"readinglist\d+";
 | 
						|
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Width of the Thumbnail generation
 | 
						|
    /// </summary>
 | 
						|
    private const int ThumbnailWidth = 320;
 | 
						|
    /// <summary>
 | 
						|
    /// Height of the Thumbnail generation
 | 
						|
    /// </summary>
 | 
						|
    private const int ThumbnailHeight = 455;
 | 
						|
    /// <summary>
 | 
						|
    /// Width of a cover for Library
 | 
						|
    /// </summary>
 | 
						|
    public const int LibraryThumbnailWidth = 32;
 | 
						|
 | 
						|
 | 
						|
    private static readonly string[] ValidIconRelations = {
 | 
						|
        "icon",
 | 
						|
        "apple-touch-icon",
 | 
						|
        "apple-touch-icon-precomposed",
 | 
						|
        "apple-touch-icon icon-precomposed" // ComicVine has it combined
 | 
						|
    };
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// A mapping of urls that need to get the icon from another url, due to strangeness (like app.plex.tv loading a black icon)
 | 
						|
    /// </summary>
 | 
						|
    private static readonly IDictionary<string, string> FaviconUrlMapper = new Dictionary<string, string>
 | 
						|
    {
 | 
						|
        ["https://app.plex.tv"] = "https://plex.tv"
 | 
						|
    };
 | 
						|
 | 
						|
    public ImageService(ILogger<ImageService> logger, IDirectoryService directoryService, IEasyCachingProviderFactory cacheFactory)
 | 
						|
    {
 | 
						|
        _logger = logger;
 | 
						|
        _directoryService = directoryService;
 | 
						|
        _cacheFactory = cacheFactory;
 | 
						|
    }
 | 
						|
 | 
						|
    public void ExtractImages(string? fileFilePath, string targetDirectory, int fileCount = 1)
 | 
						|
    {
 | 
						|
        if (string.IsNullOrEmpty(fileFilePath)) return;
 | 
						|
        _directoryService.ExistOrCreate(targetDirectory);
 | 
						|
        if (fileCount == 1)
 | 
						|
        {
 | 
						|
            _directoryService.CopyFileToDirectory(fileFilePath, targetDirectory);
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            _directoryService.CopyDirectoryToDirectory(_directoryService.FileSystem.Path.GetDirectoryName(fileFilePath), targetDirectory,
 | 
						|
                Tasks.Scanner.Parser.Parser.ImageFileExtensions);
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public string GetCoverImage(string path, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
 | 
						|
    {
 | 
						|
        if (string.IsNullOrEmpty(path)) return string.Empty;
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var dims = size.GetDimensions();
 | 
						|
            using var thumbnail = Image.Thumbnail(path, dims.Width, height: dims.Height, size: Enums.Size.Force);
 | 
						|
            var filename = fileName + encodeFormat.GetExtension();
 | 
						|
            thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
 | 
						|
            return filename;
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogWarning(ex, "[GetCoverImage] There was an error and prevented thumbnail generation on {ImageFile}. Defaulting to no cover image", path);
 | 
						|
        }
 | 
						|
 | 
						|
        return string.Empty;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Creates a thumbnail out of a memory stream and saves to <see cref="DirectoryService.CoverImageDirectory"/> with the passed
 | 
						|
    /// fileName and .png extension.
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="stream">Stream to write to disk. Ensure this is rewinded.</param>
 | 
						|
    /// <param name="fileName">filename to save as without extension</param>
 | 
						|
    /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
 | 
						|
    /// <param name="encodeFormat">Export the file as the passed encoding</param>
 | 
						|
    /// <returns>File name with extension of the file. This will always write to <see cref="DirectoryService.CoverImageDirectory"/></returns>
 | 
						|
    public string WriteCoverThumbnail(Stream stream, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
 | 
						|
    {
 | 
						|
        var dims = size.GetDimensions();
 | 
						|
        using var thumbnail = Image.ThumbnailStream(stream, dims.Width, height: dims.Height, size: Enums.Size.Force);
 | 
						|
        var filename = fileName + encodeFormat.GetExtension();
 | 
						|
        _directoryService.ExistOrCreate(outputDirectory);
 | 
						|
        try
 | 
						|
        {
 | 
						|
            _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
 | 
						|
        } catch (Exception) {/* Swallow exception */}
 | 
						|
        thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
 | 
						|
        return filename;
 | 
						|
    }
 | 
						|
 | 
						|
    public string WriteCoverThumbnail(string sourceFile, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
 | 
						|
    {
 | 
						|
        var dims = size.GetDimensions();
 | 
						|
        using var thumbnail = Image.Thumbnail(sourceFile, dims.Width, height: dims.Height, size: Enums.Size.Force);
 | 
						|
        var filename = fileName + encodeFormat.GetExtension();
 | 
						|
        _directoryService.ExistOrCreate(outputDirectory);
 | 
						|
        try
 | 
						|
        {
 | 
						|
            _directoryService.FileSystem.File.Delete(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
 | 
						|
        } catch (Exception) {/* Swallow exception */}
 | 
						|
        thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, filename));
 | 
						|
        return filename;
 | 
						|
    }
 | 
						|
 | 
						|
    public Task<string> ConvertToEncodingFormat(string filePath, string outputPath, EncodeFormat encodeFormat)
 | 
						|
    {
 | 
						|
        var file = _directoryService.FileSystem.FileInfo.New(filePath);
 | 
						|
        var fileName = file.Name.Replace(file.Extension, string.Empty);
 | 
						|
        var outputFile = Path.Join(outputPath, fileName + encodeFormat.GetExtension());
 | 
						|
 | 
						|
        using var sourceImage = Image.NewFromFile(filePath, false, Enums.Access.SequentialUnbuffered);
 | 
						|
        sourceImage.WriteToFile(outputFile);
 | 
						|
        return Task.FromResult(outputFile);
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Performs I/O to determine if the file is a valid Image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="filePath"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public async Task<bool> IsImage(string filePath)
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var info = await SixLabors.ImageSharp.Image.IdentifyAsync(filePath);
 | 
						|
            if (info == null) return false;
 | 
						|
 | 
						|
            return true;
 | 
						|
        }
 | 
						|
        catch (Exception)
 | 
						|
        {
 | 
						|
            /* Swallow Exception */
 | 
						|
        }
 | 
						|
 | 
						|
        return false;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<string> DownloadFaviconAsync(string url, EncodeFormat encodeFormat)
 | 
						|
    {
 | 
						|
        // Parse the URL to get the domain (including subdomain)
 | 
						|
        var uri = new Uri(url);
 | 
						|
        var domain = uri.Host;
 | 
						|
        var baseUrl = uri.Scheme + "://" + uri.Host;
 | 
						|
 | 
						|
 | 
						|
        var provider = _cacheFactory.GetCachingProvider(EasyCacheProfiles.Favicon);
 | 
						|
        var res = await provider.GetAsync<string>(baseUrl);
 | 
						|
        if (res.HasValue)
 | 
						|
        {
 | 
						|
            _logger.LogInformation("Kavita has already tried to fetch from {BaseUrl} and failed. Skipping duplicate check", baseUrl);
 | 
						|
            throw new KavitaException($"Kavita has already tried to fetch from {baseUrl} and failed. Skipping duplicate check");
 | 
						|
        }
 | 
						|
 | 
						|
        await provider.SetAsync(baseUrl, string.Empty, TimeSpan.FromDays(10));
 | 
						|
        if (FaviconUrlMapper.TryGetValue(baseUrl, out var value))
 | 
						|
        {
 | 
						|
            url = value;
 | 
						|
        }
 | 
						|
 | 
						|
        var correctSizeLink = string.Empty;
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            var htmlContent = url.GetStringAsync().Result;
 | 
						|
            var htmlDocument = new HtmlDocument();
 | 
						|
            htmlDocument.LoadHtml(htmlContent);
 | 
						|
            var pngLinks = htmlDocument.DocumentNode.Descendants("link")
 | 
						|
                .Where(link => ValidIconRelations.Contains(link.GetAttributeValue("rel", string.Empty)))
 | 
						|
                .Select(link => link.GetAttributeValue("href", string.Empty))
 | 
						|
                .Where(href => href.Split("?")[0].EndsWith(".png", StringComparison.InvariantCultureIgnoreCase))
 | 
						|
                .ToList();
 | 
						|
 | 
						|
            correctSizeLink = (pngLinks?.Find(pngLink => pngLink.Contains("32")) ?? pngLinks?.FirstOrDefault());
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogError(ex, "Error downloading favicon.png for {Domain}, will try fallback methods", domain);
 | 
						|
        }
 | 
						|
 | 
						|
        try
 | 
						|
        {
 | 
						|
            if (string.IsNullOrEmpty(correctSizeLink))
 | 
						|
            {
 | 
						|
                correctSizeLink = FallbackToKavitaReaderFavicon(baseUrl);
 | 
						|
            }
 | 
						|
            if (string.IsNullOrEmpty(correctSizeLink))
 | 
						|
            {
 | 
						|
                throw new KavitaException($"Could not grab favicon from {baseUrl}");
 | 
						|
            }
 | 
						|
 | 
						|
            var finalUrl = correctSizeLink;
 | 
						|
 | 
						|
            // If starts with //, it's coming usually from an offsite cdn
 | 
						|
            if (correctSizeLink.StartsWith("//"))
 | 
						|
            {
 | 
						|
                finalUrl = "https:" + correctSizeLink;
 | 
						|
            }
 | 
						|
            else if (!correctSizeLink.StartsWith(uri.Scheme))
 | 
						|
            {
 | 
						|
                finalUrl = Url.Combine(baseUrl, correctSizeLink);
 | 
						|
            }
 | 
						|
 | 
						|
            _logger.LogTrace("Fetching favicon from {Url}", finalUrl);
 | 
						|
            // Download the favicon.ico file using Flurl
 | 
						|
            var faviconStream = await finalUrl
 | 
						|
                .AllowHttpStatus("2xx,304")
 | 
						|
                .GetStreamAsync();
 | 
						|
 | 
						|
            // Create the destination file path
 | 
						|
            using var image = Image.PngloadStream(faviconStream);
 | 
						|
            var filename = $"{domain}{encodeFormat.GetExtension()}";
 | 
						|
            switch (encodeFormat)
 | 
						|
            {
 | 
						|
                case EncodeFormat.PNG:
 | 
						|
                    image.Pngsave(Path.Combine(_directoryService.FaviconDirectory, filename));
 | 
						|
                    break;
 | 
						|
                case EncodeFormat.WEBP:
 | 
						|
                    image.Webpsave(Path.Combine(_directoryService.FaviconDirectory, filename));
 | 
						|
                    break;
 | 
						|
                case EncodeFormat.AVIF:
 | 
						|
                    image.Heifsave(Path.Combine(_directoryService.FaviconDirectory, filename));
 | 
						|
                    break;
 | 
						|
                default:
 | 
						|
                    throw new ArgumentOutOfRangeException(nameof(encodeFormat), encodeFormat, null);
 | 
						|
            }
 | 
						|
 | 
						|
 | 
						|
            _logger.LogDebug("Favicon.png for {Domain} downloaded and saved successfully", domain);
 | 
						|
            return filename;
 | 
						|
        }catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogError(ex, "Error downloading favicon.png for {Domain}", domain);
 | 
						|
            throw;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private static string FallbackToKavitaReaderFavicon(string baseUrl)
 | 
						|
    {
 | 
						|
        var correctSizeLink = string.Empty;
 | 
						|
        var allOverrides = "https://kavitareader.com/assets/favicons/urls.txt".GetStringAsync().Result;
 | 
						|
        if (!string.IsNullOrEmpty(allOverrides))
 | 
						|
        {
 | 
						|
            var cleanedBaseUrl = baseUrl.Replace("https://", string.Empty);
 | 
						|
            var externalFile = allOverrides
 | 
						|
                .Split("\n")
 | 
						|
                .FirstOrDefault(url =>
 | 
						|
                    cleanedBaseUrl.Equals(url.Replace(".png", string.Empty)) ||
 | 
						|
                    cleanedBaseUrl.Replace("www.", string.Empty).Equals(url.Replace(".png", string.Empty)
 | 
						|
                    ));
 | 
						|
            if (string.IsNullOrEmpty(externalFile))
 | 
						|
            {
 | 
						|
                throw new KavitaException($"Could not grab favicon from {baseUrl}");
 | 
						|
            }
 | 
						|
 | 
						|
            correctSizeLink = "https://kavitareader.com/assets/favicons/" + externalFile;
 | 
						|
        }
 | 
						|
 | 
						|
        return correctSizeLink;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <inheritdoc />
 | 
						|
    public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth)
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth);
 | 
						|
            fileName += encodeFormat.GetExtension();
 | 
						|
            thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName));
 | 
						|
            return fileName;
 | 
						|
        }
 | 
						|
        catch (Exception e)
 | 
						|
        {
 | 
						|
            _logger.LogError(e, "Error creating thumbnail from url");
 | 
						|
        }
 | 
						|
 | 
						|
        return string.Empty;
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a chapter cover image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="chapterId"></param>
 | 
						|
    /// <param name="volumeId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetChapterFormat(int chapterId, int volumeId)
 | 
						|
    {
 | 
						|
        return $"v{volumeId}_c{chapterId}";
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a library cover image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="libraryId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetLibraryFormat(int libraryId)
 | 
						|
    {
 | 
						|
        return $"l{libraryId}";
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a series cover image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="seriesId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetSeriesFormat(int seriesId)
 | 
						|
    {
 | 
						|
        return $"series{seriesId}";
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a collection tag cover image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="tagId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetCollectionTagFormat(int tagId)
 | 
						|
    {
 | 
						|
        return $"tag{tagId}";
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a reading list cover image
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="readingListId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetReadingListFormat(int readingListId)
 | 
						|
    {
 | 
						|
        // ReSharper disable once StringLiteralTypo
 | 
						|
        return $"readinglist{readingListId}";
 | 
						|
    }
 | 
						|
 | 
						|
    /// <summary>
 | 
						|
    /// Returns the name format for a thumbnail (temp thumbnail)
 | 
						|
    /// </summary>
 | 
						|
    /// <param name="chapterId"></param>
 | 
						|
    /// <returns></returns>
 | 
						|
    public static string GetThumbnailFormat(int chapterId)
 | 
						|
    {
 | 
						|
        return $"thumbnail{chapterId}";
 | 
						|
    }
 | 
						|
 | 
						|
    public static string GetWebLinkFormat(string url, EncodeFormat encodeFormat)
 | 
						|
    {
 | 
						|
        return $"{new Uri(url).Host.Replace("www.", string.Empty)}{encodeFormat.GetExtension()}";
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    public static void CreateMergedImage(IList<string> coverImages, CoverImageSize size, string dest)
 | 
						|
    {
 | 
						|
        var dims = size.GetDimensions();
 | 
						|
        int rows, cols;
 | 
						|
 | 
						|
        if (coverImages.Count == 1)
 | 
						|
        {
 | 
						|
            rows = 1;
 | 
						|
            cols = 1;
 | 
						|
        }
 | 
						|
        else if (coverImages.Count == 2)
 | 
						|
        {
 | 
						|
            rows = 1;
 | 
						|
            cols = 2;
 | 
						|
        }
 | 
						|
        else if (coverImages.Count == 3)
 | 
						|
        {
 | 
						|
            rows = 2;
 | 
						|
            cols = 2;
 | 
						|
        }
 | 
						|
        else
 | 
						|
        {
 | 
						|
            // Default to 2x2 layout for more than 3 images
 | 
						|
            rows = 2;
 | 
						|
            cols = 2;
 | 
						|
        }
 | 
						|
 | 
						|
        var image = Image.Black(dims.Width, dims.Height);
 | 
						|
 | 
						|
        var thumbnailWidth = image.Width / cols;
 | 
						|
        var thumbnailHeight = image.Height / rows;
 | 
						|
 | 
						|
        for (var i = 0; i < coverImages.Count; i++)
 | 
						|
        {
 | 
						|
            var tile = Image.NewFromFile(coverImages[i], access: Enums.Access.Sequential);
 | 
						|
            tile = tile.ThumbnailImage(thumbnailWidth, height: thumbnailHeight);
 | 
						|
 | 
						|
            var row = i / cols;
 | 
						|
            var col = i % cols;
 | 
						|
 | 
						|
            var x = col * thumbnailWidth;
 | 
						|
            var y = row * thumbnailHeight;
 | 
						|
 | 
						|
            if (coverImages.Count == 3 && i == 2)
 | 
						|
            {
 | 
						|
                x = (image.Width - thumbnailWidth) / 2;
 | 
						|
                y = thumbnailHeight;
 | 
						|
            }
 | 
						|
 | 
						|
            image = image.Insert(tile, x, y);
 | 
						|
        }
 | 
						|
 | 
						|
        image.WriteToFile(dest);
 | 
						|
    }
 | 
						|
}
 |