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();
+ }
+}