diff --git a/.ai/constraints.md b/.ai/constraints.md index c699a5fa1d..242cb1022b 100644 --- a/.ai/constraints.md +++ b/.ai/constraints.md @@ -13,7 +13,7 @@ Central package management is on (`ManagePackageVersionsCentrally=true`). An inl → Add `` to `Directory.Packages.props`, reference without a version. ### Never add a package for a capability the repository already has -`Grand.Mediator`, FluentValidation, `Grand.Mapping`, `MongoDB.Driver`, `StackExchange.Redis`, DotLiquid, Scryber, ImageSharp, MailKit, Scrutor are already present. Adding a second library for the same job splits the codebase. +`Grand.Mediator`, FluentValidation, `Grand.Mapping`, `MongoDB.Driver`, `StackExchange.Redis`, DotLiquid, PdfPig, SkiaSharp, MailKit, Scrutor are already present. Adding a second library for the same job splits the codebase. → See the table in `.ai/standards/dependencies.md`. ### Never use Newtonsoft.Json diff --git a/.ai/knowledge/repository-map.md b/.ai/knowledge/repository-map.md index 7d5f019749..7b47c3efe4 100644 --- a/.ai/knowledge/repository-map.md +++ b/.ai/knowledge/repository-map.md @@ -114,7 +114,6 @@ Important folders: - `Controllers` - `Views` - `Views/Shared` -- `Views/PdfTemplates` - `Components` - `Commands` - `Features` diff --git a/.ai/knowledge/template-types.md b/.ai/knowledge/template-types.md index 6fdc1c2efc..701341699f 100644 --- a/.ai/knowledge/template-types.md +++ b/.ai/knowledge/template-types.md @@ -100,14 +100,15 @@ Follow existing patterns: - Preserve Vue props, component names, and model shape. - Keep generated bundle requirements in sync with `src/Web/Grand.Web/vueapp`. -## PDF Templates -Use for PDF views under: -- `src/Web/Grand.Web/Views/PdfTemplates` - -Follow existing PDF patterns: -- Keep markup simple and deterministic. -- Avoid interactive JavaScript. -- Use inline or existing PDF-compatible styling. +## PDF documents +Order invoices and packaging slips are not views. `PdfService` in +`src/Business/Grand.Business.Common/Services/Pdf` lays them out in code through `PdfComposer` +(text, tables, images, page breaks) on top of PdfPig. + +- Change the layout in `PdfService`; add a layout primitive to `PdfComposer` only when two documents need it. +- HTML from the database (attribute descriptions, invoice header and footer) goes through `PdfHtmlText`, which drops markup. +- Text is drawn with the embedded Noto Sans fonts; characters outside them print as `?`. +- Plugins add lines below the payment method by handling `OrderPdfPaymentLinesEvent`; views and widget zones do not reach a PDF. - Preserve order, shipment, address, tax, and currency formatting. ## DotLiquid Message Templates diff --git a/.ai/standards/dependencies.md b/.ai/standards/dependencies.md index 286203c313..9811290c31 100644 --- a/.ai/standards/dependencies.md +++ b/.ai/standards/dependencies.md @@ -29,8 +29,8 @@ Do not add a new third-party package when the repository already has a capabilit | MongoDB | `MongoDB.Driver` | | Caching / distributed cache | `Microsoft.Extensions.Caching.Memory`, `StackExchange.Redis` | | Templating for messages | `DotLiquid` | -| PDF | `Scryber.Core` | -| Images | `SixLabors.ImageSharp`, `SkiaSharp` | +| PDF | `PdfPig` for writing PDF objects; layout in `Grand.Business.Common/Services/Pdf/PdfComposer` | +| Images | `SkiaSharp` | | Mail | `MailKit` | | DI scanning | `Scrutor` | | API docs | `Microsoft.AspNetCore.OpenApi`, `Scalar.AspNetCore` | diff --git a/.trivyignore b/.trivyignore index b96220dddc..f1e2311a70 100644 --- a/.trivyignore +++ b/.trivyignore @@ -2,11 +2,9 @@ # on that date, so it has to be reviewed rather than forgotten. # SixLabors.ImageSharp 3.1.12 (fixed in 4.1.2), a transitive dependency of -# Scryber.Core, which pins 3.1.12 up to its latest release (9.7.5). -# GrandNode does not reference ImageSharp; Scryber.Imaging only detects and -# decodes images (TiffFormat, TiffMetadata, DecoderOptions). The affected code -# is never called: the TIFF CCITT T4/T6 encoders and HistogramEqualization. -# Drop these entries once Scryber.Core moves to ImageSharp 4.1.2 or later. +# elFinder.Net.Core 1.5.0, lifted from 2.0.0 by Grand.Web.AdminShared. +# GrandNode does not reference ImageSharp in code. 4.x is not verified with +# elFinder. Drop these entries once ImageSharp moves to 4.1.2 or elFinder goes. CVE-2026-106110 exp:2027-01-16 CVE-2026-106113 exp:2027-01-16 CVE-2026-106115 exp:2027-01-16 diff --git a/Directory.Packages.props b/Directory.Packages.props index 83e2417aeb..399609b088 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,8 +13,7 @@ - - + @@ -51,6 +50,7 @@ + diff --git a/src/Business/Grand.Business.Common/Grand.Business.Common.csproj b/src/Business/Grand.Business.Common/Grand.Business.Common.csproj index e9684ff464..033289d5e1 100644 --- a/src/Business/Grand.Business.Common/Grand.Business.Common.csproj +++ b/src/Business/Grand.Business.Common/Grand.Business.Common.csproj @@ -3,9 +3,11 @@ - - - + + + + + diff --git a/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Bold.ttf b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Bold.ttf new file mode 100644 index 0000000000..aae7546dc1 Binary files /dev/null and b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Bold.ttf differ diff --git a/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Regular.ttf b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Regular.ttf new file mode 100644 index 0000000000..f27f4ff595 Binary files /dev/null and b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/NotoSans-Regular.ttf differ diff --git a/src/Business/Grand.Business.Common/Services/Pdf/Fonts/OFL.txt b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/OFL.txt new file mode 100644 index 0000000000..6843f31878 --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Pdf/Fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/Business/Grand.Business.Common/Services/Pdf/HtmlToPdfService.cs b/src/Business/Grand.Business.Common/Services/Pdf/HtmlToPdfService.cs deleted file mode 100644 index 4508b8099d..0000000000 --- a/src/Business/Grand.Business.Common/Services/Pdf/HtmlToPdfService.cs +++ /dev/null @@ -1,132 +0,0 @@ -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Pdf; -using Grand.Data; -using Grand.Domain.Media; -using Grand.Domain.Orders; -using Grand.Domain.Shipping; -using Grand.SharedKernel.Extensions; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Logging; -using Scryber; -using Scryber.Components; -using Path = System.IO.Path; - -namespace Grand.Business.Common.Services.Pdf; - -/// -/// Generate invoice, shipment as pdf (from html template to pdf) -/// -public class HtmlToPdfService : IPdfService -{ - private const string OrderTemplate = "~/Views/PdfTemplates/OrderPdfTemplate.cshtml"; - private const string ShipmentsTemplate = "~/Views/PdfTemplates/ShipmentPdfTemplate.cshtml"; - private readonly IRepository _downloadRepository; - private readonly ILanguageService _languageService; - private readonly ILogger _logger; - private readonly IStoreFilesContext _storeFilesContext; - private readonly IViewRenderService _viewRenderService; - private readonly IWebHostEnvironment _webHostEnvironment; - - public HtmlToPdfService(IViewRenderService viewRenderService, IRepository downloadRepository, - ILanguageService languageService, IStoreFilesContext storeFilesContext, IWebHostEnvironment webHostEnvironment, - ILogger logger) - { - _viewRenderService = viewRenderService; - _languageService = languageService; - _downloadRepository = downloadRepository; - _storeFilesContext = storeFilesContext; - _webHostEnvironment = webHostEnvironment; - _logger = logger; - } - - public async Task PrintOrdersToPdf(Stream stream, IList orders, string languageId = "", - string vendorId = "", CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); - ArgumentNullException.ThrowIfNull(orders); - - var html = await _viewRenderService.RenderToStringAsync(OrderTemplate, - new ValueTuple, string>(orders, vendorId)); - //the PDF layout is the costly, synchronous part; skip it when nobody waits for the file - cancellationToken.ThrowIfCancellationRequested(); - try - { - using var sr = new StringReader(html); - using var doc = Document.ParseDocument(sr, ParseSourceType.DynamicContent); - doc.SaveAsPDF(stream); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating PDF from order HTML. HTML content: {Html}", html); - throw; - } - } - - public async Task PrintOrderToPdf(Order order, string languageId, string vendorId = "") - { - ArgumentNullException.ThrowIfNull(order); - - var fileName = $"order_{order.OrderGuid}_{CommonHelper.GenerateRandomDigitCode(4)}.pdf"; - - var dir = Path.Combine(_webHostEnvironment.WebRootPath, "assets/files/exportimport"); - ArgumentNullException.ThrowIfNull(dir); - - if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); - - var filePath = Path.Combine(dir, fileName); - await using var fileStream = new FileStream(filePath, FileMode.Create); - var orders = new List { - order - }; - await PrintOrdersToPdf(fileStream, orders, languageId, vendorId); - return filePath; - } - - public async Task PrintPackagingSlipsToPdf(Stream stream, IList shipments, string languageId = "", - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(stream); - ArgumentNullException.ThrowIfNull(shipments); - - var lang = await _languageService.GetLanguageById(languageId); - if (lang == null) - throw new ArgumentException($"Cannot load language. ID={languageId}"); - - var html = await _viewRenderService.RenderToStringAsync(ShipmentsTemplate, shipments); - cancellationToken.ThrowIfCancellationRequested(); - try - { - using TextReader sr = new StringReader(html); - using var doc = Document.ParseDocument(sr, ParseSourceType.DynamicContent); - doc.SaveAsPDF(stream); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error generating PDF from shipment HTML. HTML content: {Html}", html); - throw; - } - } - - public async Task SaveOrderToBinary(Order order, string languageId, string vendorId = "") - { - ArgumentNullException.ThrowIfNull(order); - - var fileName = $"order_{order.OrderGuid}_{CommonHelper.GenerateRandomDigitCode(4)}"; - using var ms = new MemoryStream(); - var orders = new List { - order - }; - await PrintOrdersToPdf(ms, orders, languageId, vendorId); - var download = new Download { - Filename = fileName, - Extension = ".pdf", - ContentType = "application/pdf" - }; - - download.DownloadObjectId = await _storeFilesContext.BucketUploadFromBytes(download.Filename, ms.ToArray()); - await _downloadRepository.InsertAsync(download); - - //await _mediator.EntityInserted(download); - return download.Id; - } -} \ No newline at end of file diff --git a/src/Business/Grand.Business.Common/Services/Pdf/PdfComposer.cs b/src/Business/Grand.Business.Common/Services/Pdf/PdfComposer.cs new file mode 100644 index 0000000000..9006699571 --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Pdf/PdfComposer.cs @@ -0,0 +1,470 @@ +using System.Reflection; +using UglyToad.PdfPig.Core; +using UglyToad.PdfPig.Writer; + +namespace Grand.Business.Common.Services.Pdf; + +/// +/// Lays out text, tables and images top-down on A4 pages. PdfPig only writes PDF objects at absolute +/// positions; wrapping, columns and page breaks are done here. +/// +internal sealed class PdfComposer : IDisposable +{ + private const double Margin = 40; + private const double LineSpacing = 1.3; + private const double CellPadding = 4; + private const double FooterSize = 8; + private const double ImageGap = 10; + private const string MissingGlyph = "?"; + + //A4 in points + private const double PageWidth = 595; + private const double PageHeight = 842; + + //the footer is cut to a quarter of the page, so a long footer cannot leave no room for the content + private static readonly int MaxFooterLines = (int)((PageHeight - 2 * Margin) / 4 / (FooterSize * LineSpacing)); + + private static readonly Lazy RegularFontData = new(() => ReadFont("NotoSans-Regular.ttf")); + private static readonly Lazy BoldFontData = new(() => ReadFont("NotoSans-Bold.ttf")); + + private readonly PdfDocumentBuilder _builder = new(); + private readonly PdfDocumentBuilder.AddedFont _regular; + private readonly PdfDocumentBuilder.AddedFont _bold; + private readonly Dictionary<(bool Bold, int Rune), bool> _glyphs = new(); + private readonly Dictionary _images = new(ReferenceEqualityComparer.Instance); + + private PdfPageBuilder _page; + private double _y; + private string _footerText; + private IReadOnlyList _footer = []; + + //a right-aligned image narrows the text beside it down to this y + private double _asideBottom = double.MaxValue; + private double _asideWidth; + + public PdfComposer() + { + _regular = _builder.AddTrueTypeFont(RegularFontData.Value); + _bold = _builder.AddTrueTypeFont(BoldFontData.Value); + } + + public double ContentWidth => PageWidth - 2 * Margin; + + private double Bottom => Margin + (_footer.Count == 0 ? 0 : _footer.Count * FooterSize * LineSpacing + 8); + + public void Dispose() + { + _builder.Dispose(); + } + + /// + /// Starts a new page. The footer is repeated on the pages a table or text overflows to. + /// + public void NewPage(string footer = null) + { + _page = _builder.AddPage(PageWidth, PageHeight); + _y = PageHeight - Margin; + _asideBottom = double.MaxValue; + _footerText = footer; + _footer = string.IsNullOrWhiteSpace(footer) + ? [] + : Wrap(footer, FooterSize, _regular, _ => ContentWidth).Take(MaxFooterLines).ToList(); + DrawFooter(); + } + + public void Space(double height) + { + _y -= height; + } + + public void Text(string text, double size = 10, bool bold = false, PdfAlign align = PdfAlign.Left, + byte gray = 0) + { + if (string.IsNullOrWhiteSpace(text)) return; + + var font = bold ? _bold : _regular; + var lineHeight = size * LineSpacing; + //lines that start above the bottom of a right-aligned image are wrapped short of it + var besideImage = _y > _asideBottom ? (int)Math.Ceiling((_y - _asideBottom) / lineHeight) : 0; + foreach (var line in Wrap(text, size, font, + i => i < besideImage ? ContentWidth - _asideWidth : ContentWidth)) + { + EnsureSpace(lineHeight); + DrawLine(line, size, font, Margin, _y, ContentWidth, align, gray); + _y -= lineHeight; + } + } + + public void HorizontalRule(byte gray = 0, double width = 0.5) + { + ClearImage(); + _page.SetStrokeColor(gray, gray, gray); + _page.DrawLine(new PdfPoint(Margin, _y), new PdfPoint(PageWidth - Margin, _y), width); + _page.ResetColor(); + } + + /// + /// Draws a PNG at the current position without moving the cursor, scaled down to fit the box. Text that + /// follows a right-aligned image wraps beside it until , a rule or a table. + /// + public void Image(byte[] png, double maxWidth, double maxHeight, PdfAlign align) + { + if (!TryReadPngSize(png, out var width, out var height)) return; + + var scale = Math.Min(1, Math.Min(maxWidth / width, maxHeight / height)); + var w = width * scale; + var h = height * scale; + var x = align == PdfAlign.Right ? PageWidth - Margin - w : Margin; + var rectangle = new PdfRectangle(x, _y - h, x + w, _y); + //the image is written into the document once and every later page refers to it + if (_images.TryGetValue(png, out var added)) + _page.AddImage(added, rectangle); + else + _images[png] = _page.AddPng(png, rectangle); + + if (align != PdfAlign.Right) return; + _asideBottom = _y - h; + _asideWidth = w + ImageGap; + } + + /// + /// Moves the cursor below the image drawn beside the text, if it is not already there. + /// + public void ClearImage() + { + if (_asideBottom == double.MaxValue) return; + _y = Math.Min(_y, _asideBottom); + _asideBottom = double.MaxValue; + } + + /// + /// Draws a table. Column widths are relative weights of the content width. A row that does not fit on + /// the page moves to the next one, repeating the header; a row taller than a whole page is split + /// between its lines. + /// + public void Table(IReadOnlyList columns, IReadOnlyList header, + IEnumerable> rows, double size = 10, bool rowLines = true) + { + ClearImage(); + var totalWeight = columns.Sum(c => c.Weight); + var widths = columns.Select(c => ContentWidth * c.Weight / totalWeight).ToArray(); + + if (header != null) DrawHeader(columns, widths, header, size); + + foreach (var row in rows) + { + var cells = new List<(string Text, double Size, PdfDocumentBuilder.AddedFont Font)>[columns.Count]; + var height = 0d; + for (var i = 0; i < columns.Count; i++) + { + cells[i] = []; + var cell = i < row.Count ? row[i] : PdfCell.Empty; + var width = widths[i] - 2 * CellPadding; + foreach (var line in cell.Lines) + { + var lineSize = line.Small ? size - 2 : size; + var font = line.Bold ? _bold : _regular; + cells[i].AddRange(Wrap(line.Text, lineSize, font, _ => width) + .Select(text => (text, lineSize, font))); + } + + height = Math.Max(height, cells[i].Sum(l => l.Size * LineSpacing)); + } + + height += 2 * CellPadding; + //a row that fits on an empty page is kept whole + var pageRoom = PageHeight - Margin - Bottom - HeaderHeight(header, size); + if (_y - height < Bottom && height <= pageRoom) + NewTablePage(columns, widths, header, size); + + var next = new int[columns.Count]; + while (true) + { + var room = _y - 2 * CellPadding - Bottom; + var used = 0d; + var done = true; + var x = Margin; + for (var i = 0; i < columns.Count; i++) + { + var top = _y - CellPadding; + var cellHeight = 0d; + while (next[i] < cells[i].Count) + { + var (text, lineSize, font) = cells[i][next[i]]; + var lineHeight = lineSize * LineSpacing; + if (cellHeight + lineHeight > room) break; + + DrawLine(text, lineSize, font, x + CellPadding, top, widths[i] - 2 * CellPadding, + columns[i].Align, 0); + top -= lineHeight; + cellHeight += lineHeight; + next[i]++; + } + + done &= next[i] == cells[i].Count; + used = Math.Max(used, cellHeight); + x += widths[i]; + } + + if (done) + { + _y -= used + 2 * CellPadding; + break; + } + + //not even one line fits at the top of a page: stop rather than add pages forever + if (used == 0 && room >= pageRoom - 2 * CellPadding) break; + + NewTablePage(columns, widths, header, size); + } + + if (rowLines) HorizontalRule(220); + } + } + + public byte[] Build() + { + //a PDF needs at least one page, also when there was nothing to print + if (_page == null) NewPage(); + return _builder.Build(); + } + + /// + /// Checks that PdfPig can embed the PNG, so a file its decoder rejects is found before the document is + /// laid out instead of failing it half way. + /// + public static bool CanEmbedPng(byte[] png) + { + if (!TryReadPngSize(png, out _, out _)) return false; + try + { + using var builder = new PdfDocumentBuilder(); + builder.AddPage(PageWidth, PageHeight).AddPng(png, new PdfRectangle(0, 0, 10, 10)); + return true; + } + catch (Exception) + { + return false; + } + } + + private void NewTablePage(IReadOnlyList columns, double[] widths, IReadOnlyList header, + double size) + { + NewPage(_footerText); + if (header != null) DrawHeader(columns, widths, header, size); + } + + private static double HeaderHeight(IReadOnlyList header, double size) + { + return header == null ? 0 : size * LineSpacing + 2 * CellPadding; + } + + private void DrawHeader(IReadOnlyList columns, double[] widths, IReadOnlyList header, + double size) + { + var height = HeaderHeight(header, size); + EnsureSpace(height); + _page.SetStrokeColor(128, 128, 128); + _page.SetTextAndFillColor(128, 128, 128); + _page.DrawRectangle(new PdfPoint(Margin, _y - height), ContentWidth, height, 0.5, true); + _page.ResetColor(); + + var x = Margin; + for (var i = 0; i < columns.Count; i++) + { + var text = i < header.Count ? header[i] : string.Empty; + var width = widths[i] - 2 * CellPadding; + var line = Wrap(text, size, _bold, _ => width).FirstOrDefault(); + DrawLine(line, size, _bold, x + CellPadding, _y - CellPadding, width, columns[i].Align, 255); + x += widths[i]; + } + + _y -= height; + } + + private void DrawFooter() + { + var top = Margin + _footer.Count * FooterSize * LineSpacing; + foreach (var line in _footer) + { + DrawLine(line, FooterSize, _regular, Margin, top, ContentWidth, PdfAlign.Left, 90); + top -= FooterSize * LineSpacing; + } + } + + private void EnsureSpace(double height) + { + if (_y - height < Bottom) NewPage(_footerText); + } + + /// + /// Draws one already wrapped line with its top at . + /// + private void DrawLine(string text, double size, PdfDocumentBuilder.AddedFont font, double x, double top, + double width, PdfAlign align, byte gray) + { + if (string.IsNullOrEmpty(text)) return; + + if (align == PdfAlign.Right) x += width - Measure(text, size, font); + if (gray != 0) _page.SetTextAndFillColor(gray, gray, gray); + _page.AddText(text, size, new PdfPoint(x, top - size), font); + if (gray != 0) _page.ResetColor(); + } + + /// + /// Breaks text into lines, giving the width of the n-th line. Each word is + /// measured once and a line is as wide as its words and the spaces between them. + /// + private List Wrap(string text, double size, PdfDocumentBuilder.AddedFont font, Func width) + { + var lines = new List(); + if (string.IsNullOrEmpty(text)) return lines; + + var space = Measure(" ", size, font); + foreach (var paragraph in Supported(text, font).Split('\n')) + { + var line = new StringBuilder(); + var lineWidth = 0d; + foreach (var word in paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var wordWidth = Measure(word, size, font); + if (line.Length > 0 && lineWidth + space + wordWidth <= width(lines.Count)) + { + line.Append(' ').Append(word); + lineWidth += space + wordWidth; + continue; + } + + if (line.Length > 0) lines.Add(line.ToString()); + var rest = word; + //a single word wider than the column is broken between characters + if (rest.Length > 1 && wordWidth > width(lines.Count)) + (rest, wordWidth) = BreakWord(rest, size, font, width, lines); + + line.Clear().Append(rest); + lineWidth = wordWidth; + } + + lines.Add(line.ToString()); + } + + return lines; + } + + /// + /// Adds the full-width pieces of a word wider than the column to and returns the + /// last piece with its width. The word is measured once, so a very long token (a pasted text attribute, + /// a URL) costs linear time instead of a measurement per piece. + /// + private (string Tail, double Width) BreakWord(string word, double size, PdfDocumentBuilder.AddedFont font, + Func width, List lines) + { + var letters = _page.MeasureText(word, size, new PdfPoint(0, 0), font); + //one letter per rune; charEnds[i] is where rune i ends in the string + var charEnds = new List(word.Length); + foreach (var rune in word.EnumerateRunes()) + charEnds.Add((charEnds.Count == 0 ? 0 : charEnds[^1]) + rune.Utf16SequenceLength); + if (letters.Count != charEnds.Count) return (word, Measure(word, size, font)); + + var start = 0; + var offset = 0d; + while (letters.Count - start > 1 && letters[^1].EndBaseLine.X - offset > width(lines.Count)) + { + var limit = width(lines.Count); + var end = start + 1; + while (end < letters.Count && letters[end].EndBaseLine.X - offset <= limit) end++; + lines.Add(word[(start == 0 ? 0 : charEnds[start - 1])..charEnds[end - 1]]); + offset = letters[end - 1].EndBaseLine.X; + start = end; + } + + return (word[(start == 0 ? 0 : charEnds[start - 1])..], letters[^1].EndBaseLine.X - offset); + } + + private double Measure(string text, double size, PdfDocumentBuilder.AddedFont font) + { + if (string.IsNullOrEmpty(text)) return 0; + var letters = _page.MeasureText(text, size, new PdfPoint(0, 0), font); + return letters.Count == 0 ? 0 : letters[^1].EndBaseLine.X; + } + + /// + /// PdfPig throws on a character the font has no glyph for, so such characters are replaced up front. + /// + private string Supported(string text, PdfDocumentBuilder.AddedFont font) + { + var sb = new StringBuilder(text.Length); + foreach (var rune in text.Replace("\r", string.Empty).Replace('\t', ' ').EnumerateRunes()) + { + if (rune.Value is '\n' or ' ') + { + sb.Append((char)rune.Value); + continue; + } + + if (Rune.IsControl(rune)) continue; + var key = (ReferenceEquals(font, _bold), rune.Value); + if (!_glyphs.TryGetValue(key, out var supported)) + { + try + { + _page.MeasureText(rune.ToString(), 10, new PdfPoint(0, 0), font); + supported = true; + } + catch (InvalidOperationException) + { + supported = false; + } + + _glyphs[key] = supported; + } + + sb.Append(supported ? rune.ToString() : MissingGlyph); + } + + return sb.ToString(); + } + + private static bool TryReadPngSize(byte[] png, out int width, out int height) + { + width = height = 0; + //8-byte signature, then the IHDR chunk: length, type, width, height (big-endian) + if (png is not { Length: >= 24 } || png[0] != 0x89 || png[1] != 'P' || png[2] != 'N' || png[3] != 'G') + return false; + + width = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19]; + height = (png[20] << 24) | (png[21] << 16) | (png[22] << 8) | png[23]; + return width > 0 && height > 0; + } + + private static byte[] ReadFont(string fileName) + { + using var stream = Assembly.GetExecutingAssembly() + .GetManifestResourceStream($"Grand.Business.Common.Pdf.Fonts.{fileName}") + ?? throw new InvalidOperationException($"Embedded font {fileName} is missing"); + using var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } +} + +internal enum PdfAlign +{ + Left, + Right +} + +internal sealed record PdfColumn(double Weight, PdfAlign Align = PdfAlign.Left); + +internal sealed record PdfLine(string Text, bool Bold = false, bool Small = false); + +internal sealed record PdfCell(IReadOnlyList Lines) +{ + public static readonly PdfCell Empty = new([]); + + public static PdfCell Of(string text, bool bold = false) + { + return new PdfCell([new PdfLine(text ?? string.Empty, bold)]); + } +} diff --git a/src/Business/Grand.Business.Common/Services/Pdf/PdfHtmlText.cs b/src/Business/Grand.Business.Common/Services/Pdf/PdfHtmlText.cs new file mode 100644 index 0000000000..66e4c3d7b8 --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Pdf/PdfHtmlText.cs @@ -0,0 +1,44 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace Grand.Business.Common.Services.Pdf; + +/// +/// Turns the HTML fragments that reach a PDF (attribute descriptions joined with <br />, the invoice +/// header and footer from PDF settings) into plain text lines. Markup is dropped, never rendered. +/// +internal static partial class PdfHtmlText +{ + public static string ToPlainText(string html) + { + if (string.IsNullOrWhiteSpace(html)) return string.Empty; + + var text = Hidden().Replace(html, string.Empty); + text = LineBreak().Replace(text, "\n"); + text = CellEnd().Replace(text, " "); + text = Tag().Replace(text, string.Empty); + text = WebUtility.HtmlDecode(text).Replace(' ', ' '); + + var lines = text.Split('\n') + .Select(l => Whitespace().Replace(l, " ").Trim()) + .Where(l => l.Length > 0); + return string.Join("\n", lines); + } + + //content that is never shown on a page + [GeneratedRegex(@"<(script|style)\b[^>]*>.*?|", RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex Hidden(); + + [GeneratedRegex(@"<(br|hr)\b[^>]*>|", RegexOptions.IgnoreCase)] + private static partial Regex LineBreak(); + + //table cells next to each other keep a space between their texts + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex CellEnd(); + + [GeneratedRegex("<[^>]*>")] + private static partial Regex Tag(); + + [GeneratedRegex(@"[ \t\r\f\v]+")] + private static partial Regex Whitespace(); +} diff --git a/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs b/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs new file mode 100644 index 0000000000..3060e6fc5c --- /dev/null +++ b/src/Business/Grand.Business.Common/Services/Pdf/PdfService.cs @@ -0,0 +1,410 @@ +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Extensions; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Data; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Domain.Tax; +using Grand.Mediator; +using Grand.SharedKernel.Extensions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Path = System.IO.Path; + +namespace Grand.Business.Common.Services.Pdf; + +/// +/// Generates order invoices and packaging slips as PDF documents +/// +public class PdfService : IPdfService +{ + private const double LogoMaxWidth = 150; + private const double LogoMaxHeight = 60; + + private readonly ICountryService _countryService; + private readonly IRepository _downloadRepository; + private readonly IGiftVoucherService _giftVoucherService; + private readonly ILogger _logger; + private readonly IMediator _mediator; + private readonly IOrderService _orderService; + private readonly IPaymentService _paymentService; + private readonly PdfSettings _pdfSettings; + private readonly IPictureService _pictureService; + private readonly IProductService _productService; + private readonly IStoreFilesContext _storeFilesContext; + private readonly IStoreService _storeService; + private readonly ITranslationService _translationService; + private readonly IWebHostEnvironment _webHostEnvironment; + + public PdfService( + IRepository downloadRepository, + ITranslationService translationService, + IStoreFilesContext storeFilesContext, + IWebHostEnvironment webHostEnvironment, + IProductService productService, + ICountryService countryService, + IStoreService storeService, + IPictureService pictureService, + IPaymentService paymentService, + IGiftVoucherService giftVoucherService, + IOrderService orderService, + PdfSettings pdfSettings, + IMediator mediator, + ILogger logger) + { + _downloadRepository = downloadRepository; + _translationService = translationService; + _storeFilesContext = storeFilesContext; + _webHostEnvironment = webHostEnvironment; + _productService = productService; + _countryService = countryService; + _storeService = storeService; + _pictureService = pictureService; + _paymentService = paymentService; + _giftVoucherService = giftVoucherService; + _orderService = orderService; + _pdfSettings = pdfSettings; + _mediator = mediator; + _logger = logger; + } + + public async Task PrintOrdersToPdf(Stream stream, IList orders, string languageId = "", + string vendorId = "", CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(orders); + + using var pdf = new PdfComposer(); + var logo = await LoadLogo(); + foreach (var order in orders) + { + cancellationToken.ThrowIfCancellationRequested(); + await ComposeOrder(pdf, order, vendorId, logo); + } + + //nothing is written until the whole document is laid out, so a cancelled request leaves the stream empty + cancellationToken.ThrowIfCancellationRequested(); + await stream.WriteAsync(pdf.Build(), cancellationToken); + } + + public async Task PrintOrderToPdf(Order order, string languageId, string vendorId = "") + { + ArgumentNullException.ThrowIfNull(order); + + var fileName = $"order_{order.OrderGuid}_{CommonHelper.GenerateRandomDigitCode(4)}.pdf"; + + var dir = Path.Combine(_webHostEnvironment.WebRootPath, "assets/files/exportimport"); + ArgumentNullException.ThrowIfNull(dir); + + if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); + + var filePath = Path.Combine(dir, fileName); + await using var fileStream = new FileStream(filePath, FileMode.Create); + await PrintOrdersToPdf(fileStream, [order], languageId, vendorId); + return filePath; + } + + public async Task PrintPackagingSlipsToPdf(Stream stream, IList shipments, string languageId = "", + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(shipments); + + //each slip is printed in its order's customer language + using var pdf = new PdfComposer(); + foreach (var shipment in shipments) + { + cancellationToken.ThrowIfCancellationRequested(); + await ComposePackagingSlip(pdf, shipment); + } + + cancellationToken.ThrowIfCancellationRequested(); + await stream.WriteAsync(pdf.Build(), cancellationToken); + } + + public async Task SaveOrderToBinary(Order order, string languageId, string vendorId = "") + { + ArgumentNullException.ThrowIfNull(order); + + var fileName = $"order_{order.OrderGuid}_{CommonHelper.GenerateRandomDigitCode(4)}"; + using var ms = new MemoryStream(); + await PrintOrdersToPdf(ms, [order], languageId, vendorId); + var download = new Download { + Filename = fileName, + Extension = ".pdf", + ContentType = "application/pdf" + }; + + download.DownloadObjectId = await _storeFilesContext.BucketUploadFromBytes(download.Filename, ms.ToArray()); + await _downloadRepository.InsertAsync(download); + + return download.Id; + } + + private async Task ComposeOrder(PdfComposer pdf, Order order, string vendorId, byte[] logo) + { + var lang = order.CustomerLanguageId; + var limitByVendor = !string.IsNullOrEmpty(vendorId); + var priceIncludesTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; + var store = await _storeService.GetStoreById(order.StoreId); + + pdf.NewPage(PdfHtmlText.ToPlainText(_pdfSettings.InvoiceFooterText)); + if (logo != null) pdf.Image(logo, LogoMaxWidth, LogoMaxHeight, PdfAlign.Right); + pdf.Text(PdfHtmlText.ToPlainText(_pdfSettings.InvoiceHeaderText), 9, gray: 90); + pdf.Text(store?.CompanyName, 14, true); + pdf.Text($"{Format("PDFInvoice.Order#", lang, order.OrderNumber)} ({order.CreatedOnUtc})", 11, true); + pdf.ClearImage(); + pdf.Space(8); + + var billing = PdfCell.Empty; + if (order.BillingAddress != null) + { + var paymentMethod = _paymentService.LoadPaymentMethodBySystemName(order.PaymentMethodSystemName); + var paymentLines = new OrderPdfPaymentLinesEvent(order, lang); + await _mediator.Publish(paymentLines); + billing = await AddressCell(order.BillingAddress, "PDFInvoice", "PDFInvoice.BillingInformation", lang, + Format("PDFInvoice.PaymentMethod", lang, paymentMethod.GetPaymentMethodName(order)), + paymentLines.Lines.Select(PdfHtmlText.ToPlainText)); + } + + var shipping = order.ShippingAddress == null + ? PdfCell.Empty + : await AddressCell(order.ShippingAddress, "PDFInvoice", "PDFInvoice.ShippingInformation", lang, + Format("PDFInvoice.ShippingMethod", lang, order.ShippingMethod)); + pdf.HorizontalRule(220); + pdf.Table([new PdfColumn(1), new PdfColumn(1)], null, [[billing, shipping]]); + + pdf.Space(14); + pdf.Text(_translationService.GetResource("PDFInvoice.Product(s)", lang)); + pdf.Space(4); + + var items = order.OrderItems.Where(x => !limitByVendor || x.VendorId == vendorId).ToList(); + var rows = new List>(); + foreach (var item in items) + { + var product = await _productService.GetProductByIdIncludeArch(item.ProductId); + var name = new List { new(product?.GetTranslation(x => x.Name, lang) ?? item.ProductId) }; + var attributes = PdfHtmlText.ToPlainText(item.AttributeDescription); + if (attributes.Length > 0) name.Add(new PdfLine(attributes, Small: true)); + + rows.Add([ + new PdfCell(name), + PdfCell.Of((priceIncludesTax ? item.UnitPriceInclTax : item.UnitPriceExclTax).ToString("N2")), + PdfCell.Of(item.Quantity.ToString()), + PdfCell.Of((priceIncludesTax ? item.PriceInclTax : item.PriceExclTax).ToString("N2"), true) + ]); + } + + pdf.Table( + [ + new PdfColumn(6), new PdfColumn(2, PdfAlign.Right), new PdfColumn(1.2, PdfAlign.Right), + new PdfColumn(2, PdfAlign.Right) + ], + [ + _translationService.GetResource("PDFInvoice.ProductName", lang), + _translationService.GetResource("PDFInvoice.ProductPrice", lang), + _translationService.GetResource("PDFInvoice.ProductQuantity", lang), + _translationService.GetResource("PDFInvoice.ProductTotal", lang) + ], + rows); + + pdf.Space(10); + if (!limitByVendor) pdf.Text(PdfHtmlText.ToPlainText(order.CheckoutAttributeDescription), 9); + pdf.Table( + [new PdfColumn(5), new PdfColumn(3, PdfAlign.Right), new PdfColumn(2, PdfAlign.Right)], + null, + limitByVendor + ? VendorTotals(order, items, priceIncludesTax, lang) + : await Totals(order, priceIncludesTax, lang), + rowLines: false); + } + + /// + /// Totals of a vendor's own lines. Shipping, order discounts, gift vouchers and the payment fee belong to + /// the whole order and are not split between vendors, so they are left out. + /// + private List> VendorTotals(Order order, IList items, bool priceIncludesTax, + string lang) + { + var inclTax = items.Sum(x => x.PriceInclTax); + var exclTax = items.Sum(x => x.PriceExclTax); + var totals = new List> { + TotalRow(order, _translationService.GetResource("PDFInvoice.Sub-Total", lang), + priceIncludesTax ? inclTax : exclTax) + }; + if (inclTax - exclTax > 0) + totals.Add(TotalRow(order, _translationService.GetResource("PDFInvoice.Tax", lang), inclTax - exclTax)); + totals.Add(TotalRow(order, _translationService.GetResource("PDFInvoice.OrderTotal", lang), inclTax, true)); + return totals; + } + + private static IReadOnlyList TotalRow(Order order, string label, double amount, bool bold = false) + { + return [PdfCell.Empty, PdfCell.Of(label, bold), PdfCell.Of(Money(order, amount), bold)]; + } + + private async Task>> Totals(Order order, bool priceIncludesTax, string lang) + { + var totals = new List>(); + + void Add(string label, double amount, bool bold = false) + { + totals.Add(TotalRow(order, label, amount, bold)); + } + + Add(_translationService.GetResource("PDFInvoice.Sub-Total", lang), + priceIncludesTax ? order.OrderSubtotalInclTax : order.OrderSubtotalExclTax); + if (order.OrderSubTotalDiscountInclTax > 0 || order.OrderSubTotalDiscountExclTax > 0) + Add(_translationService.GetResource("PDFInvoice.Discount", lang), + priceIncludesTax ? order.OrderSubTotalDiscountInclTax : order.OrderSubTotalDiscountExclTax); + if (order.PaymentMethodAdditionalFeeInclTax > 0 || order.PaymentMethodAdditionalFeeExclTax > 0) + Add(_translationService.GetResource("PDFInvoice.PaymentmethodAdditionalFee", lang), + priceIncludesTax ? order.PaymentMethodAdditionalFeeInclTax : order.PaymentMethodAdditionalFeeExclTax); + Add(_translationService.GetResource("PDFInvoice.Shipping", lang), + priceIncludesTax ? order.OrderShippingInclTax : order.OrderShippingExclTax); + if (order.OrderDiscount > 0) + Add(_translationService.GetResource("PDFInvoice.Discount", lang), order.OrderDiscount); + + foreach (var usage in await _giftVoucherService.GetAllGiftVoucherUsageHistory(order.Id)) + { + if (usage.UsedValue <= 0) continue; + var giftVoucher = await _giftVoucherService.GetGiftVoucherById(usage.GiftVoucherId); + Add(Format("PDFInvoice.GiftVoucherInfo", lang, giftVoucher?.Code), usage.UsedValue); + } + + if (order.OrderTax > 0) + Add(_translationService.GetResource("PDFInvoice.Tax", lang), order.OrderTax); + Add(_translationService.GetResource("PDFInvoice.OrderTotal", lang), order.OrderTotal, true); + + return totals; + } + + private async Task ComposePackagingSlip(PdfComposer pdf, Shipment shipment) + { + var order = await _orderService.GetOrderById(shipment.OrderId); + if (order == null) return; + + var lang = order.CustomerLanguageId; + var store = await _storeService.GetStoreById(order.StoreId); + + pdf.NewPage(); + pdf.Text(Format("PDFPackagingSlip.Shipment", lang, shipment.ShipmentNumber), 14, true); + pdf.Text(store?.CompanyName); + pdf.Space(10); + + if (order.ShippingAddress != null) + { + var address = await AddressCell(order.ShippingAddress, "PDFPackagingSlip", + "PDFInvoice.ShippingInformation", lang, null); + foreach (var line in address.Lines) pdf.Text(line.Text, bold: line.Bold); + pdf.Space(6); + } + + pdf.Text(Format("PDFPackagingSlip.ShippingMethod", lang, order.ShippingMethod), bold: true); + pdf.Space(14); + pdf.Text(_translationService.GetResource("PDFInvoice.Product(s)", lang)); + pdf.Space(4); + + var rows = new List>(); + foreach (var item in shipment.ShipmentItems) + { + var orderItem = order.OrderItems.FirstOrDefault(x => x.Id == item.OrderItemId); + //a vendor's shipment carries only that vendor's lines + if (orderItem == null || + (!string.IsNullOrEmpty(shipment.VendorId) && orderItem.VendorId != shipment.VendorId)) + continue; + + var product = await _productService.GetProductByIdIncludeArch(item.ProductId); + var name = new List { new(product?.GetTranslation(x => x.Name, lang) ?? item.ProductId) }; + var attributes = PdfHtmlText.ToPlainText(orderItem.AttributeDescription); + if (attributes.Length > 0) name.Add(new PdfLine(attributes, Small: true)); + + rows.Add([new PdfCell(name), PdfCell.Of(product?.Sku), PdfCell.Of(item.Quantity.ToString())]); + } + + pdf.Table( + [new PdfColumn(6), new PdfColumn(2.5), new PdfColumn(1.2, PdfAlign.Right)], + [ + _translationService.GetResource("PDFPackagingSlip.ProductName", lang), + _translationService.GetResource("PDFPackagingSlip.SKU", lang), + _translationService.GetResource("PDFPackagingSlip.QTY", lang) + ], + rows); + } + + private async Task AddressCell(Address address, string resourcePrefix, string titleKey, string lang, + string closingLine, IEnumerable extraLines = null) + { + var lines = new List { new(_translationService.GetResource(titleKey, lang), true) }; + if (!string.IsNullOrEmpty(address.Company)) + lines.Add(new PdfLine(Format($"{resourcePrefix}.Company", lang, address.Company))); + lines.Add(new PdfLine(Format($"{resourcePrefix}.Name", lang, $"{address.FirstName} {address.LastName}"))); + lines.Add(new PdfLine(Format($"{resourcePrefix}.Phone", lang, address.PhoneNumber))); + lines.Add(new PdfLine(Format($"{resourcePrefix}.Address", lang, address.Address1))); + lines.Add(new PdfLine($"{address.City}, {address.ZipPostalCode}")); + var country = (await _countryService.GetCountryById(address.CountryId))?.Name; + if (!string.IsNullOrEmpty(country)) lines.Add(new PdfLine(country)); + if (!string.IsNullOrEmpty(closingLine)) + { + lines.Add(new PdfLine(string.Empty)); + lines.Add(new PdfLine(closingLine, true)); + } + + if (extraLines != null) + lines.AddRange(extraLines.Where(l => l.Length > 0).Select(l => new PdfLine(l))); + + return new PdfCell(lines); + } + + /// + /// Loads the PDF logo as PNG, the one raster format the composer embeds. Pictures are usually stored as + /// WebP, so anything else is converted. A logo that cannot be read is left out rather than failing the PDF. + /// + private async Task LoadLogo() + { + if (string.IsNullOrEmpty(_pdfSettings.LogoPictureId)) return null; + try + { + var picture = await _pictureService.GetPictureById(_pdfSettings.LogoPictureId); + if (picture == null) return null; + + var binary = await _pictureService.LoadPictureBinary(picture); + if (binary is not { Length: > 0 }) return null; + + var isPng = picture.MimeType?.Contains("png", StringComparison.OrdinalIgnoreCase) == true; + var png = isPng ? binary : _pictureService.ConvertPicture(binary, 100, "Png"); + //a PNG PdfPig cannot decode, e.g. interlaced, is written again as a plain one + if (isPng && !PdfComposer.CanEmbedPng(png)) png = _pictureService.ConvertPicture(binary, 100, "Png"); + if (PdfComposer.CanEmbedPng(png)) return png; + + _logger.LogWarning("PDF logo {PictureId} is not a PNG the PDF writer can embed", + _pdfSettings.LogoPictureId); + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PDF logo {PictureId} could not be loaded", _pdfSettings.LogoPictureId); + return null; + } + } + + private string Format(string resourceKey, string lang, object value) + { + return string.Format(_translationService.GetResource(resourceKey, lang), value); + } + + private static string Money(Order order, double amount) + { + return $"{amount:N2} {order.CustomerCurrencyCode}"; + } +} diff --git a/src/Business/Grand.Business.Common/Startup/StartupApplication.cs b/src/Business/Grand.Business.Common/Startup/StartupApplication.cs index 6e9b9c91fd..4f474d1b0e 100644 --- a/src/Business/Grand.Business.Common/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Common/Startup/StartupApplication.cs @@ -51,7 +51,7 @@ private static void RegisterCommonService(IServiceCollection serviceCollection) serviceCollection.AddScoped(); serviceCollection.AddScoped(); serviceCollection.AddScoped(); - serviceCollection.AddScoped(); + serviceCollection.AddScoped(); } private static void RegisterDirectoryService(IServiceCollection serviceCollection) diff --git a/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPdfPaymentLinesEvent.cs b/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPdfPaymentLinesEvent.cs new file mode 100644 index 0000000000..ffe45f9736 --- /dev/null +++ b/src/Business/Grand.Business.Core/Events/Checkout/Orders/OrderPdfPaymentLinesEvent.cs @@ -0,0 +1,29 @@ +using Grand.Domain.Orders; +using Grand.Mediator; + +namespace Grand.Business.Core.Events.Checkout.Orders; + +/// +/// Published while an order PDF is composed. Lines a handler adds are printed below the payment method, +/// where the pdf_payment_method_below widget zone used to render. +/// +public class OrderPdfPaymentLinesEvent : INotification +{ + public OrderPdfPaymentLinesEvent(Order order, string languageId) + { + Order = order; + LanguageId = languageId; + } + + public Order Order { get; } + + /// + /// Language the PDF is written in + /// + public string LanguageId { get; } + + /// + /// Plain text lines; markup is not rendered + /// + public IList Lines { get; } = new List(); +} diff --git a/src/Business/Grand.Business.Core/Interfaces/Common/Pdf/IViewRenderService.cs b/src/Business/Grand.Business.Core/Interfaces/Common/Pdf/IViewRenderService.cs deleted file mode 100644 index 5c65eea45f..0000000000 --- a/src/Business/Grand.Business.Core/Interfaces/Common/Pdf/IViewRenderService.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Grand.Business.Core.Interfaces.Common.Pdf; - -/// -/// Allow render razor view as string -/// -public interface IViewRenderService -{ - Task RenderToStringAsync(string viewPath, T model); -} \ No newline at end of file diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Pdf/HtmlToPdfServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Pdf/HtmlToPdfServiceTests.cs deleted file mode 100644 index b29e3f70bc..0000000000 --- a/src/Tests/Grand.Business.Common.Tests/Services/Pdf/HtmlToPdfServiceTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Grand.Business.Common.Services.Pdf; -using Grand.Business.Core.Interfaces.Common.Localization; -using Grand.Business.Core.Interfaces.Common.Pdf; -using Grand.Data; -using Grand.Domain.Localization; -using Grand.Domain.Media; -using Grand.Domain.Orders; -using Grand.Domain.Shipping; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; - -namespace Grand.Business.Common.Tests.Services.Pdf; - -[TestClass] -public class HtmlToPdfServiceTests -{ - private Mock _languageServiceMock; - private HtmlToPdfService _service; - private Mock _viewRenderServiceMock; - - [TestInitialize] - public void Init() - { - _viewRenderServiceMock = new Mock(); - _languageServiceMock = new Mock(); - _service = new HtmlToPdfService(_viewRenderServiceMock.Object, new Mock>().Object, - _languageServiceMock.Object, new Mock().Object, - new Mock().Object, new Mock>().Object); - } - - [TestMethod] - public async Task PrintOrdersToPdf_WhenRequestCancelled_DoesNotWriteThePdf() - { - //Arrange - _viewRenderServiceMock - .Setup(v => v.RenderToStringAsync(It.IsAny(), It.IsAny, string>>())) - .ReturnsAsync(""); - using var stream = new MemoryStream(); - using var cancellation = new CancellationTokenSource(); - await cancellation.CancelAsync(); - //Act & Assert - await Assert.ThrowsAsync(() => - _service.PrintOrdersToPdf(stream, [new Order()], cancellationToken: cancellation.Token)); - Assert.AreEqual(0, stream.Length); - } - - [TestMethod] - public async Task PrintPackagingSlipsToPdf_WhenRequestCancelled_DoesNotWriteThePdf() - { - //Arrange - _languageServiceMock.Setup(l => l.GetLanguageById("lang-1")).ReturnsAsync(new Language { Id = "lang-1" }); - _viewRenderServiceMock - .Setup(v => v.RenderToStringAsync(It.IsAny(), It.IsAny>())) - .ReturnsAsync(""); - using var stream = new MemoryStream(); - using var cancellation = new CancellationTokenSource(); - await cancellation.CancelAsync(); - //Act & Assert - await Assert.ThrowsAsync(() => - _service.PrintPackagingSlipsToPdf(stream, [new Shipment()], "lang-1", cancellation.Token)); - Assert.AreEqual(0, stream.Length); - } -} diff --git a/src/Tests/Grand.Business.Common.Tests/Services/Pdf/PdfServiceTests.cs b/src/Tests/Grand.Business.Common.Tests/Services/Pdf/PdfServiceTests.cs new file mode 100644 index 0000000000..97dc680fd7 --- /dev/null +++ b/src/Tests/Grand.Business.Common.Tests/Services/Pdf/PdfServiceTests.cs @@ -0,0 +1,470 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using Grand.Business.Common.Services.Pdf; +using Grand.Business.Core.Events.Checkout.Orders; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Checkout.GiftVouchers; +using Grand.Business.Core.Interfaces.Checkout.Orders; +using Grand.Business.Core.Interfaces.Checkout.Payments; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Pdf; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Storage; +using Grand.Data; +using Grand.Domain.Catalog; +using Grand.Domain.Common; +using Grand.Domain.Media; +using Grand.Domain.Orders; +using Grand.Domain.Shipping; +using Grand.Domain.Stores; +using Grand.Domain.Tax; +using Grand.Mediator; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using UglyToad.PdfPig; + +namespace Grand.Business.Common.Tests.Services.Pdf; + +[TestClass] +public class PdfServiceTests +{ + private Mock _mediatorMock; + private Mock _orderServiceMock; + private PdfSettings _pdfSettings; + private Mock _pictureServiceMock; + private Mock _productServiceMock; + private PdfService _service; + + [TestInitialize] + public void Init() + { + _orderServiceMock = new Mock(); + _productServiceMock = new Mock(); + _pictureServiceMock = new Mock(); + _pdfSettings = new PdfSettings(); + _mediatorMock = new Mock(); + + var translationServiceMock = new Mock(); + translationServiceMock + .Setup(t => t.GetResource(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns((string key, string _, string _, bool _) => key + " {0}"); + + var storeServiceMock = new Mock(); + storeServiceMock.Setup(s => s.GetStoreById(It.IsAny())) + .ReturnsAsync(new Store { CompanyName = "Sklep Łódź sp. z o.o." }); + + var giftVoucherServiceMock = new Mock(); + giftVoucherServiceMock.Setup(g => g.GetAllGiftVoucherUsageHistory(It.IsAny())) + .ReturnsAsync(new List()); + + _productServiceMock.Setup(p => p.GetProductByIdIncludeArch(It.IsAny())) + .ReturnsAsync((string id) => new Product { Id = id, Name = "Product " + id, Sku = "SKU-" + id }); + + _service = new PdfService(new Mock>().Object, translationServiceMock.Object, + new Mock().Object, new Mock().Object, + _productServiceMock.Object, new Mock().Object, + storeServiceMock.Object, _pictureServiceMock.Object, new Mock().Object, + giftVoucherServiceMock.Object, _orderServiceMock.Object, _pdfSettings, _mediatorMock.Object, + new Mock>().Object); + } + + [TestMethod] + public async Task PrintOrdersToPdf_WritesInvoiceWithPolishCharactersAndTotals() + { + //Arrange + var order = CreateOrder(); + //Act + var text = await PrintOrders([order]); + //Assert + StringAssert.Contains(text, "Sklep Łódź sp. z o.o."); + StringAssert.Contains(text, "Zażółć Gęślą"); + StringAssert.Contains(text, "Product p1"); + StringAssert.Contains(text, "Product p2"); + StringAssert.Contains(text, "PDFInvoice.OrderTotal"); + StringAssert.Contains(text, "PLN"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_DropsMarkupFromAttributeDescription() + { + //Arrange + var order = CreateOrder(); + order.OrderItems.First().AttributeDescription = "Color: Red
Size: M & L"; + //Act + var text = await PrintOrders([order]); + //Assert + StringAssert.Contains(text, "Color: Red"); + StringAssert.Contains(text, "Size: M & L"); + Assert.DoesNotContain(" m.Publish(It.IsAny(), It.IsAny())) + .Callback((OrderPdfPaymentLinesEvent e, CancellationToken _) => e.Lines.Add("Transfer title: 1001")) + .Returns(Task.CompletedTask); + //Act + var text = await PrintOrders([CreateOrder()]); + //Assert + StringAssert.Contains(text, "Transfer title: 1001"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_EmbedsTheLogoOnceForAllOrders() + { + //Arrange + const string onePixelPng = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + _pdfSettings.LogoPictureId = "logo"; + var logo = new Picture { Id = "logo", MimeType = "image/png" }; + _pictureServiceMock.Setup(p => p.GetPictureById("logo")).ReturnsAsync(logo); + _pictureServiceMock.Setup(p => p.LoadPictureBinary(logo)).ReturnsAsync(Convert.FromBase64String(onePixelPng)); + //Act + using var single = new MemoryStream(); + await _service.PrintOrdersToPdf(single, [CreateOrder()]); + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [CreateOrder(), CreateOrder(), CreateOrder()]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.AreEqual(3, pdf.NumberOfPages); + foreach (var page in pdf.GetPages()) + Assert.HasCount(1, page.GetImages()); + //an RGBA PNG is written as an image plus its alpha mask; three pages must not add more of them + Assert.AreEqual(ImageObjects(single), ImageObjects(stream)); + } + + private static int ImageObjects(MemoryStream pdf) + { + return Encoding.Latin1.GetString(pdf.ToArray()).Split("/Subtype /Image").Length - 1; + } + + [TestMethod] + public async Task PrintOrdersToPdf_ForVendor_PrintsOnlyVendorLinesAndTheirTotals() + { + //Arrange + var order = CreateOrder(); + order.OrderItems.Single(x => x.Id == "i1").PriceInclTax = 123; + order.OrderItems.Single(x => x.Id == "i2").PriceInclTax = 61.5; + order.CustomerTaxDisplayTypeId = TaxDisplayType.ExcludingTax; + //Act + var text = await PrintOrders([order], "vendor-1"); + //Assert + StringAssert.Contains(text, "Product p1"); + Assert.DoesNotContain("Product p2", text); + StringAssert.Contains(text, "PDFInvoice.Sub-Total {0}100.00 PLN"); + StringAssert.Contains(text, "PDFInvoice.Tax {0}23.00 PLN"); + StringAssert.Contains(text, "PDFInvoice.OrderTotal {0}123.00 PLN"); + //order-level amounts are not the vendor's + Assert.DoesNotContain("PDFInvoice.Shipping {0}", text); + Assert.DoesNotContain("202.95", text); + } + + [TestMethod] + public async Task PrintOrdersToPdf_VeryLongWord_IsBrokenInsideTheColumn() + { + //Arrange + var order = CreateOrder(); + order.OrderItems.First().AttributeDescription = new string('W', 5000); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [order]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + var letters = pdf.GetPages().SelectMany(p => p.Letters).Where(l => l.Value == "W").ToList(); + Assert.HasCount(5000, letters); + //the product name column ends well before the price column at the right edge of the page + Assert.IsTrue(letters.All(l => l.GlyphRectangle.Right < 595 - 40 - 150)); + } + + [TestMethod] + public async Task PrintOrdersToPdf_ReplacesCharactersMissingFromTheFont() + { + //Arrange + var order = CreateOrder(); + order.BillingAddress.FirstName = "中文"; + //Act + var text = await PrintOrders([order]); + //Assert + StringAssert.Contains(text, "?? Gęślą"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_StartsEveryOrderOnANewPageAndRepeatsTheFooter() + { + //Arrange + _pdfSettings.InvoiceFooterText = "

Footer & bank account

"; + var many = CreateOrder(); + for (var i = 3; i < 60; i++) + many.OrderItems.Add(new OrderItem { Id = "i" + i, ProductId = "p" + i, Quantity = 1 }); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [many, CreateOrder()]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.IsGreaterThan(2, pdf.NumberOfPages); + foreach (var page in pdf.GetPages()) + StringAssert.Contains(page.Text, "Footer & bank account"); + StringAssert.Contains(pdf.GetPage(pdf.NumberOfPages).Text, "Product p1"); + } + + [TestMethod] + public async Task PrintPackagingSlipsToPdf_ForVendorShipment_PrintsOnlyVendorLines() + { + //Arrange + var order = CreateOrder(); + _orderServiceMock.Setup(o => o.GetOrderById(order.Id)).ReturnsAsync(order); + var shipment = new Shipment { OrderId = order.Id, ShipmentNumber = 7, VendorId = "vendor-1" }; + shipment.ShipmentItems.Add(new ShipmentItem { OrderItemId = "i1", ProductId = "p1", Quantity = 2 }); + shipment.ShipmentItems.Add(new ShipmentItem { OrderItemId = "i2", ProductId = "p2", Quantity = 1 }); + //Act + using var stream = new MemoryStream(); + await _service.PrintPackagingSlipsToPdf(stream, [shipment], "lang-1"); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + var text = pdf.GetPage(1).Text; + StringAssert.Contains(text, "SKU-p1"); + Assert.DoesNotContain("Product p2", text); + StringAssert.Contains(text, "Zażółć Gęślą"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_WhenRequestCancelled_DoesNotWriteThePdf() + { + //Arrange + using var stream = new MemoryStream(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + //Act & Assert + await Assert.ThrowsAsync(() => + _service.PrintOrdersToPdf(stream, [CreateOrder()], cancellationToken: cancellation.Token)); + Assert.AreEqual(0, stream.Length); + } + + [TestMethod] + public async Task PrintPackagingSlipsToPdf_WhenRequestCancelled_DoesNotWriteThePdf() + { + //Arrange + using var stream = new MemoryStream(); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + //Act & Assert + await Assert.ThrowsAsync(() => + _service.PrintPackagingSlipsToPdf(stream, [new Shipment()], "lang-1", cancellation.Token)); + Assert.AreEqual(0, stream.Length); + } + + [TestMethod] + public async Task PrintOrdersToPdf_WithNoOrders_WritesOneEmptyPage() + { + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, []); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.AreEqual(1, pdf.NumberOfPages); + } + + [TestMethod] + public async Task PrintPackagingSlipsToPdf_WhenEveryOrderIsMissing_WritesOneEmptyPage() + { + //Act + using var stream = new MemoryStream(); + await _service.PrintPackagingSlipsToPdf(stream, [new Shipment { OrderId = "deleted" }]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.AreEqual(1, pdf.NumberOfPages); + } + + [TestMethod] + public async Task PrintOrdersToPdf_RowTallerThanAPage_IsSplitAcrossPagesAboveTheFooter() + { + //Arrange + _pdfSettings.InvoiceFooterText = "Footer"; + var order = CreateOrder(); + order.OrderItems.First().AttributeDescription = + string.Join("
", Enumerable.Range(1, 150).Select(i => $"Message line {i}")); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [order]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.IsGreaterThan(1, pdf.NumberOfPages); + var text = string.Join("\n", pdf.GetPages().Select(p => p.Text)); + StringAssert.Contains(text, "Message line 1"); + StringAssert.Contains(text, "Message line 150"); + StringAssert.Contains(text, "PDFInvoice.OrderTotal"); + foreach (var page in pdf.GetPages()) + { + var words = page.GetWords().ToList(); + var footerTop = words.Single(w => w.Text == "Footer").BoundingBox.Top; + Assert.IsTrue(words.Where(w => w.Text != "Footer").All(w => w.BoundingBox.Bottom > footerTop), + $"page {page.Number} has text over the footer"); + } + } + + [TestMethod] + public async Task PrintOrdersToPdf_FooterLongerThanThePage_IsCutAndKeepsOnePage() + { + //Arrange + _pdfSettings.InvoiceFooterText = + string.Join("
", Enumerable.Range(1, 200).Select(i => $"Footer line {i}")); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [CreateOrder()]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.AreEqual(1, pdf.NumberOfPages); + StringAssert.Contains(pdf.GetPage(1).Text, "PDFInvoice.OrderTotal"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_HeaderTextWrapsBesideTheLogo() + { + //Arrange + SetLogo(CreatePng(150, 60)); + _pdfSettings.InvoiceHeaderText = string.Join(" ", Enumerable.Repeat("Header", 80)); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [CreateOrder()]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + var page = pdf.GetPage(1); + var logo = page.GetImages().Single().BoundingBox; + var overLogo = page.Letters.Where(l => l.GlyphRectangle.Right > logo.Left && + l.GlyphRectangle.Top > logo.Bottom); + Assert.IsFalse(overLogo.Any(), "text is drawn over the logo"); + } + + [TestMethod] + public async Task PrintOrdersToPdf_LogoPdfPigCannotRead_IsConvertedInsteadOfFailingTheInvoice() + { + //Arrange + var broken = CreatePng(20, 20); + //corrupts the compressed image data while the PNG header stays readable + broken[40] ^= 0xFF; + broken[41] ^= 0xFF; + SetLogo(broken); + _pictureServiceMock.Setup(p => p.ConvertPicture(broken, 100, "Png")).Returns(CreatePng(20, 20)); + //Act + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, [CreateOrder()]); + //Assert + using var pdf = PdfDocument.Open(stream.ToArray()); + Assert.HasCount(1, pdf.GetPage(1).GetImages()); + } + + [TestMethod] + public async Task PrintOrdersToPdf_FooterTableCells_KeepASpaceAndHideStyles() + { + //Arrange + _pdfSettings.InvoiceFooterText = + "
Bank: PKOIBAN: PL61
"; + //Act + var text = await PrintOrders([CreateOrder()]); + //Assert + StringAssert.Contains(text, "PKO IBAN"); + Assert.DoesNotContain("color", text); + } + + private void SetLogo(byte[] png) + { + _pdfSettings.LogoPictureId = "logo"; + var logo = new Picture { Id = "logo", MimeType = "image/png" }; + _pictureServiceMock.Setup(p => p.GetPictureById("logo")).ReturnsAsync(logo); + _pictureServiceMock.Setup(p => p.LoadPictureBinary(logo)).ReturnsAsync(png); + } + + /// + /// A gray 8-bit RGB PNG of the given size. + /// + private static byte[] CreatePng(int width, int height) + { + using var raw = new MemoryStream(); + using (var zlib = new ZLibStream(raw, CompressionLevel.Optimal, true)) + { + var row = new byte[1 + width * 3]; + Array.Fill(row, (byte)128, 1, width * 3); + for (var y = 0; y < height; y++) zlib.Write(row); + } + + using var png = new MemoryStream(); + png.Write([0x89, (byte)'P', (byte)'N', (byte)'G', 0x0D, 0x0A, 0x1A, 0x0A]); + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header, width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height); + header[8] = 8; + header[9] = 2; + WriteChunk(png, "IHDR", header); + WriteChunk(png, "IDAT", raw.ToArray()); + WriteChunk(png, "IEND", []); + return png.ToArray(); + } + + private static void WriteChunk(Stream png, string type, byte[] data) + { + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, data.Length); + png.Write(length); + var typeAndData = Encoding.ASCII.GetBytes(type).Concat(data).ToArray(); + png.Write(typeAndData); + var crc = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(crc, Crc32(typeAndData)); + png.Write(crc); + } + + private static uint Crc32(byte[] data) + { + var crc = 0xFFFFFFFFu; + foreach (var b in data) + { + crc ^= b; + for (var k = 0; k < 8; k++) crc = (crc & 1) != 0 ? 0xEDB88320u ^ (crc >> 1) : crc >> 1; + } + + return ~crc; + } + + private async Task PrintOrders(IList orders, string vendorId = "") + { + using var stream = new MemoryStream(); + await _service.PrintOrdersToPdf(stream, orders, vendorId: vendorId); + using var pdf = PdfDocument.Open(stream.ToArray()); + return string.Join("\n", pdf.GetPages().Select(p => p.Text)); + } + + private static Order CreateOrder() + { + var address = new Address { + FirstName = "Zażółć", LastName = "Gęślą", City = "Łódź", ZipPostalCode = "90-001", + Address1 = "ul. Piotrkowska 1", PhoneNumber = "+48 600 000 000" + }; + var order = new Order { + Id = "order-1", + OrderNumber = 1001, + CustomerCurrencyCode = "PLN", + BillingAddress = address, + ShippingAddress = address, + ShippingMethod = "Kurier", + OrderSubtotalExclTax = 150, + OrderShippingExclTax = 15, + OrderTax = 37.95, + OrderTotal = 202.95 + }; + order.OrderItems.Add(new OrderItem { + Id = "i1", ProductId = "p1", VendorId = "vendor-1", Quantity = 2, UnitPriceExclTax = 50, + PriceExclTax = 100 + }); + order.OrderItems.Add(new OrderItem { + Id = "i2", ProductId = "p2", VendorId = "vendor-2", Quantity = 1, UnitPriceExclTax = 50, + PriceExclTax = 50 + }); + return order; + } +} diff --git a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj index 59d418e9a2..29d97d48e7 100644 --- a/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj +++ b/src/Web/Grand.Web.AdminShared/Grand.Web.AdminShared.csproj @@ -19,5 +19,9 @@ Microsoft.AspNetCore.Http 2.1.1 has a known high severity advisory (GHSA-hxrm-9w7p-39cc). This reference lifts it to a patched 2.x. Remove only together with elFinder. --> + +
diff --git a/src/Web/Grand.Web.Common/Startup/StartupApplication.cs b/src/Web/Grand.Web.Common/Startup/StartupApplication.cs index bd54a34771..df3b46d03a 100644 --- a/src/Web/Grand.Web.Common/Startup/StartupApplication.cs +++ b/src/Web/Grand.Web.Common/Startup/StartupApplication.cs @@ -1,4 +1,3 @@ -using Grand.Business.Core.Interfaces.Common.Pdf; using Grand.Business.Core.Interfaces.System.Admin; using Grand.Data; using Grand.Infrastructure; @@ -22,7 +21,6 @@ using Grand.Web.Common.Themes; using Grand.Web.Common.Validators; using Grand.Web.Common.View; -using Grand.Web.Common.ViewRender; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Caching.Memory; @@ -157,7 +155,5 @@ private static void RegisterFramework(IServiceCollection serviceCollection) //request reCAPTCHA service serviceCollection.AddHttpClient(client => client.Timeout = TimeSpan.FromSeconds(10)); - - serviceCollection.AddScoped(); } } \ No newline at end of file diff --git a/src/Web/Grand.Web.Common/ViewRender/ViewRenderService.cs b/src/Web/Grand.Web.Common/ViewRender/ViewRenderService.cs deleted file mode 100644 index 7659341961..0000000000 --- a/src/Web/Grand.Web.Common/ViewRender/ViewRenderService.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Grand.Business.Core.Interfaces.Common.Pdf; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.AspNetCore.Mvc.Razor; -using Microsoft.AspNetCore.Mvc.Rendering; -using Microsoft.AspNetCore.Mvc.ViewFeatures; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; - -namespace Grand.Web.Common.ViewRender; - -/// -/// Allow to get Razor page content as string -/// -public class ViewRenderService : IViewRenderService -{ - private readonly ILogger _logger; - private readonly IRazorViewEngine _razorViewEngine; - private readonly IServiceProvider _serviceProvider; - private readonly ITempDataProvider _tempDataProvider; - - - public ViewRenderService(IRazorViewEngine razorViewEngine, - ITempDataProvider tempDataProvider, - IServiceProvider serviceProvider, - ILogger logger) - { - _razorViewEngine = razorViewEngine; - _tempDataProvider = tempDataProvider; - _serviceProvider = serviceProvider; - _logger = logger; - } - - - public async Task RenderToStringAsync(string viewPath, TModel model) - { - var httpContext = new DefaultHttpContext { RequestServices = _serviceProvider }; - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - - await using var sw = new StringWriter(); - var viewResult = _razorViewEngine.GetView(viewPath, viewPath, false); - - ArgumentNullException.ThrowIfNull(viewResult.View); - - var viewDictionary = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) { - Model = model - }; - - var viewContext = new ViewContext( - actionContext, - viewResult.View, - viewDictionary, - new TempDataDictionary(actionContext.HttpContext, _tempDataProvider), - sw, - new HtmlHelperOptions() - ); - - try - { - await viewResult.View.RenderAsync(viewContext); - } - catch (Exception ex) - { - var partialHtmlLength = sw.GetStringBuilder().Length; - _logger.LogError(ex, "Error rendering view '{ViewPath}'. Partial HTML length: {PartialHtmlLength}", - viewPath, partialHtmlLength); - throw; - } - - return sw.ToString(); - } -} \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/OrderPdfTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/OrderPdfTemplate.cshtml deleted file mode 100644 index 1b644c3cf7..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/OrderPdfTemplate.cshtml +++ /dev/null @@ -1,330 +0,0 @@ -@using Grand.Business.Core.Extensions -@using Grand.Business.Core.Interfaces.Catalog.Products -@using Grand.Business.Core.Interfaces.Checkout.GiftVouchers -@using Grand.Business.Core.Interfaces.Checkout.Payments -@using Grand.Business.Core.Interfaces.Common.Directory -@using Grand.Business.Core.Interfaces.Common.Localization -@using Grand.Business.Core.Interfaces.Common.Stores -@using Grand.Business.Core.Interfaces.Storage -@using Grand.Domain.Tax -@model (IList, string) -@inject IProductService _productService -@inject ICountryService _countryService -@inject IStoreService _storeService -@inject IPictureService _pictureService -@inject PdfSettings _pdfSettings -@inject ITranslationService _translationService -@inject IPaymentService _paymentService -@inject IGiftVoucherService _giftVoucherService -@{ - var logoPicture = await _pictureService.GetPictureById(_pdfSettings.LogoPictureId); - var logoExists = logoPicture != null; - var limitbyVendor = !string.IsNullOrEmpty(Model.Item2); -} - - - - - - -
- @{ - var i = 0; - } - @foreach (var order in Model.Item1) - { - var priceIncludesTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; - var store = await _storeService.GetStoreById(order.StoreId); - if (!string.IsNullOrEmpty(_pdfSettings.InvoiceHeaderText)) - { -
@Html.Raw(_pdfSettings.InvoiceHeaderText)
- } - -

@store.CompanyName

- if (logoExists) - { - var url = await _pictureService.GetPictureUrl(logoPicture, 0, false, store.Url); -
- logo -
- } - -
@(string.Format(_translationService.GetResource("PDFInvoice.Order#", order.CustomerLanguageId), order.OrderNumber)) (@order.CreatedOnUtc)
- - - - - -
- @if (order.BillingAddress != null) - { - @(_translationService.GetResource("PDFInvoice.BillingInformation", order.CustomerLanguageId)) -
- @if (!string.IsNullOrEmpty(order.BillingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFInvoice.Company", order.CustomerLanguageId), order.BillingAddress.Company))
- } - - @(string.Format(_translationService.GetResource("PDFInvoice.Name", order.CustomerLanguageId), order.BillingAddress.FirstName + " " + order.BillingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFInvoice.Phone", order.CustomerLanguageId), order.BillingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFInvoice.Address", order.CustomerLanguageId), order.BillingAddress.Address1))
- @(order.BillingAddress.City + ", " + order.BillingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.BillingAddress.CountryId))?.Name)
-
- var paymentMethod = _paymentService.LoadPaymentMethodBySystemName(order.PaymentMethodSystemName); - var paymentMethodStr = paymentMethod.GetPaymentMethodName(order); - @(string.Format(_translationService.GetResource("PDFInvoice.PaymentMethod", order.CustomerLanguageId), paymentMethodStr)) -
- @await Component.InvokeAsync("Widget", new { widgetZone = "pdf_payment_method_below", additionalData = order.Id }) - } -
- @if (order.ShippingAddress != null) - { - @(_translationService.GetResource("PDFInvoice.ShippingInformation", order.CustomerLanguageId)) -
- @if (!string.IsNullOrEmpty(order.ShippingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFInvoice.Company", order.CustomerLanguageId), order.ShippingAddress.Company))
- } - - @(string.Format(_translationService.GetResource("PDFInvoice.Name", order.CustomerLanguageId), order.ShippingAddress.FirstName + " " + order.ShippingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFInvoice.Phone", order.CustomerLanguageId), order.ShippingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFInvoice.Address", order.CustomerLanguageId), order.ShippingAddress.Address1))
- @(order.ShippingAddress.City + ", " + order.ShippingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.ShippingAddress.CountryId))?.Name)
-
- @(string.Format(_translationService.GetResource("PDFInvoice.ShippingMethod", order.CustomerLanguageId), order.ShippingMethod)) -
- } -
- -
- @(_translationService.GetResource("PDFInvoice.Product(s)", order.CustomerLanguageId)) -
- - - - - - - - @foreach (var item in order.OrderItems.Where(x => x.VendorId == Model.Item2 || !limitbyVendor).ToList()) - { - - - - - - - } -
@(_translationService.GetResource("PDFInvoice.ProductName", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductPrice", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductQuantity", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductTotal", order.CustomerLanguageId))
- @((await _productService.GetProductByIdIncludeArch(item.ProductId)).Name) - @if (!string.IsNullOrEmpty(item.AttributeDescription)) - { -
- @(Html.Raw(item.AttributeDescription)) - } -
- @if (priceIncludesTax) - { - @item.UnitPriceInclTax.ToString("N2") - } - else - { - @item.UnitPriceExclTax.ToString("N2") - } - @item.Quantity - @if (priceIncludesTax) - { - @item.PriceInclTax.ToString("N2") - } - else - { - @item.PriceExclTax.ToString("N2") - } -
- @if (!limitbyVendor) - { -
- @if (!string.IsNullOrEmpty(order.CheckoutAttributeDescription)) - { - @(Html.Raw(order.CheckoutAttributeDescription)) - } -
- - @(_translationService.GetResource("PDFInvoice.Sub-Total", order.CustomerLanguageId)) - @if (priceIncludesTax) - { - @order.OrderSubtotalInclTax.ToString("N2") - } - else - { - @order.OrderSubtotalExclTax.ToString("N2") - } - @order.CustomerCurrencyCode
- @if (order.OrderSubTotalDiscountInclTax > 0 || order.OrderSubTotalDiscountExclTax > 0) - { - @(_translationService.GetResource("PDFInvoice.Discount", order.CustomerLanguageId)) @if (priceIncludesTax) - { - @order.OrderSubTotalDiscountInclTax.ToString("N2") - } - else - { - @order.OrderSubTotalDiscountExclTax.ToString("N2") - } - - @order.CustomerCurrencyCode - } - @if (order.PaymentMethodAdditionalFeeInclTax > 0 || order.PaymentMethodAdditionalFeeExclTax > 0) - { - @(_translationService.GetResource("PDFInvoice.PaymentmethodAdditionalFee", order.CustomerLanguageId)) @if (priceIncludesTax) - { - @order.PaymentMethodAdditionalFeeInclTax.ToString("N2") - } - else - { - @order.PaymentMethodAdditionalFeeExclTax.ToString("N2") - } - - @order.CustomerCurrencyCode
- } - @(_translationService.GetResource("PDFInvoice.Shipping", order.CustomerLanguageId)) - @if (priceIncludesTax) - { - @order.OrderShippingInclTax.ToString("N2") - } - else - { - @order.OrderShippingExclTax.ToString("N2") - } - @order.CustomerCurrencyCode
- @if (order.OrderDiscount > 0) - { - @(_translationService.GetResource("PDFInvoice.Discount", order.CustomerLanguageId)) - @order.OrderDiscount.ToString("N2") - @order.CustomerCurrencyCode
- } - @foreach (var gcuh in await _giftVoucherService.GetAllGiftVoucherUsageHistory(order.Id)) - { - if (gcuh.UsedValue > 0) - { - var giftvoucher = await _giftVoucherService.GetGiftVoucherById(gcuh.GiftVoucherId); - @(string.Format(_translationService.GetResource("PDFInvoice.GiftVoucherInfo", order.CustomerLanguageId), giftvoucher.Code)) - @gcuh.UsedValue
- } - } - @if (order.OrderTax > 0) - { - @(_translationService.GetResource("PDFInvoice.Tax", order.CustomerLanguageId)) - @order.OrderTax.ToString("N2") - @order.CustomerCurrencyCode
- } - - @(_translationService.GetResource("PDFInvoice.OrderTotal", order.CustomerLanguageId)) @order.OrderTotal.ToString("N2") @order.CustomerCurrencyCode
-
- -
-
- } - - if (!string.IsNullOrEmpty(_pdfSettings.InvoiceFooterText)) - { -
- @Html.Raw(_pdfSettings.InvoiceFooterText) -
- } - - if (Model.Item1.Count - 1 > i) - { -
- } - - i++; - } -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/ShipmentPdfTemplate.cshtml b/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/ShipmentPdfTemplate.cshtml deleted file mode 100644 index 7e1f5bc818..0000000000 --- a/src/Web/Grand.Web.Vendor/Areas/Vendor/Views/PdfTemplates/ShipmentPdfTemplate.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@using Grand.Business.Core.Interfaces.Catalog.Products -@using Grand.Business.Core.Interfaces.Checkout.Orders -@using Grand.Business.Core.Interfaces.Common.Directory -@using Grand.Business.Core.Interfaces.Common.Localization -@model IList -@inject IOrderService _orderService -@inject IProductService _productService -@inject ICountryService _countryService -@inject IContextAccessor _contextAccessor -@inject ITranslationService _translationService - -@{ - var companyName = _contextAccessor.StoreContext.CurrentStore.CompanyName; - var limitbyVendor = _contextAccessor.WorkContext.CurrentVendor != null; -} - - - - - - -
- @for (var i = 0; i < Model.Count; i++) - { - var order = await _orderService.GetOrderById(Model[i].OrderId); - var shipment = Model[i]; -
-

@(string.Format(_translationService.GetResource("PDFPackagingSlip.Shipment", order.CustomerLanguageId), shipment.ShipmentNumber))

-
@companyName
-
- @if (order.ShippingAddress != null) - { -
-
@(_translationService.GetResource("PDFInvoice.ShippingInformation", order.CustomerLanguageId))
- @if (!string.IsNullOrEmpty(order.ShippingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFPackagingSlip.Company", order.CustomerLanguageId), order.ShippingAddress.Company))
- } - @(string.Format(_translationService.GetResource("PDFPackagingSlip.Name", order.CustomerLanguageId), order.ShippingAddress.FirstName + " " + order.ShippingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.Phone", order.CustomerLanguageId), order.ShippingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.Address", order.CustomerLanguageId), order.ShippingAddress.Address1))
- @(order.ShippingAddress.City + ", " + order.ShippingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.ShippingAddress.CountryId))?.Name)
-
- } - -
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.ShippingMethod", order.CustomerLanguageId), order.ShippingMethod)) -
-
- @(_translationService.GetResource("PDFInvoice.Product(s)", order.CustomerLanguageId)) -
- - - - - - - @foreach (var item in shipment.ShipmentItems) - { - var orderItem = order.OrderItems.FirstOrDefault(x => x.Id == item.OrderItemId); - if (orderItem != null && (orderItem.VendorId == _contextAccessor.WorkContext.CurrentVendor?.Id || !limitbyVendor)) - { - - - - - - - } - } -
@(_translationService.GetResource("PDFPackagingSlip.ProductName", order.CustomerLanguageId))@(_translationService.GetResource("PDFPackagingSlip.SKU", order.CustomerLanguageId))@(_translationService.GetResource("PDFPackagingSlip.QTY", order.CustomerLanguageId))
- @((await _productService.GetProductByIdIncludeArch(item.ProductId)).Name) - @if (!string.IsNullOrEmpty(orderItem.AttributeDescription)) - { -
- @(Html.Raw(orderItem.AttributeDescription)) - } -
@((await _productService.GetProductByIdIncludeArch(item.ProductId)).Sku)@item.Quantity
- } -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web/Views/PdfTemplates/OrderPdfTemplate.cshtml b/src/Web/Grand.Web/Views/PdfTemplates/OrderPdfTemplate.cshtml deleted file mode 100644 index 6b42eb2305..0000000000 --- a/src/Web/Grand.Web/Views/PdfTemplates/OrderPdfTemplate.cshtml +++ /dev/null @@ -1,330 +0,0 @@ -@using Grand.Business.Core.Extensions -@using Grand.Business.Core.Interfaces.Catalog.Products -@using Grand.Business.Core.Interfaces.Checkout.GiftVouchers -@using Grand.Business.Core.Interfaces.Checkout.Payments -@using Grand.Business.Core.Interfaces.Common.Directory -@using Grand.Business.Core.Interfaces.Common.Localization -@using Grand.Business.Core.Interfaces.Common.Stores -@using Grand.Business.Core.Interfaces.Storage -@model (IList, string) -@inject IProductService _productService -@inject ICountryService _countryService -@inject IStoreService _storeService -@inject IPictureService _pictureService -@inject PdfSettings _pdfSettings -@inject ITranslationService _translationService -@inject IPaymentService _paymentService -@inject ICurrencyService _currencyService -@inject IGiftVoucherService _giftVoucherService -@{ - var logoPicture = await _pictureService.GetPictureById(_pdfSettings.LogoPictureId); - var logoExists = logoPicture != null; - var limitbyVendor = !string.IsNullOrEmpty(Model.Item2); -} - - - - - - -
- @{ - var i = 0; - } - @foreach (var order in Model.Item1) - { - var priceIncludesTax = order.CustomerTaxDisplayTypeId == TaxDisplayType.IncludingTax; - var store = await _storeService.GetStoreById(order.StoreId); - if (!string.IsNullOrEmpty(_pdfSettings.InvoiceHeaderText)) - { -
@Html.Raw(_pdfSettings.InvoiceHeaderText)
- } - -

@store.CompanyName

- if (logoExists) - { - var url = await _pictureService.GetPictureUrl(logoPicture, 0, false, store.Url); -
- logo -
- } - -
@(string.Format(_translationService.GetResource("PDFInvoice.Order#", order.CustomerLanguageId), order.OrderNumber)) (@order.CreatedOnUtc)
- - - - - -
- @if (order.BillingAddress != null) - { - @(_translationService.GetResource("PDFInvoice.BillingInformation", order.CustomerLanguageId)) -
- @if (!string.IsNullOrEmpty(order.BillingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFInvoice.Company", order.CustomerLanguageId), order.BillingAddress.Company))
- } - - @(string.Format(_translationService.GetResource("PDFInvoice.Name", order.CustomerLanguageId), order.BillingAddress.FirstName + " " + order.BillingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFInvoice.Phone", order.CustomerLanguageId), order.BillingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFInvoice.Address", order.CustomerLanguageId), order.BillingAddress.Address1))
- @(order.BillingAddress.City + ", " + order.BillingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.BillingAddress.CountryId))?.Name)
-
- var paymentMethod = _paymentService.LoadPaymentMethodBySystemName(order.PaymentMethodSystemName); - var paymentMethodStr = paymentMethod.GetPaymentMethodName(order); - @(string.Format(_translationService.GetResource("PDFInvoice.PaymentMethod", order.CustomerLanguageId), paymentMethodStr)) -
- @await Component.InvokeAsync("Widget", new { widgetZone = "pdf_payment_method_below", additionalData = order.Id }) - } -
- @if (order.ShippingAddress != null) - { - @(_translationService.GetResource("PDFInvoice.ShippingInformation", order.CustomerLanguageId)) -
- @if (!string.IsNullOrEmpty(order.ShippingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFInvoice.Company", order.CustomerLanguageId), order.ShippingAddress.Company))
- } - - @(string.Format(_translationService.GetResource("PDFInvoice.Name", order.CustomerLanguageId), order.ShippingAddress.FirstName + " " + order.ShippingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFInvoice.Phone", order.CustomerLanguageId), order.ShippingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFInvoice.Address", order.CustomerLanguageId), order.ShippingAddress.Address1))
- @(order.ShippingAddress.City + ", " + order.ShippingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.ShippingAddress.CountryId))?.Name)
-
- @(string.Format(_translationService.GetResource("PDFInvoice.ShippingMethod", order.CustomerLanguageId), order.ShippingMethod)) -
- } -
- -
- @(_translationService.GetResource("PDFInvoice.Product(s)", order.CustomerLanguageId)) -
- - - - - - - - @foreach (var item in order.OrderItems.Where(x => x.VendorId == Model.Item2 || !limitbyVendor).ToList()) - { - - - - - - - } -
@(_translationService.GetResource("PDFInvoice.ProductName", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductPrice", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductQuantity", order.CustomerLanguageId))@(_translationService.GetResource("PDFInvoice.ProductTotal", order.CustomerLanguageId))
- @((await _productService.GetProductByIdIncludeArch(item.ProductId)).Name) - @if (!string.IsNullOrEmpty(item.AttributeDescription)) - { -
- @(Html.Raw(item.AttributeDescription)) - } -
- @if (priceIncludesTax) - { - @item.UnitPriceInclTax.ToString("N2") - } - else - { - @item.UnitPriceExclTax.ToString("N2") - } - @item.Quantity - @if (priceIncludesTax) - { - @item.PriceInclTax.ToString("N2") - } - else - { - @item.PriceExclTax.ToString("N2") - } -
- @if (!limitbyVendor) - { -
- @if (!string.IsNullOrEmpty(order.CheckoutAttributeDescription)) - { - @(Html.Raw(order.CheckoutAttributeDescription)) - } -
- - @(_translationService.GetResource("PDFInvoice.Sub-Total", order.CustomerLanguageId)) - @if (priceIncludesTax) - { - @order.OrderSubtotalInclTax.ToString("N2") - } - else - { - @order.OrderSubtotalExclTax.ToString("N2") - } - @order.CustomerCurrencyCode
- @if (order.OrderSubTotalDiscountInclTax > 0 || order.OrderSubTotalDiscountExclTax > 0) - { - @(_translationService.GetResource("PDFInvoice.Discount", order.CustomerLanguageId)) @if (priceIncludesTax) - { - @order.OrderSubTotalDiscountInclTax.ToString("N2") - } - else - { - @order.OrderSubTotalDiscountExclTax.ToString("N2") - } - - @order.CustomerCurrencyCode - } - @if (order.PaymentMethodAdditionalFeeInclTax > 0 || order.PaymentMethodAdditionalFeeExclTax > 0) - { - @(_translationService.GetResource("PDFInvoice.PaymentmethodAdditionalFee", order.CustomerLanguageId)) @if (priceIncludesTax) - { - @order.PaymentMethodAdditionalFeeInclTax.ToString("N2") - } - else - { - @order.PaymentMethodAdditionalFeeExclTax.ToString("N2") - } - - @order.CustomerCurrencyCode
- } - @(_translationService.GetResource("PDFInvoice.Shipping", order.CustomerLanguageId)) - @if (priceIncludesTax) - { - @order.OrderShippingInclTax.ToString("N2") - } - else - { - @order.OrderShippingExclTax.ToString("N2") - } - @order.CustomerCurrencyCode
- @if (order.OrderDiscount > 0) - { - @(_translationService.GetResource("PDFInvoice.Discount", order.CustomerLanguageId)) - @order.OrderDiscount.ToString("N2") - @order.CustomerCurrencyCode
- } - @foreach (var gcuh in await _giftVoucherService.GetAllGiftVoucherUsageHistory(order.Id)) - { - if (gcuh.UsedValue > 0) - { - var giftvoucher = await _giftVoucherService.GetGiftVoucherById(gcuh.GiftVoucherId); - @(string.Format(_translationService.GetResource("PDFInvoice.GiftVoucherInfo", order.CustomerLanguageId), giftvoucher.Code)) - @gcuh.UsedValue
- } - } - @if (order.OrderTax > 0) - { - @(_translationService.GetResource("PDFInvoice.Tax", order.CustomerLanguageId)) - @order.OrderTax.ToString("N2") - @order.CustomerCurrencyCode
- } - - @(_translationService.GetResource("PDFInvoice.OrderTotal", order.CustomerLanguageId)) @order.OrderTotal.ToString("N2") @order.CustomerCurrencyCode
-
- -
-
- } - - if (!string.IsNullOrEmpty(_pdfSettings.InvoiceFooterText)) - { -
- @Html.Raw(_pdfSettings.InvoiceFooterText) -
- } - - if (Model.Item1.Count - 1 > i) - { -
- } - - i++; - } -
- - \ No newline at end of file diff --git a/src/Web/Grand.Web/Views/PdfTemplates/ShipmentPdfTemplate.cshtml b/src/Web/Grand.Web/Views/PdfTemplates/ShipmentPdfTemplate.cshtml deleted file mode 100644 index 7e1f5bc818..0000000000 --- a/src/Web/Grand.Web/Views/PdfTemplates/ShipmentPdfTemplate.cshtml +++ /dev/null @@ -1,142 +0,0 @@ -@using Grand.Business.Core.Interfaces.Catalog.Products -@using Grand.Business.Core.Interfaces.Checkout.Orders -@using Grand.Business.Core.Interfaces.Common.Directory -@using Grand.Business.Core.Interfaces.Common.Localization -@model IList -@inject IOrderService _orderService -@inject IProductService _productService -@inject ICountryService _countryService -@inject IContextAccessor _contextAccessor -@inject ITranslationService _translationService - -@{ - var companyName = _contextAccessor.StoreContext.CurrentStore.CompanyName; - var limitbyVendor = _contextAccessor.WorkContext.CurrentVendor != null; -} - - - - - - -
- @for (var i = 0; i < Model.Count; i++) - { - var order = await _orderService.GetOrderById(Model[i].OrderId); - var shipment = Model[i]; -
-

@(string.Format(_translationService.GetResource("PDFPackagingSlip.Shipment", order.CustomerLanguageId), shipment.ShipmentNumber))

-
@companyName
-
- @if (order.ShippingAddress != null) - { -
-
@(_translationService.GetResource("PDFInvoice.ShippingInformation", order.CustomerLanguageId))
- @if (!string.IsNullOrEmpty(order.ShippingAddress.Company)) - { - @(string.Format(_translationService.GetResource("PDFPackagingSlip.Company", order.CustomerLanguageId), order.ShippingAddress.Company))
- } - @(string.Format(_translationService.GetResource("PDFPackagingSlip.Name", order.CustomerLanguageId), order.ShippingAddress.FirstName + " " + order.ShippingAddress.LastName))
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.Phone", order.CustomerLanguageId), order.ShippingAddress.PhoneNumber))
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.Address", order.CustomerLanguageId), order.ShippingAddress.Address1))
- @(order.ShippingAddress.City + ", " + order.ShippingAddress.ZipPostalCode)
- @((await _countryService.GetCountryById(order.ShippingAddress.CountryId))?.Name)
-
- } - -
- @(string.Format(_translationService.GetResource("PDFPackagingSlip.ShippingMethod", order.CustomerLanguageId), order.ShippingMethod)) -
-
- @(_translationService.GetResource("PDFInvoice.Product(s)", order.CustomerLanguageId)) -
- - - - - - - @foreach (var item in shipment.ShipmentItems) - { - var orderItem = order.OrderItems.FirstOrDefault(x => x.Id == item.OrderItemId); - if (orderItem != null && (orderItem.VendorId == _contextAccessor.WorkContext.CurrentVendor?.Id || !limitbyVendor)) - { - - - - - - - } - } -
@(_translationService.GetResource("PDFPackagingSlip.ProductName", order.CustomerLanguageId))@(_translationService.GetResource("PDFPackagingSlip.SKU", order.CustomerLanguageId))@(_translationService.GetResource("PDFPackagingSlip.QTY", order.CustomerLanguageId))
- @((await _productService.GetProductByIdIncludeArch(item.ProductId)).Name) - @if (!string.IsNullOrEmpty(orderItem.AttributeDescription)) - { -
- @(Html.Raw(orderItem.AttributeDescription)) - } -
@((await _productService.GetProductByIdIncludeArch(item.ProductId)).Sku)@item.Quantity
- } -
- - \ No newline at end of file