#nullable enable using System; using System.Collections.ObjectModel; using System.Globalization; using System.IO; using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Avalonia.Platform.Storage; using Avalonia.Themes.Fluent; using DialogHostAvalonia; using ImageMagick; using MigraDoc.DocumentObjectModel; using MigraDoc.Rendering; using PdfSharp.Fonts; using PdfSharp.Pdf.IO; using PdfSharp.Snippets.Font; using MayShow.Helpers; using MayShow.Interfaces; using MayShow.Models; using Docnet.Core.Models; using Docnet.Core; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; using System.Reflection.Metadata.Ecma335; using Docnet.Core.Readers; using MigraDoc.DocumentObjectModel.Visitors; using System.Collections.Generic; namespace MayShow.ViewModels; class MainViewModel : BaseViewModel, IFontResolver, ICanCheckShutdown { private bool _isPerformingInitialLoad; private string _processDir; private bool _isCreatingPDF; private string _programLog; private string _workingFolder; private string _reportTitle; private ObservableCollection _reportFiles; private DateTime? _lastGeneratedTime; private Settings _settings; private List _dateDisplayFormats; private bool _hasUnsavedWork; public MainViewModel(IChangeViewModel viewModelChanger) : base(viewModelChanger) { _isPerformingInitialLoad = true; _processDir = Path.GetDirectoryName(Environment.ProcessPath) ?? ""; Console.WriteLine("Internal storage directory is: {0}", Utilities.GetInternalDataPath()); _isCreatingPDF = false; var quotes = Constants.GetQuotes(); Random random = new Random(); var quoteIndex = random.Next(0, quotes.Length); _programLog = "----- MayShow v" + Constants.AppVersion + " ------" + Environment.NewLine; _programLog += quotes[quoteIndex] + Environment.NewLine; _programLog += "---------------------------------------" + Environment.NewLine; _programLog += "Loaded and ready to create report!" + Environment.NewLine; _programLog += "Please copy and send this Program Log when reporting any issues with the software."; _workingFolder = ""; ReportFiles = _reportFiles = new ObservableCollection(); _reportTitle = ""; _lastGeneratedTime = null; _settings = Settings.LoadSettings(); _dateDisplayFormats = Constants.GetDateDisplayFormats(); NotifyPropertyChanged(nameof(DataGridDateFormat)); NotifyPropertyChanged(nameof(DataGridDateFormatWatermark)); if (!string.IsNullOrWhiteSpace(_settings.LastUsedPath)) { LogInfo("Loading data at last used path of {0}", _settings.LastUsedPath); ScanFolder(_settings.LastUsedPath); } else { LogInfo("Choose a receipt folder to begin..."); } HasUnsavedWork = false; _isPerformingInitialLoad = false; } public string ReportTitle { get => _reportTitle; set { _reportTitle = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(IsTitleBoxVisible)); NotifyPropertyChanged(nameof(CanAddItem)); } } public bool IsTitleBoxVisible { get => !string.IsNullOrWhiteSpace(WorkingFolder); } public bool CanAddItem { get => IsTitleBoxVisible && !IsCreatingPDF; } public bool IsCreatingPDF { get => _isCreatingPDF; set { _isCreatingPDF = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); NotifyPropertyChanged(nameof(CanAddItem)); } } public bool IsCreatePDFButtonEnabled { get => !_isCreatingPDF && _reportFiles.Count > 0; } public bool HasWorkingFolder { get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder); } public bool HasWorkingFolderAndNotMakingPDF { get => !string.IsNullOrWhiteSpace(WorkingFolder) && Directory.Exists(WorkingFolder) && !_isCreatingPDF; } public string WorkingFolder { get => _workingFolder; set { _workingFolder = value; NotifyPropertyChanged(); NotifyPropertyChanged(nameof(HasWorkingFolder)); NotifyPropertyChanged(nameof(HasWorkingFolderAndNotMakingPDF)); } } public string ProgramLog { get => _programLog; set { _programLog = value; NotifyPropertyChanged(); } } public bool HasUnsavedWork { get => _hasUnsavedWork; set { _hasUnsavedWork = value; NotifyPropertyChanged(); } } public ObservableCollection ReportFiles { get => _reportFiles; set { _reportFiles = value; NotifyPropertyChanged(); _reportFiles.CollectionChanged += ( sender, e ) => { NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); HasUnsavedWork = true; }; } } public string DataGridDateFormat { get => _settings.DataGridDateFormat; } public string DataGridDateFormatWatermark { get => _dateDisplayFormats.Where(x => x.Value == _settings.DataGridDateFormat).FirstOrDefault()?.Example ?? "2025-12-04"; } private void LogInfo(string message, params object[]? arguments) { var timestamp = string.Format("[{0:s}]", DateTime.Now); Console.WriteLine(timestamp + " " + message, arguments); ProgramLog += Environment.NewLine + string.Format(message, arguments ?? []); } public async void ChooseFolder() { var topLevel = TopLevelGrabber?.GetTopLevel(); if (topLevel is not null) { var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions() { Title = "Pick a folder of files...", AllowMultiple = false, }); if (folders.Count == 1) { var folder = folders[0]; LogInfo("Clearing existing list and loading items in folder: " + folder.Path.LocalPath); ReportFiles.Clear(); ScanFolder(folder.Path.LocalPath); _settings.LastUsedPath = folder.Path.LocalPath; await _settings.SaveSettingsAsync(); ResortPDFItemsByDate(); HasUnsavedWork = true; } } } private string GetReportSavedDataPath(string folderPath) { if (_settings.SaveReportJsonDataInInternalDir) { var internalPath = Utilities.GetInternalDataPath(); if (!_settings.WorkingFolderToInternalFolderName.ContainsKey(folderPath)) { var uuid = ""; var potentialPath = ""; var isDone = false; // make sure uuid not already used...just in case...because paranoia... do { uuid = Guid.NewGuid().ToString(); potentialPath = Path.Combine(internalPath, uuid); isDone = !Directory.Exists(potentialPath); } while (!isDone); // make internal dir -- using dir so we have option to copy data into dir later if needed // (if we ever implement a more robust report system where we keep all files) Directory.CreateDirectory(potentialPath); _settings.WorkingFolderToInternalFolderName[folderPath] = uuid; _settings.SaveSettingsNotAsync(); // save new key/value pair } return Path.Combine( internalPath, _settings.WorkingFolderToInternalFolderName[folderPath], Constants.ReportSavedDataFileName ); } else { return Path.Combine(folderPath, Constants.ReportSavedDataFileName); } } private void ScanFolder(string path) { if (Directory.Exists(path)) { WorkingFolder = path; NotifyPropertyChanged(nameof(IsTitleBoxVisible)); NotifyPropertyChanged(nameof(CanAddItem)); var reportFilePath = GetReportSavedDataPath(path); var successfullyLoadedPriorReport = false; if (File.Exists(reportFilePath)) { // load prior report var json = File.ReadAllText(reportFilePath); var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); var report = JsonSerializer.Deserialize(json, jsonContext.PDFReport); if (report != null && report.Files.Count > 0) { Console.WriteLine("Loading prior report data at {0}", reportFilePath); ReportFiles = new ObservableCollection(report.Files); ReportTitle = report.Title; WorkingFolder = report.BaseFolder; _lastGeneratedTime = report.LastGenerated ?? null; LogInfo("Reloaded report last saved at {0}", report.LastSaved); successfullyLoadedPriorReport = true; } } if (!successfullyLoadedPriorReport) { // Scan folder for files and display in DataGrid ReportFiles.Clear(); ReportTitle = ""; var filePaths = Directory.GetFiles(WorkingFolder); foreach (var filePath in filePaths) { AddFileBasedOnPath(filePath); } ResortPDFItemsByDate(); HasUnsavedWork = true; } } else { LogInfo("Error: The directory {0} does not exist. Please select another folder.", path); } NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); } public void ShowAbout() { DialogHost.Show(new AboutViewModel()); } public async Task ShowSettings() { var updatedSettings = await DialogHost.Show(new SettingsViewModel(_settings, TopLevelGrabber)); if (updatedSettings != null) { _settings = (Settings)updatedSettings; await _settings.SaveSettingsAsync(); LogInfo("Saved updated settings!"); NotifyPropertyChanged(nameof(DataGridDateFormat)); NotifyPropertyChanged(nameof(DataGridDateFormatWatermark)); } } public void RemoveFile(object f) => RemoveFileImpl((ReportFile)f); public async void RemoveFileImpl(ReportFile file) { var result = await DialogHost.Show(new WarningDeleteItemViewModel(file)); if (result != null && (bool)result) { var idx = ReportFiles.IndexOf(file); if (idx != -1) { ReportFiles.RemoveAt(idx); HasUnsavedWork = true; } } } // https://github.com/AvaloniaUI/Avalonia/issues/10075 public void EditFileProperties(object f) => EditFilePropertiesImpl((ReportFile)f); public async void EditFilePropertiesImpl(ReportFile file) { var result = await DialogHost.Show(new EditFileViewModel(file, ViewModelChanger)); if (result != null && result is ReportFile updatedData) { file.Title = updatedData.Title; file.ReceiptDateTime = updatedData.ReceiptDateTime; file.Notes = updatedData.Notes; HasUnsavedWork = true; } } public async void AddItem() { var topLevel = TopLevelGrabber?.GetTopLevel(); if (topLevel is not null) { var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() { Title = "Choose image or PDF files...", AllowMultiple = true, FileTypeFilter = [ new FilePickerFileType("All Types") { Patterns = Constants.AllowedFileExtensionPatterns, AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], MimeTypes = [ "image/*", "application/pdf", "image/heic" ] }, FilePickerFileTypes.ImageAll, new FilePickerFileType("HEIC Images") { Patterns = [ "*.heic" ], AppleUniformTypeIdentifiers = [ "public.heic" ], MimeTypes = [ "image/heic" ] }, FilePickerFileTypes.Pdf, ], }); if (files.Count > 0) { foreach (var file in files) { var filePath = file.TryGetLocalPath(); AddFileBasedOnPath(filePath); } } } } private void AddFileBasedOnPath(string? filePath) { if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath) && !filePath.EndsWith(".DS_Store")) { // make sure extensions are OK var fileExtensions = Constants.AllowedFileExtensionsNoStar; var didMatch = false; foreach (var fileExtension in fileExtensions) { if (filePath.ToLower().EndsWith("." + fileExtension.ToLower())) { didMatch = true; break; } } if (!didMatch) { if (!filePath.EndsWith(Constants.ReportSavedDataFileName)) { LogInfo("File {0} did not match allowed file extension types, so it was not added.", filePath); } } else { var date = Utilities.CheckValidDateInString(filePath); ReportFiles.Add(new ReportFile() { Title = Path.GetFileName(filePath), ReceiptDateTime = date.HasValue ? date.Value.ToDateTime(TimeOnly.MinValue) : File.GetCreationTime(filePath), Notes = "", FilePath = filePath, }); HasUnsavedWork = true; } } } public async void RemoveAllItems() { var result = await DialogHost.Show(new ConfirmViewModel("Warning!", "Are you sure you want to remove all items from this report?", "Remove All Items", "Cancel")); if (result != null && (bool)result) { ReportFiles.Clear(); HasUnsavedWork = true; NotifyPropertyChanged(nameof(IsCreatePDFButtonEnabled)); } } public void LocateFile(object f) => LocateFileImpl((ReportFile) f); public async void LocateFileImpl(ReportFile reportFile) { var topLevel = TopLevelGrabber?.GetTopLevel(); if (topLevel is not null) { var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions() { Title = "Choose image or PDF file...", AllowMultiple = false, FileTypeFilter = [ new FilePickerFileType("All Types") { Patterns = Constants.AllowedFileExtensionPatterns, AppleUniformTypeIdentifiers = [ "public.image", "com.adobe.pdf", "public.heic" ], MimeTypes = [ "image/*", "application/pdf", "image/heic" ] }, FilePickerFileTypes.ImageAll, new FilePickerFileType("HEIC Images") { Patterns = [ "*.heic" ], AppleUniformTypeIdentifiers = [ "public.heic" ], MimeTypes = [ "image/heic" ] }, FilePickerFileTypes.Pdf, ], }); if (files.Count > 0) { var file = files[0]; reportFile.FilePath = file.Path.LocalPath; HasUnsavedWork = true; } } } // https://github.com/AvaloniaUI/Avalonia/issues/10075 public void OpenFile(object f) => OpenFileImpl((ReportFile)f); public void OpenFileImpl(ReportFile file) { var topLevel = TopLevelGrabber?.GetTopLevel(); if (topLevel is not null) { var launcher = topLevel.Launcher; launcher.LaunchUriAsync(new Uri(file.FilePath)); } } public void OpenFileLocation(object f) => OpenFileLocationImpl((ReportFile)f); private void OpenFileLocationImpl(ReportFile file) { OpenFolderForFileInFileViewer(file.FilePath); } private void OpenFolderForFileInFileViewer(string fullPathToFile) { var topLevel = TopLevelGrabber?.GetTopLevel(); var dirName = Path.GetDirectoryName(fullPathToFile); if (topLevel is not null && dirName != null) { var launcher = topLevel.Launcher; launcher.LaunchUriAsync(new Uri(dirName)); } } public void ResortPDFItemsByDate() { LogInfo("Sorting report files list..."); ReportFiles = new ObservableCollection(ReportFiles.OrderBy(x => x.ReceiptDateTime)); HasUnsavedWork = true; } public async void BuildPDF() { if (string.IsNullOrWhiteSpace(ReportTitle)) { await DialogHost.Show(new WarningViewModel("You must provide a report title!")); } else { try { await Task.Run(() => CreatePDF(WorkingFolder)); } catch (Exception e) { LogInfo("PDF process failed! Reason: " + e.Message); if (e.StackTrace != null) { LogInfo(e.StackTrace); } var otherException = e.InnerException; while (otherException != null) { LogInfo(">> Inner exception: " + otherException.Message); if (otherException.StackTrace != null) { LogInfo(otherException.StackTrace); } otherException = otherException.InnerException; } LogInfo("Please report this error to a programmer or fix the issue listed above."); IsCreatingPDF = false; } } } public async Task SaveInterimReportInfo() { var report = new PDFReport() { Title = ReportTitle, Files = ReportFiles.ToList(), BaseFolder = WorkingFolder, LastSaved = DateTime.Now, LastGenerated = _lastGeneratedTime, }; await SavePDFReportDataToDisk(report); } private async Task SavePDFReportDataToDisk(PDFReport report) { var jsonContext = new SourceGenerationContext(Utilities.GetSerializerOptions()); using var memoryStream = new MemoryStream(); await JsonSerializer.SerializeAsync(memoryStream, report, jsonContext.PDFReport); memoryStream.Position = 0; using var reader = new StreamReader(memoryStream); var json = await reader.ReadToEndAsync(); var savePath = GetReportSavedDataPath(WorkingFolder); await File.WriteAllTextAsync(savePath, json); LogInfo("Saved report information to {0}", savePath); HasUnsavedWork = false; } private async Task CreateAndSaveReportObjectAfterReportCreation() { var report = new PDFReport() { Title = ReportTitle, Files = ReportFiles.ToList(), BaseFolder = WorkingFolder, LastSaved = DateTime.Now, LastGenerated = DateTime.Now, }; _lastGeneratedTime = DateTime.Now; await SavePDFReportDataToDisk(report); } public async Task CopyLogToClipboard() { var clipboard = TopLevelGrabber?.GetTopLevel().Clipboard; if (clipboard != null) { await clipboard.SetTextAsync(ProgramLog); LogInfo("Program log has been copied to the clipboard!"); } } public byte[]? GetFont(string faceName) { LogInfo(string.Format("Loading font {0}", faceName)); if (faceName == "Noto Sans") { var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); if (!File.Exists(path)) { path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Regular.ttf"); } return File.ReadAllBytes(path); } if (faceName == "Noto Sans Bold") { var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); if (!File.Exists(path)) { path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans/static/NotoSans-Bold.ttf"); } return File.ReadAllBytes(path); } if (faceName == "Noto Sans JP") { var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); if (!File.Exists(path)) { path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"); } return File.ReadAllBytes(path); } if (faceName == "Noto Sans JP Bold") { var path = Path.Combine(_processDir, "Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); if (!File.Exists(path)) { path = Path.Combine(_processDir, "../Resources/Assets/Fonts/Noto_Sans_JP/static/NotoSansJP-SemiBold.ttf"); } return File.ReadAllBytes(path); } return null; } public FontResolverInfo? ResolveTypeface(string familyName, bool bold, bool italic) { // LogInfo(string.Format("Resolving font name {0}", familyName)); if (familyName == "Noto Sans") { if (bold) { return new FontResolverInfo(familyName + " Bold"); } return new FontResolverInfo(familyName); } if (familyName == "Noto Sans JP") { if (bold) { return new FontResolverInfo(familyName + " Bold"); } return new FontResolverInfo(familyName); } return null; } private Paragraph GetFooterParagraph() { var footerPar = new Paragraph(); footerPar.Format.Alignment = ParagraphAlignment.Center; footerPar.Format.Font.Size = 10; footerPar.AddText("--Page "); footerPar.AddPageField(); footerPar.AddText(" of "); footerPar.AddNumPagesField(); footerPar.AddText("--"); footerPar.AddLineBreak(); footerPar.AddText("Report generated on " + DateTime.Now.ToString("f")); footerPar.Tag = "FooterPar"; footerPar.Format.Font.Name = "Noto Sans"; return footerPar; } private decimal GetExistingPageItemHeight(PdfDocumentRenderer pdfRenderer, decimal footerParagraphHeight) { pdfRenderer.DocumentRenderer.PrepareDocument(); var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; var heightForExistingItemsOnPage = footerParagraphHeight; if (currPageCount.HasValue) { var renderInfo = pdfRenderer.DocumentRenderer.GetRenderInfoFromPage(currPageCount.Value); if (renderInfo != null) { // Console.WriteLine("Got render info for page: {0}", currPageCount); foreach (var item in renderInfo) { heightForExistingItemsOnPage += (decimal)item.LayoutInfo.ContentArea.Height.Inch; } } } return heightForExistingItemsOnPage; } // https://forum.pdfsharp.net/viewtopic.php?f=2&t=1025 private async Task CreatePDF(string folderPath) { // TODO: calculate needed width for images based on page width and margins and all that? // safety checks var outputDir = _settings.SaveOutputPdfInWorkingDir ? folderPath : _settings.OutputPdfDir; if (!Directory.Exists(outputDir)) { await DialogHost.Show(new WarningViewModel("Output directory not found! Please adjust your application Settings before continuing. Output directory: " + outputDir)); return; } // setup globals and consts... GlobalFontSettings.FontResolver = this; GlobalFontSettings.FallbackFontResolver = new FailsafeFontResolver(); const decimal pageWidth = 8.5m; const decimal pageHeight = 11.0m; const decimal margin = 0.5m; const int imageResolution = 72; const int imageInsertMarginPixels = 30; // we calculate max available; use max - this # for max image size var maxItemPxWidth = ((pageWidth - (2 * margin)) * imageResolution) - imageInsertMarginPixels; // start making PDF! IsCreatingPDF = true; var pdfDoc = new Document(); var outputFileName = ReportTitle + ".pdf"; var folderName = new DirectoryInfo(folderPath).Name; const int maxImageWidth = 425; var imageLineFormat = new MigraDoc.DocumentObjectModel.Shapes.LineFormat() { Color = Colors.Black, Width = Unit.FromPoint(2), };; if (folderName.Contains('-')) { // see if year/month format var parts = folderName.Split('-'); if (parts[0].Length == 4 && parts[1].Length <= 2 && int.TryParse(parts[0], out int year) && int.TryParse(parts[1], out int month)) { outputFileName = string.Format("{0} {1} Receipts.pdf", CultureInfo.CurrentCulture.DateTimeFormat.GetMonthName(month), year); LogInfo("Auto-changed output file name to " + outputFileName); } } // setup initial section (for page characteristics) var section = pdfDoc.AddSection(); section.PageSetup.PageFormat = PageFormat.Letter; section.PageSetup.PageWidth = pageWidth + "in"; section.PageSetup.PageHeight = pageHeight + "in"; section.PageSetup.TopMargin = margin + "in"; section.PageSetup.RightMargin = margin + "in"; section.PageSetup.BottomMargin = margin + "in"; section.PageSetup.LeftMargin = margin + "in"; // setup footer for page number var footerPar = GetFooterParagraph(); section.Footers.Primary.Add(footerPar); // create a quick PDF doc renderer to measure footer paragraph height var footerParagraphHeight = 0.4m; // estimate var footerOnlyPdfDoc = new Document(); var sectionClone = section.Clone(); footerOnlyPdfDoc.Add(sectionClone); sectionClone.Add(GetFooterParagraph()); var footerPdfRenderer = new PdfDocumentRenderer { Document = footerOnlyPdfDoc }; footerPdfRenderer.DocumentRenderer.PrepareDocument(); var footerRenderInfo = footerPdfRenderer.DocumentRenderer.GetRenderInfoFromPage(1); if (footerRenderInfo != null) { foreach (var item in footerRenderInfo) { if (item.DocumentObject.Tag?.ToString() == "FooterPar") { Console.WriteLine("Got footer paragraph height!"); footerParagraphHeight = (decimal)item.LayoutInfo.ContentArea.Height.Inch; break; } } } // continue setting up document // First page only: add report title var reportTitlePar = section.AddParagraph(); reportTitlePar.Format.Alignment = ParagraphAlignment.Center; reportTitlePar.Format.Font.Size = 16; reportTitlePar.Format.Font.Bold = true; reportTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too reportTitlePar.AddText(ReportTitle); reportTitlePar.Tag = "TitlePar"; // get converted files directory path and create it if necessary var convertedDir = Path.Combine(Utilities.GetInternalDataPath(), "converted"); if (!Directory.Exists(convertedDir)) { Directory.CreateDirectory(convertedDir); } // var pdfRenderer = new PdfDocumentRenderer { Document = pdfDoc, WorkingDirectory = folderPath }; var hasAddedData = false; for (var i = 0; i < ReportFiles.Count; i++) { var file = ReportFiles[i]; var fileName = file.FileName; var filePath = file.FilePath; if (!File.Exists(filePath)) { LogInfo("ERROR: File \"{0}\" does not exist at path \"{1}\". Please remove it from the report or re-add it using the Add Item button if you still want it to be in this report.", file.Title, file.FilePath); IsCreatingPDF = false; return; } if (fileName == ".DS_Store" || fileName == outputFileName) { continue; } if (i > 0 && hasAddedData) { section.AddPageBreak(); } var imageTitlePar = section.AddParagraph(); imageTitlePar.Format.Alignment = ParagraphAlignment.Center; imageTitlePar.Format.Font.Size = 12; imageTitlePar.Format.Font.Bold = true; imageTitlePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too imageTitlePar.AddText(string.IsNullOrWhiteSpace(file.Title) ? file.FileName : file.Title); imageTitlePar.Tag = "ReceiptTitlePar"; var receiptDatePar = section.AddParagraph(); receiptDatePar.Format.Alignment = ParagraphAlignment.Center; receiptDatePar.Format.Font.Size = 12; receiptDatePar.Format.Font.Bold = true; receiptDatePar.Format.Font.Name = "Noto Sans JP"; // has english letters in it, too receiptDatePar.AddText(file.ReceiptDate.ToString(_settings.ReportDateFormat)); receiptDatePar.Tag = "ReceiptDatePar"; if (!string.IsNullOrWhiteSpace(file.Notes)) { var imageNotesPar = section.AddParagraph(); imageNotesPar.Format.Alignment = ParagraphAlignment.Center; imageNotesPar.Format.Font.Size = 10; imageNotesPar.Format.Font.Bold = false; imageNotesPar.Format.Font.Name = "Noto Sans JP"; imageNotesPar.AddText(file.Notes); imageNotesPar.Tag = "ReceiptNotesPar"; } var emptyPar = section.AddParagraph(); // add empty line for spacing emptyPar.Tag = "EmptyParagraph"; // now add the image var lowerName = fileName.ToLower(); var isPDF = lowerName.EndsWith(".pdf"); // convert heic, webp, or png to JPEG for size and ease of use // (and probably compat reasons too, though I haven't tested that...) var isHEIC = lowerName.EndsWith(".heic"); var isWebp = lowerName.EndsWith(".webp"); var isPNG = lowerName.EndsWith(".png"); var info = new FileInfo(file.FilePath); uint loadedImageWidth = 0; uint loadedImageHeight = 0; // get max pixel height remaining for items on this page // (For multi-page PDFs, showing page 2 and on will have more height since they have no title, // but to keep things consistent we will use the same height for all PDF pages.) // render up to now on this page and get height remaining in inches var currPageCount = pdfRenderer.DocumentRenderer.FormattedDocument?.PageCount; var heightForExistingItemsOnPage = GetExistingPageItemHeight(pdfRenderer, footerParagraphHeight); var remainingHeightInches = pageHeight - (2 * margin) - heightForExistingItemsOnPage; var remainingHeightPixels = (remainingHeightInches * imageResolution) - imageInsertMarginPixels; if (!isPDF) { using var mImage = new MagickImage(info.FullName); loadedImageWidth = mImage.Width; loadedImageHeight = mImage.Height; var convertedOutputPath = Path.Combine(convertedDir, info.Name + ".jpg"); var didAdjust = false; LogInfo("Image orientation of {0} is {1}", fileName, mImage.Orientation); if (mImage.Orientation != OrientationType.TopLeft) { LogInfo("Auto-adjusted image orientation of {0}", fileName); mImage.AutoOrient(); didAdjust = true; } // perform needed image manipulations if (isHEIC || isWebp || isPNG || (!isPDF && info.Length > _settings.ImageResizeThreshold * 1024 * 1024)) { // Save image as jpg mImage.Quality = 80; if (mImage.Width >= 400 || mImage.Height >= 400) { loadedImageWidth = (uint)Math.Floor(mImage.Width * 0.5); loadedImageHeight = (uint)Math.Floor(mImage.Height * 0.5); mImage.Scale(loadedImageWidth, loadedImageHeight); LogInfo("Image {2} scaled to {0}x{1}", loadedImageWidth, loadedImageHeight, fileName); } didAdjust = true; LogInfo("Converted image {0} to JPEG", fileName); } else { // load height/width loadedImageWidth = mImage.Width; loadedImageHeight = mImage.Height; } if (didAdjust) { await mImage.WriteAsync(convertedOutputPath); filePath = convertedOutputPath; LogInfo(string.Format("Saved adjusted image to JPEG; file path is now {0}", filePath)); } // write to PDF var paragraph = section.AddParagraph(); paragraph.Format.Alignment = ParagraphAlignment.Center; var image = paragraph.AddImage(filePath); image.Resolution = imageResolution; // dots per inch image.Tag = "ReceiptImageTag"; paragraph.Tag = "ReceiptImageParagraphTag"; image.LineFormat = imageLineFormat.Clone(); // resize down until it will fit on the page while (loadedImageHeight > remainingHeightPixels || loadedImageWidth > maxItemPxWidth) { // Console.WriteLine("Image height = {0}, width = {1}; decreasing size by 5% to h={2}, w={3}", loadedImageHeight, loadedImageWidth, (uint)Math.Floor(loadedImageHeight * 0.95), (uint)Math.Floor(loadedImageWidth * 0.95)); // keep reducing size by 5% (little by little) until it fits on the page // ...might skew ever so slightly but should not be noticable... loadedImageHeight = (uint)Math.Floor(loadedImageHeight * 0.95); loadedImageWidth = (uint)Math.Floor(loadedImageWidth * 0.95); } image.Height = loadedImageHeight; image.Width = loadedImageWidth; } else // isPDF { // need to render PDF to images if (_settings.UseDocnetPDFImageRendering) { // render using Docnet library (which utilizes pdfium, the chrome renderer) string RenderPdfPageToImage(IDocReader docReader, int pgNum) { Console.WriteLine("Rendering pg " + pgNum); using var pageReader = docReader.GetPageReader(pgNum); Console.WriteLine("Getting image for page " + pgNum); var rawBytes = pageReader.GetImage(RenderFlags.RenderAnnotations); Console.WriteLine("Getting width & height for page " + pgNum); var width = pageReader.GetPageWidth(); var height = pageReader.GetPageHeight(); Console.WriteLine("Loading pixel data for page " + pgNum); using var img = Image.LoadPixelData(rawBytes, width, height); // you are likely going to want this as well otherwise you might end up with transparent parts. img.Mutate(x => x.BackgroundColor(SixLabors.ImageSharp.Color.White)); var pdfPageImageOutputPath = Path.Combine(convertedDir, info.Name + "-Page-" + (pgNum + 1).ToString().PadLeft(3, '0') + ".jpg"); img.Save(pdfPageImageOutputPath); Console.WriteLine("Done rendering pg " + pgNum); return pdfPageImageOutputPath; } // render all pages to images var docReader = DocLib.Instance.GetDocReader( filePath, new PageDimensions(1080, 1920)); // TODO: are these dims right? // add to document var pgCount = docReader.GetPageCount(); if (pgCount > 0) { var convertedPdfImagePath = RenderPdfPageToImage(docReader, 0); imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", pgCount, pgCount == 1 ? "" : "s")); var paragraph = section.AddParagraph(); paragraph.Format.Alignment = ParagraphAlignment.Center; // get image height/width off of disk so we can resize down if needed var image = paragraph.AddImage(convertedPdfImagePath); image.LockAspectRatio = true; image.LineFormat = imageLineFormat.Clone(); using (var firstPdfPageImage = new MagickImage(convertedPdfImagePath)) { var pdfPageImageWidth = firstPdfPageImage.Width; var pdfPageImageHeight = firstPdfPageImage.Height; // resize down until it will fit on the page while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) { pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * 0.95); pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * 0.95); } image.Height = pdfPageImageHeight; image.Width = pdfPageImageWidth; } for (var j = 1; j < pgCount; j++) { section.AddPageBreak(); paragraph = section.AddParagraph(); paragraph.Format.Alignment = ParagraphAlignment.Center; convertedPdfImagePath = RenderPdfPageToImage(docReader, j); image = paragraph.AddImage(convertedPdfImagePath); image.LockAspectRatio = true; image.Width = maxImageWidth; image.LineFormat = imageLineFormat.Clone(); using (var otherPdfPageImage = new MagickImage(convertedPdfImagePath)) { var pdfPageImageWidth = otherPdfPageImage.Width; var pdfPageImageHeight = otherPdfPageImage.Height; // resize down until it will fit on the page while (pdfPageImageHeight > remainingHeightPixels || pdfPageImageWidth > maxItemPxWidth) { pdfPageImageHeight = (uint)Math.Floor(pdfPageImageHeight * 0.95); pdfPageImageWidth = (uint)Math.Floor(pdfPageImageWidth * 0.95); } image.Height = pdfPageImageHeight; image.Width = pdfPageImageWidth; } } } } else { // use older, not-docnet rendering method. // uses MigraDoc rendering. Does not work with annotations, and since Migradoc // doesn't let us know how big the image is, we can't do the image resizing, so // we just do our best. // render first page (eventually need to improve code to just do everything in a loop) var paragraph = section.AddParagraph(); paragraph.Format.Alignment = ParagraphAlignment.Center; var image = paragraph.AddImage(filePath); image.LockAspectRatio = true; image.Width = maxImageWidth; // can't be too wide now...not sure why...maybe due to margins... image.LineFormat = imageLineFormat.Clone(); // render other PDF pages, if any // see: https://stackoverflow.com/a/65091204/3938401 var pdfFileToAdd = PdfReader.Open(filePath, PdfDocumentOpenMode.Import); var pgCount = pdfFileToAdd.PageCount; imageTitlePar.AddText(string.Format(" (PDF with {0} page{1}) ", pgCount, pgCount == 1 ? "" : "s")); for (var j = 2; j <= pgCount; j++) { section.AddPageBreak(); paragraph = section.AddParagraph(); paragraph.Format.Alignment = ParagraphAlignment.Center; image = paragraph.AddImage(filePath + "#" + j); image.LockAspectRatio = true; image.Width = maxImageWidth; image.LineFormat = imageLineFormat.Clone(); } } } LogInfo(string.Format("Added image: {0} ({1})", file.Title, filePath)); hasAddedData = true; } LogInfo("Rendering document to PDF file..."); pdfRenderer.DocumentRenderer.PrepareDocument(); // needed if you make edits after first PrepareDocument() is called pdfRenderer.RenderDocument(); // actually save to disk now string outputPDFFilePath = Path.Join(outputDir, outputFileName); LogInfo("Saving PDF document to disk..."); pdfRenderer.PdfDocument.Save(outputPDFFilePath); LogInfo("Finished saving PDF output to: " + outputPDFFilePath); await CreateAndSaveReportObjectAfterReportCreation(); // clean up data dir Directory.Delete(convertedDir, true); // show output folder to user OpenFolderForFileInFileViewer(outputPDFFilePath); IsCreatingPDF = false; } public async Task CheckIsSafeToShutdown() { if (!HasUnsavedWork || string.IsNullOrWhiteSpace(WorkingFolder)) { return true; } else { var result = await DialogHost.Show(new ShutdownCheckViewModel()); if (result != null && result is ShutdownCheckOptions opt) { if (opt == ShutdownCheckOptions.SaveAndShutdown) { await SaveInterimReportInfo(); return true; } else if (opt == ShutdownCheckOptions.NoSaveShutdown) { return true; } else if (opt == ShutdownCheckOptions.CancelShutdown) { return false; } } } return false; } }