using DebenuPDFLibraryDLL1013; namespace EnvelopeRenderer.Cli.Render; /// /// Wraps Debenu Quick PDF Library 10.13 (loaded dynamically from via /// its own LoadLibrary/GetProcAddress interop — see EnvelopeRenderer.Debenu). Every DPL* call in /// this library returns 0 on failure by convention (inferred from the vendor wrapper's own /// `if (dll == null) return 0;` fallback on every method — there's no documented error-text /// lookup beyond a numeric ), so every call here is /// checked and turned into a descriptive error instead of failing silently. /// /// Batching mitigation (Sprint 3): Debenu's in-memory document model gets /// progressively more expensive to append to as more pages accumulate in a single open /// document — confirmed in code/BENCHMARK.md's root-cause investigation (a scaled-down /// probe showed throughput falling from ~550 pages/sec to ~13-15 pages/sec within a few thousand /// pages of a single document, and resetting back to ~550/s immediately after a fresh /// instance was created). To work around this without changing any /// observable behavior for small/typical renders, this renderer periodically saves the /// in-progress document to its own temporary file every pages, /// releases the Debenu instance, and opens a brand-new one for the next batch of pages. If more /// than one batch was ever created, merges every batch file into the final /// output via Debenu's own MergeFileListFast file-list merge API — a real Debenu /// operation, not a byte-level PDF concatenation implemented in this repo. If only one batch was /// ever needed (the common case for renders under pages), falls back to the original single SaveToFile call with no temp files and /// no merge step at all, so small renders are byte-for-byte unaffected by this change. /// public sealed class DebenuPdfRenderer : IPdfRenderer { /// /// Default number of pages added to a single underlying Debenu document before it is saved /// to a temporary batch file and a fresh document is opened for the next batch. Chosen from /// real measurements in code/BENCHMARK.md ("Batch size selection"): small enough to /// keep sustained throughput close to the ~400-550 pages/sec "fresh document" rate (a batch /// size of 1,000+ let the per-page cost climb enough to meaningfully hurt throughput again), /// large enough that the extra Debenu overhead per batch — a full font re-embed each time a /// document is reopened, observed at roughly +1 MB per extra batch for this template's single /// TrueType font — stays a modest fraction of total output size at the product's 100,000-record /// target scale rather than one closer to it (see the file-size-vs-throughput trade-off table /// in code/BENCHMARK.md). /// public const int DefaultPagesPerBatch = 300; private readonly string _dllPath; private readonly string? _licenseKey; private readonly int _pagesPerBatch; private readonly List _batchFilePaths = new(); private readonly string _batchTempDirectory; private PDFLibrary _pdf; private readonly Dictionary _fontHandles = new(StringComparer.OrdinalIgnoreCase); private int _pagesInCurrentBatch; private bool _disposed; // A freshly-created Debenu document already has one page (verified: PageCount() == 1 right // after construction, at the library's default Letter size). Calling NewPage() before the // first record would leave a spurious blank page 1 in front of every render, so the first // AddPage call (of the whole render, and again of every subsequent batch after a // release/reopen) reuses the document's existing page instead of creating a new one. private bool _firstPageUsed; private DebenuPdfRenderer(string dllPath, string? licenseKey, PDFLibrary pdf, int pagesPerBatch) { _dllPath = dllPath; _licenseKey = licenseKey; _pdf = pdf; _pagesPerBatch = pagesPerBatch; _batchTempDirectory = Path.Combine(Path.GetTempPath(), $"EnvelopeRenderer-render-{Guid.NewGuid():N}"); } public static bool TryCreate( string dllPath, string? licenseKey, out DebenuPdfRenderer? renderer, out string? error) => TryCreate(dllPath, licenseKey, DefaultPagesPerBatch, out renderer, out error); /// Overload allowing tests (and future tuning) to pick a batch size other than /// without changing production behavior. public static bool TryCreate( string dllPath, string? licenseKey, int pagesPerBatch, out DebenuPdfRenderer? renderer, out string? error) { if (pagesPerBatch <= 0) { throw new ArgumentOutOfRangeException(nameof(pagesPerBatch), "Pages per batch must be positive."); } if (!TryOpenLibrary(dllPath, licenseKey, out var pdf, out error)) { renderer = null; return false; } renderer = new DebenuPdfRenderer(dllPath, licenseKey, pdf!, pagesPerBatch); error = null; return true; } private static bool TryOpenLibrary( string dllPath, string? licenseKey, out PDFLibrary? pdf, out string? error) { var opened = new PDFLibrary(dllPath); if (!opened.LibraryLoaded()) { pdf = null; error = $"Could not load Debenu Quick PDF Library from '{dllPath}'."; return false; } if (!string.IsNullOrEmpty(licenseKey)) { opened.UnlockKey(licenseKey); } pdf = opened; error = null; return true; } public bool AddPage(double pageWidth, double pageHeight, IReadOnlyList draws, out string? error) { if (_firstPageUsed && _pagesInCurrentBatch >= _pagesPerBatch) { if (!FlushCurrentBatchAndReopen(out error)) { return false; } } if (_firstPageUsed) { if (_pdf.NewPage() == 0) { error = $"Failed to start a new page (error code {_pdf.LastErrorCode()})."; return false; } } else { _firstPageUsed = true; } if (_pdf.SetPageDimensions(pageWidth, pageHeight) == 0) { error = $"Failed to set page size to {pageWidth} x {pageHeight} points " + $"(error code {_pdf.LastErrorCode()})."; return false; } if (_pdf.SetFillColor(0, 0, 0) == 0) { error = $"Failed to set text fill color (error code {_pdf.LastErrorCode()})."; return false; } foreach (var draw in draws) { if (string.IsNullOrEmpty(draw.Text)) { continue; } if (!TryGetFontHandle(draw.FontName, out var fontHandle, out error)) { return false; } if (_pdf.SelectFont(fontHandle) == 0) { error = $"Failed to select font '{draw.FontName}' (error code {_pdf.LastErrorCode()})."; return false; } if (_pdf.SetTextSize(draw.Size) == 0) { error = $"Failed to set text size {draw.Size} for font '{draw.FontName}' " + $"(error code {_pdf.LastErrorCode()})."; return false; } if (draw.Angle == 0) { // Unrotated path — byte-for-byte the same call this renderer made before Sprint // 4's rotation story, so every pre-existing template (angle always 0) renders // identically and pays no extra GetTextWidth/Ascent/Descent cost per draw. if (_pdf.DrawText(draw.X, draw.Y, draw.Text) == 0) { error = $"Failed to draw text with font '{draw.FontName}' at ({draw.X}, {draw.Y}) " + $"(error code {_pdf.LastErrorCode()})."; return false; } } else { // Rotate around the element's own bounding-box center, not the (X, Y) anchor // DrawRotatedText itself rotates around — see RotatedTextAnchorCalculator for the // anchor-offset math and the empirically-confirmed sign convention. var width = _pdf.GetTextWidth(draw.Text); var ascent = _pdf.GetTextAscent(); var descent = _pdf.GetTextDescent(); var (anchorX, anchorY) = RotatedTextAnchorCalculator.ComputeAnchor( draw.X, draw.Y, width, ascent, descent, draw.Angle); // Confirmed empirically against the real DLL: DrawRotatedText rejects a negative // Angle outright (returns 0 with LastErrorCode() also 0 — no descriptive error at // all), even though it is mathematically periodic. Normalize to Debenu's expected // [0, 360) range before the call; the anchor-offset math above already used the // original signed angle (trig functions handle negative angles natively and // correctly), so this normalization is purely for the vendor call's own input // validation, not a behavior change. var normalizedAngle = ((draw.Angle % 360) + 360) % 360; if (_pdf.DrawRotatedText(anchorX, anchorY, normalizedAngle, draw.Text) == 0) { error = $"Failed to draw rotated text with font '{draw.FontName}' at " + $"({draw.X}, {draw.Y}), angle {draw.Angle} (error code {_pdf.LastErrorCode()})."; return false; } } } _pagesInCurrentBatch++; error = null; return true; } public bool Save(string outputPath, out string? error) { if (_batchFilePaths.Count == 0) { // Fast path: no periodic flush was ever triggered (the whole render fit in one // batch), so this behaves exactly as it did before batching existed — one direct // SaveToFile call, no temp files, no merge step. This is the common case for renders // under DefaultPagesPerBatch pages and keeps this change a no-op for them. if (_pdf.SaveToFile(outputPath) == 0) { error = $"Failed to save PDF to '{outputPath}' (error code {_pdf.LastErrorCode()})."; return false; } error = null; return true; } // Batching occurred: flush the final (possibly partial) batch to its own file, then // merge every batch — in order — into the requested output path via Debenu's own // file-list merge API. var finalBatchPath = NextBatchFilePath(); if (_pdf.SaveToFile(finalBatchPath) == 0) { error = $"Failed to save final batch to '{finalBatchPath}' (error code {_pdf.LastErrorCode()})."; return false; } _batchFilePaths.Add(finalBatchPath); ReleaseCurrentInstance(); return MergeBatchesInto(outputPath, out error); } /// Saves the current in-progress batch to its own temp file, releases the current /// Debenu instance, and opens a fresh one for the next batch of pages — the exact /// "periodic Save + release/reopen a fresh PDFLibrary instance" mitigation confirmed in /// code/BENCHMARK.md to reset the per-page cost curve back to its initial rate. private bool FlushCurrentBatchAndReopen(out string? error) { var batchPath = NextBatchFilePath(); if (_pdf.SaveToFile(batchPath) == 0) { error = $"Failed to save batch {_batchFilePaths.Count + 1} to '{batchPath}' " + $"(error code {_pdf.LastErrorCode()})."; return false; } _batchFilePaths.Add(batchPath); ReleaseCurrentInstance(); if (!TryOpenLibrary(_dllPath, _licenseKey, out var reopened, out error)) { return false; } _pdf = reopened!; _fontHandles.Clear(); _firstPageUsed = false; _pagesInCurrentBatch = 0; error = null; return true; } private bool MergeBatchesInto(string outputPath, out string? error) { if (!TryOpenLibrary(_dllPath, _licenseKey, out var mergePdf, out error)) { return false; } const string listName = "EnvelopeRendererBatches"; try { // ClearFileList's return value reflects how many entries it removed (0 is the // expected, non-error result for a brand-new list on this freshly-opened instance, // confirmed against the real DLL — not every DPL* function follows the "0 = failure" // convention documented on the class), so its result is intentionally not treated as // a pass/fail signal here. mergePdf!.ClearFileList(listName); foreach (var batchPath in _batchFilePaths) { if (mergePdf.AddToFileList(listName, batchPath) == 0) { error = $"Failed to add batch '{batchPath}' to the merge list " + $"(error code {mergePdf.LastErrorCode()})."; return false; } } if (mergePdf.MergeFileListFast(listName, outputPath) == 0) { error = $"Failed to merge {_batchFilePaths.Count} render batches into " + $"'{outputPath}' (error code {mergePdf.LastErrorCode()})."; return false; } error = null; return true; } finally { if (mergePdf!.LibraryLoaded()) { mergePdf.ReleaseLibrary(); } CleanupBatchFiles(); } } private string NextBatchFilePath() { Directory.CreateDirectory(_batchTempDirectory); return Path.Combine(_batchTempDirectory, $"batch-{_batchFilePaths.Count:D6}.pdf"); } private void ReleaseCurrentInstance() { if (_pdf.LibraryLoaded()) { _pdf.ReleaseLibrary(); } } private void CleanupBatchFiles() { foreach (var path in _batchFilePaths) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort cleanup only; a leftover temp file under %TEMP% is not worth // failing an otherwise-successful render over. } } try { if (Directory.Exists(_batchTempDirectory)) { Directory.Delete(_batchTempDirectory, recursive: true); } } catch (IOException) { // Best-effort cleanup only, as above. } } /// /// Fonts are added by TrueType family name (e.g. "Arial") rather than Debenu's numeric /// AddStandardFont IDs, because those IDs aren't documented anywhere in this repo and /// guessing them risks silently rendering the wrong font — a real defect for a print job. /// A missing/unresolvable font is a blocking failure per the Definition of Done's /// "missing-font behavior remains blocking" rule. /// private bool TryGetFontHandle(string fontName, out int handle, out string? error) { if (_fontHandles.TryGetValue(fontName, out handle)) { error = null; return true; } handle = _pdf.AddTrueTypeFont(fontName, Embed: 1); if (handle == 0) { error = $"Font not found or could not be embedded: '{fontName}'."; return false; } _fontHandles[fontName] = handle; error = null; return true; } public void Dispose() { if (_disposed) { return; } if (_pdf.LibraryLoaded()) { _pdf.ReleaseLibrary(); } // Covers the failure path: if a mid-render AddPage/Save call failed after one or more // batches were already flushed to disk, Save's own cleanup never ran. Safe to call again // even when Save already cleaned up successfully (CleanupBatchFiles is idempotent). CleanupBatchFiles(); _disposed = true; } }