diff --git a/OpenNest.Reporting/NestPdfWriter.cs b/OpenNest.Reporting/NestPdfWriter.cs index 1de7df5..120c9b6 100644 --- a/OpenNest.Reporting/NestPdfWriter.cs +++ b/OpenNest.Reporting/NestPdfWriter.cs @@ -26,6 +26,11 @@ public static class NestPdfWriter private static readonly XColor Fill = XColor.FromArgb(222, 222, 222); private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + // PDFsharp/MigraDoc layout and font state is process-wide and not safe for concurrent + // documents: parallel exports laid out text differently. Reports are rare, so serialize them. + private static readonly object RenderLock = new(); + + /// Thread-safe; concurrent calls are serialized. public static void Write(NestReportSnapshot snapshot, string destination) => Write(snapshot, destination, null); @@ -34,6 +39,12 @@ public static class NestPdfWriter { ArgumentNullException.ThrowIfNull(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(destination); + lock (RenderLock) + WriteSerialized(snapshot, destination, wrapOutput); + } + + private static void WriteSerialized(NestReportSnapshot snapshot, string destination, Func? wrapOutput) + { ValidateText(snapshot); if (snapshot.Plates.Length > 1) throw new NotSupportedException($"A report with {snapshot.Plates.Length} plate layouts is not supported by this report slice; it supports at most one layout."); diff --git a/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs b/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs new file mode 100644 index 0000000..4235447 --- /dev/null +++ b/OpenNest.Tests/Reporting/NestPdfWriterConcurrencyTests.cs @@ -0,0 +1,56 @@ +using System.Collections.Concurrent; +using System.Text; +using OpenNest.Reporting; +using PdfSharp.Pdf.IO; + +namespace OpenNest.Tests.Reporting; + +public sealed class NestPdfWriterConcurrencyTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-concurrency-" + Guid.NewGuid().ToString("N")); + + public NestPdfWriterConcurrencyTests() => Directory.CreateDirectory(directory); + + public void Dispose() => Directory.Delete(directory, true); + + [Fact] + public void Write_ConcurrentExportsProduceTheSameLayoutAsASequentialExport() + { + // PDFsharp/MigraDoc layout shares process-wide font state; unsynchronized concurrent + // exports produced shifted/merged words (for example "Total physicalsheets"). + var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt); + var reference = Path.Combine(directory, "reference.pdf"); + NestPdfWriter.Write(snapshot, reference); + var expected = PageContent(reference); + var mismatches = new ConcurrentBag(); + + Parallel.For(0, 128, new ParallelOptions { MaxDegreeOfParallelism = System.Math.Max(8, Environment.ProcessorCount) }, index => + { + var path = Path.Combine(directory, $"export-{index}.pdf"); + NestPdfWriter.Write(snapshot, path); + if (PageContent(path) != expected) + mismatches.Add(index); + }); + + Assert.Empty(mismatches); + } + + /// Page content streams only: document IDs and font-subset tags vary per export. + private static string PageContent(string path) + { + using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var text = new StringBuilder(); + foreach (var page in pdf.Pages) + { + foreach (var item in page.Contents.Elements) + { + var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfSharp.Pdf.PdfDictionary + ?? item as PdfSharp.Pdf.PdfDictionary; + if (dictionary?.Stream != null) + text.Append(Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue)); + } + text.Append('\f'); + } + return text.ToString(); + } +}