|
- using DebenuPDFLibraryDLL1013;
-
- namespace EnvelopeRenderer.Cli.Render;
-
- /// <summary>
- /// Wraps Debenu Quick PDF Library 10.13 (loaded dynamically from <paramref name="dllPath"/> 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 <see cref="PDFLibrary.LastErrorCode"/>), so every call here is
- /// checked and turned into a descriptive error instead of failing silently.
- ///
- /// <para><b>Batching mitigation (Sprint 3):</b> Debenu's in-memory document model gets
- /// progressively more expensive to append to as more pages accumulate in a single open
- /// document — confirmed in <c>code/BENCHMARK.md</c>'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
- /// <see cref="PDFLibrary"/> 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 <see cref="_pagesPerBatch"/> 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, <see cref="Save"/> merges every batch file into the final
- /// output via Debenu's own <c>MergeFileListFast</c> 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 <see cref="_pagesPerBatch"/> pages), <see
- /// cref="Save"/> falls back to the original single <c>SaveToFile</c> call with no temp files and
- /// no merge step at all, so small renders are byte-for-byte unaffected by this change.</para>
- /// </summary>
- public sealed class DebenuPdfRenderer : IPdfRenderer
- {
- /// <summary>
- /// 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 <c>code/BENCHMARK.md</c> ("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 <c>code/BENCHMARK.md</c>).
- /// </summary>
- public const int DefaultPagesPerBatch = 300;
-
- private readonly string _dllPath;
- private readonly string? _licenseKey;
- private readonly int _pagesPerBatch;
- private readonly List<string> _batchFilePaths = new();
- private readonly string _batchTempDirectory;
- private PDFLibrary _pdf;
- private readonly Dictionary<string, int> _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);
-
- /// <summary>Overload allowing tests (and future tuning) to pick a batch size other than
- /// <see cref="DefaultPagesPerBatch"/> without changing production behavior.</summary>
- 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<TextDraw> 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);
- }
-
- /// <summary>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
- /// <c>code/BENCHMARK.md</c> to reset the per-page cost curve back to its initial rate.</summary>
- 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.
- }
- }
-
- /// <summary>
- /// 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.
- /// </summary>
- 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;
- }
- }
|